From 4924f7a0bf0a63e2f34a25ea4d5ca84e9cfe4998 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Fri, 16 Oct 2026 21:18:09 +0200 Subject: [PATCH 01/13] fix: let an expired payment that never reached the network be cancelled --- .../to/bitkit/repositories/LightningRepo.kt | 21 +++++ .../repositories/OnchainSendAttemptStore.kt | 32 +++++++ .../repositories/PaykitPaymentProofRepo.kt | 85 +++++++++++++++++++ .../to/bitkit/services/LightningService.kt | 15 ++++ .../screens/wallets/send/SendPendingScreen.kt | 75 +++++++++++++++- .../wallets/send/SendPendingViewModel.kt | 44 ++++++++++ app/src/main/res/values/strings.xml | 5 ++ .../OnchainSendAttemptStoreTest.kt | 51 +++++++++++ .../PaykitPaymentProofRepoTest.kt | 77 ++++++++++++++++- .../wallets/send/SendPendingViewModelTest.kt | 39 +++++++++ changelog.d/next/1551.fixed.md | 1 + 11 files changed, 441 insertions(+), 4 deletions(-) create mode 100644 changelog.d/next/1551.fixed.md diff --git a/app/src/main/java/to/bitkit/repositories/LightningRepo.kt b/app/src/main/java/to/bitkit/repositories/LightningRepo.kt index ff36cbda28..a909bb728d 100644 --- a/app/src/main/java/to/bitkit/repositories/LightningRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/LightningRepo.kt @@ -1861,6 +1861,26 @@ class LightningRepo @Inject constructor( suspend fun currentOnchainSendAttempt(): OnchainSendAttempt? = onchainSendAttemptStore.current() + /** Syncs the on-chain wallet, then checks that none of the attempt's candidates reached the mempool or the chain. */ + suspend fun onchainCandidatesNeverReachedNetwork( + attempt: OnchainSendAttempt, + timeout: Duration = ABANDON_SYNC_TIMEOUT, + ): Boolean { + val requestedAt = (System.currentTimeMillis() / 1000).toULong() + withTimeoutOrNull(timeout) { + sync() + while ((lightningService.status?.latestOnchainWalletSyncTimestamp ?: 0uL) <= requestedAt) { + delay(1.seconds) + } + } ?: throw OnchainSendAbandonSyncError() + return lightningService.walletKnowsNoCandidate(attempt) + } + + suspend fun abandonUnbroadcastOnchainSend( + expected: OnchainSendAttempt, + removeOriginalProof: suspend (OnchainSendAttempt) -> Boolean, + ): Boolean = onchainSendAttemptStore.abandonUnbroadcast(expected, removeOriginalProof) + suspend fun releaseInterruptedShopPreparation( removeOriginalProof: suspend (OnchainSendAttempt) -> Boolean, ): Boolean = onchainSendAttemptStore.releaseInterruptedShopPreparation(removeOriginalProof) @@ -2581,6 +2601,7 @@ class LightningRepo @Inject constructor( private val BACKGROUND_STOP_DELAY = 5.seconds private val CHANNELS_USABLE_TIMEOUT = 15.seconds private val CHANNELS_USABLE_POLL_DELAY = 1.seconds + private val ABANDON_SYNC_TIMEOUT = 30.seconds private val NO_USABLE_CHANNELS_FEEDBACK_DELAY = 2_500.milliseconds /** Max time to wait for a starting node before its id is treated as unavailable. */ diff --git a/app/src/main/java/to/bitkit/repositories/OnchainSendAttemptStore.kt b/app/src/main/java/to/bitkit/repositories/OnchainSendAttemptStore.kt index 907ffe1c08..8d8a0a6f5b 100644 --- a/app/src/main/java/to/bitkit/repositories/OnchainSendAttemptStore.kt +++ b/app/src/main/java/to/bitkit/repositories/OnchainSendAttemptStore.kt @@ -112,11 +112,22 @@ data class OnchainSendAttempt( val blocksNextSend: Boolean get() = isUnresolved || (hasPositiveEvidence && !localFollowupComplete) + + /** A Shop payment from the node wallet that dispatched but never had its acceptance confirmed. */ + val canBeAbandoned: Boolean + get() = isUnresolved && requestId != null && !isTransfer && walletId == WalletScope.default && + !payerIdentity.isNullOrBlank() && initialDispatchAttempted && txid != null && + !originalInputs.isNullOrEmpty() && candidateTxids.isNotEmpty() } class OnchainSendBlockedError(val attempt: OnchainSendAttempt? = null) : AppError("A previous on-chain send is unresolved or this payment was already sent.") +class OnchainSendAbandonRefusedError(val requestOpen: Boolean) : + AppError("This on-chain payment may still reach the network or its request is still open.") + +class OnchainSendAbandonSyncError : AppError("The on-chain wallet could not sync before releasing a payment.") + class OnchainSendAttemptUnreadableError(cause: Throwable) : AppError("Failed to read on-chain send attempt", cause) class OnchainSendNotDispatchedError(cause: Throwable) : AppError("On-chain send did not reach the backend", cause) @@ -250,6 +261,27 @@ class OnchainSendAttemptStore @Inject constructor( } } + /** + * Releases a Shop payment whose request has ended once the caller has shown that none of its candidates + * reached the network. The original proof is removed first, so a crash in between leaves an attempt that + * a later call can still release. + */ + suspend fun abandonUnbroadcast( + expected: OnchainSendAttempt, + removeOriginalProof: suspend (OnchainSendAttempt) -> Boolean, + ): Boolean = withContext(ioDispatcher + NonCancellable) { + mutex.withLock { + val current = loadWithRetainedAccepted(expected.walletIndex) ?: return@withLock false + if (current != expected || !current.canBeAbandoned || current.attemptId in inFlightPreparations) { + return@withLock false + } + if (!removeOriginalProof(current)) return@withLock false + keychain.delete(KEY, current.walletIndex) + _backupStateVersion.update { it + 1 } + true + } + } + suspend fun retainPreparedReceipt( attemptId: String, walletIndex: Int, diff --git a/app/src/main/java/to/bitkit/repositories/PaykitPaymentProofRepo.kt b/app/src/main/java/to/bitkit/repositories/PaykitPaymentProofRepo.kt index 6878087a7c..316a8fffc7 100644 --- a/app/src/main/java/to/bitkit/repositories/PaykitPaymentProofRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/PaykitPaymentProofRepo.kt @@ -2,6 +2,8 @@ package to.bitkit.repositories import com.synonym.paykit.BillingPeriod import com.synonym.paykit.PaymentProofRecord +import com.synonym.paykit.PaymentRequestLifecycleState +import com.synonym.paykit.PaymentRequestLocalRole import com.synonym.paykit.PubkyIdentityCapability import dagger.Lazy import kotlinx.coroutines.CoroutineDispatcher @@ -409,6 +411,89 @@ class PaykitPaymentProofRepo @Inject constructor( } } + /** + * Whether the request behind an unresolved Shop payment can no longer be paid: the payee ended it, or its + * deadline passed. A record that is missing, carries a proof or needs recovery never counts as ended. + */ + suspend fun onchainRequestHasEnded(attempt: OnchainSendAttempt): Boolean = withContext(ioDispatcher) { + val requestId = attempt.requestId ?: return@withContext false + if (!attempt.canBeAbandoned) return@withContext false + val identity = currentIdentity() ?: return@withContext false + if (!PubkyPublicKeyFormat.matches(identity, attempt.payerIdentity)) return@withContext false + val record = paykitSdkService.paymentRequests().firstOrNull { + it.localRole == PaymentRequestLocalRole.PAYER && it.paymentRequestId == requestId.paymentRequestId && + PubkyPublicKeyFormat.matches(it.counterparty, requestId.counterparty) + } ?: return@withContext false + if (record.paymentProofs.isNotEmpty()) return@withContext false + when (record.state) { + PaymentRequestLifecycleState.PROPOSAL_EXPIRED, + PaymentRequestLifecycleState.REJECTED, + PaymentRequestLifecycleState.CANCELED -> true + PaymentRequestLifecycleState.PROPOSED, + PaymentRequestLifecycleState.ACCEPTED, + PaymentRequestLifecycleState.ACTIVE_RECURRING -> { + val now = Clock.System.now() + val request = runCatching { record.toPaykitPaymentRequestHistory(now) }.getOrNull() + ?: return@withContext false + request.isExpired(now) || quoteDeadlinePassed(request, identity, now) + } + else -> false + } + } + + private suspend fun quoteDeadlinePassed(request: PaykitPaymentRequest, identity: String, now: Instant): Boolean { + val proof = operationMutex.withLock { + loadProofs().lastOrNull { + it.kind == PaykitPaymentProofKind.Onchain && it.requestId == request.id && + PubkyPublicKeyFormat.matches(it.identity, identity) + } + } + val quoteId = proof?.conversionQuoteId ?: return false + val method = MethodId.entries.firstOrNull { it.rawValue == proof.paymentEndpointIdentifier && it.isOnchain } + ?: return false + val payment = runCatching { request.payment(method, now, quoteId) }.getOrNull() ?: return false + return request.submissionDeadline(payment)?.let { now > it } == true + } + + /** + * Releases an unresolved Shop payment whose request has ended once [neverReachedNetwork] shows that none of its + * transactions reached the mempool or the chain. Its original proof is removed so the request stays unpaid. + */ + suspend fun abandonUnbroadcastOnchainPayment( + attempt: OnchainSendAttempt, + neverReachedNetwork: suspend (OnchainSendAttempt) -> Boolean, + ): Result = withContext(ioDispatcher) { + runSuspendCatching { + if (!onchainRequestHasEnded(attempt)) throw OnchainSendAbandonRefusedError(requestOpen = true) + if (!neverReachedNetwork(attempt)) throw OnchainSendAbandonRefusedError(requestOpen = false) + val released = lightningRepo.abandonUnbroadcastOnchainSend(attempt) { current -> + operationMutex.withLock { + val proofs = loadProofs() + val original = proofs.filter { + it.kind == PaykitPaymentProofKind.Onchain && it.requestId == current.requestId && + PubkyPublicKeyFormat.matches(it.identity, requireNotNull(current.payerIdentity)) + } + val safe = original.all { + it.onchainWalletId == current.walletId && it.proofData == null && + !it.onchainAcceptanceVerified && + (it.paymentIdentifier == null || current.candidateTxids.any { txid -> + txid.equals(it.paymentIdentifier, ignoreCase = true) + }) + } + if (!safe) return@withLock false + for (proof in original) releaseDeniedPrivateConsumption(proof) + if (original.isNotEmpty()) persist(proofs - original.toSet()) + true + } + } + if (!released) throw OnchainSendBlockedError(attempt) + Logger.info( + "Released on-chain payment ${attempt.txid} after its request ended without it reaching the network", + context = TAG, + ) + } + } + suspend fun completeRecoveredOnchainPayment(request: PaykitPaymentRequest, attempt: OnchainSendAttempt): Boolean = withContext(ioDispatcher) { val proof = operationMutex.withLock { diff --git a/app/src/main/java/to/bitkit/services/LightningService.kt b/app/src/main/java/to/bitkit/services/LightningService.kt index 80cf654b01..4c5390da79 100644 --- a/app/src/main/java/to/bitkit/services/LightningService.kt +++ b/app/src/main/java/to/bitkit/services/LightningService.kt @@ -1028,6 +1028,21 @@ class LightningService internal constructor( } } + /** + * Whether the wallet knows none of the attempt's candidates while it still knows the transactions its coins + * came from. A candidate that reached the mempool or the chain spends wallet coins, so a sync adds it. + */ + suspend fun walletKnowsNoCandidate(attempt: OnchainSendAttempt): Boolean { + val originalNode = node ?: throw ServiceError.NodeNotSetup() + val inputs = attempt.originalInputs.orEmpty() + if (inputs.isEmpty() || attempt.walletIndex != currentWalletIndex) return false + return callOnchainSend { + if (currentWalletIndex != attempt.walletIndex || node !== originalNode) throw ServiceError.NodeNotSetup() + attempt.candidateTxids.none { originalNode.getTransactionDetails(it) != null } && + inputs.all { originalNode.getTransactionDetails(it.txid) != null } + } + } + internal suspend fun callOnchainSend(block: suspend () -> T): T = ServiceQueue.LDK.background(ldkQueue) { runSuspendCatching { block() } }.getOrThrow() diff --git a/app/src/main/java/to/bitkit/ui/screens/wallets/send/SendPendingScreen.kt b/app/src/main/java/to/bitkit/ui/screens/wallets/send/SendPendingScreen.kt index 96d6d8326b..282c631f3a 100644 --- a/app/src/main/java/to/bitkit/ui/screens/wallets/send/SendPendingScreen.kt +++ b/app/src/main/java/to/bitkit/ui/screens/wallets/send/SendPendingScreen.kt @@ -30,6 +30,7 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.rotate +import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.input.KeyboardType @@ -39,13 +40,13 @@ import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.compose.collectAsStateWithLifecycle import kotlinx.coroutines.flow.filterNotNull import to.bitkit.R +import to.bitkit.models.Toast import to.bitkit.models.WalletScope -import to.bitkit.repositories.OnchainRecoveryFeeRate import to.bitkit.repositories.OnchainPreparedReceipt +import to.bitkit.repositories.OnchainRecoveryFeeRate import to.bitkit.repositories.OnchainSendAttempt import to.bitkit.repositories.OnchainSendOutcome import to.bitkit.repositories.PendingPaymentResolution -import to.bitkit.ui.scaffold.AppAlertDialog import to.bitkit.ui.components.BalanceHeaderView import to.bitkit.ui.components.BiometricsView import to.bitkit.ui.components.BodyM @@ -54,9 +55,11 @@ import to.bitkit.ui.components.PrimaryButton import to.bitkit.ui.components.SecondaryButton import to.bitkit.ui.components.TextInput import to.bitkit.ui.components.VerticalSpacer +import to.bitkit.ui.scaffold.AppAlertDialog import to.bitkit.ui.scaffold.SheetTopBar import to.bitkit.ui.settingsViewModel import to.bitkit.ui.shared.modifiers.sheetHeight +import to.bitkit.ui.shared.toast.ToastEventBus import to.bitkit.ui.shared.util.gradientBackground import to.bitkit.ui.theme.AppThemeSurface import to.bitkit.ui.theme.Colors @@ -108,6 +111,18 @@ fun SendPendingScreen( } } + var showAbandonConfirmation by remember { mutableStateOf(false) } + AbandonExpiredPayment( + isAbandoned = uiState.abandoned, + showConfirmation = showAbandonConfirmation, + onConfirm = { + showAbandonConfirmation = false + viewModel.abandonExpired() + }, + onDismiss = { showAbandonConfirmation = false }, + onClose = onClose, + ) + uiState.recoveredTxid?.let { winner -> LaunchedEffect(winner) { onRecovered(winner, uiState.amount) } } @@ -131,12 +146,52 @@ fun SendPendingScreen( onViewDetails = onViewDetails, canRetry = uiState.recoveryAttempt != null, isRecovering = uiState.isRecovering, - recoveryError = uiState.recoveryErrorMessage(), + recoveryError = uiState.abandonErrorMessage() ?: uiState.recoveryErrorMessage(), onRetry = onRetry, + canAbandon = uiState.abandonableAttempt != null, + isAbandoning = uiState.isAbandoning, + onAbandon = { showAbandonConfirmation = true }, ) } } +@Composable +private fun AbandonExpiredPayment( + isAbandoned: Boolean, + showConfirmation: Boolean, + onConfirm: () -> Unit, + onDismiss: () -> Unit, + onClose: () -> Unit, +) { + val abandonedToast = stringResource(R.string.wallet__send_pending__abandon_done) + LaunchedEffect(isAbandoned) { + if (isAbandoned) { + ToastEventBus.send(type = Toast.ToastType.SUCCESS, title = abandonedToast) + onClose() + } + } + if (showConfirmation) { + AppAlertDialog( + title = stringResource(R.string.wallet__send_pending__abandon), + text = stringResource(R.string.wallet__send_pending__abandon_note), + confirmText = stringResource(R.string.common__continue), + dismissText = stringResource(R.string.common__cancel), + onConfirm = onConfirm, + onDismiss = onDismiss, + ) + } +} + +@Composable +private fun SendPendingUiState.abandonErrorMessage(): String? = abandonError?.let { + stringResource( + when (it) { + AbandonError.MayStillConfirm -> R.string.wallet__send_pending__abandon_refused + AbandonError.SyncUnavailable -> R.string.wallet__send_pending__abandon_sync + } + ) +} + @Composable private fun SendPendingUiState.recoveryErrorMessage(): String? = recoveryError?.let { stringResource( @@ -295,6 +350,9 @@ internal fun SendPendingContent( isRecovering: Boolean = false, recoveryError: String? = null, onRetry: () -> Unit = {}, + canAbandon: Boolean = false, + isAbandoning: Boolean = false, + onAbandon: () -> Unit = {}, ) { Column( modifier = modifier @@ -361,6 +419,17 @@ internal fun SendPendingContent( ) VerticalSpacer(16.dp) } + if (canAbandon) { + SecondaryButton( + text = stringResource(R.string.wallet__send_pending__abandon), + enabled = !isAbandoning, + onClick = onAbandon, + modifier = Modifier + .fillMaxWidth() + .testTag("AbandonExpiredOnchainPayment"), + ) + VerticalSpacer(16.dp) + } Row(horizontalArrangement = Arrangement.spacedBy(16.dp)) { SecondaryButton( text = stringResource(R.string.wallet__send_details), diff --git a/app/src/main/java/to/bitkit/ui/screens/wallets/send/SendPendingViewModel.kt b/app/src/main/java/to/bitkit/ui/screens/wallets/send/SendPendingViewModel.kt index e510d36947..d0e237582f 100644 --- a/app/src/main/java/to/bitkit/ui/screens/wallets/send/SendPendingViewModel.kt +++ b/app/src/main/java/to/bitkit/ui/screens/wallets/send/SendPendingViewModel.kt @@ -22,8 +22,10 @@ import to.bitkit.repositories.ActivityRepo import to.bitkit.repositories.LightningRepo import to.bitkit.repositories.OnchainRecoveryFeeRate import to.bitkit.repositories.OnchainPreparedReceipt +import to.bitkit.repositories.OnchainSendAbandonSyncError import to.bitkit.repositories.OnchainSendAttempt import to.bitkit.repositories.OnchainSendOutcome +import to.bitkit.repositories.PaykitPaymentProofRepo import to.bitkit.repositories.PendingPaymentRepo import to.bitkit.repositories.PendingPaymentResolution import to.bitkit.utils.Logger @@ -35,6 +37,7 @@ class SendPendingViewModel @Inject constructor( private val pendingPaymentRepo: PendingPaymentRepo, private val activityRepo: ActivityRepo, private val lightningRepo: LightningRepo, + private val paykitPaymentProofRepo: PaykitPaymentProofRepo, ) : ViewModel() { companion object { @@ -78,6 +81,7 @@ class SendPendingViewModel @Inject constructor( if (matchesOriginalWallet && hasOriginalReceipt && attempt != null) { if (attempt.isUnresolved) _uiState.update { it.copy(recoveryAttempt = attempt) } observeOriginalCompletion(attempt) + offerAbandonIfRequestEnded(attempt) } } } @@ -167,6 +171,40 @@ class SendPendingViewModel @Inject constructor( } } + private suspend fun offerAbandonIfRequestEnded(attempt: OnchainSendAttempt) { + if (!attempt.canBeAbandoned) return + if (runSuspendCatching { paykitPaymentProofRepo.onchainRequestHasEnded(attempt) }.getOrDefault(false)) { + _uiState.update { it.copy(abandonableAttempt = attempt) } + } + } + + fun abandonExpired() { + val attempt = _uiState.value.abandonableAttempt ?: return + if (_uiState.value.isAbandoning) return + _uiState.update { it.copy(isAbandoning = true, abandonError = null) } + viewModelScope.launch { + paykitPaymentProofRepo.abandonUnbroadcastOnchainPayment(attempt) { + lightningRepo.onchainCandidatesNeverReachedNetwork(it) + }.onSuccess { + _uiState.update { + it.copy(isAbandoning = false, abandoned = true, abandonableAttempt = null, recoveryAttempt = null) + } + }.onFailure { error -> + Logger.warn("Could not release on-chain payment ${attempt.txid}", error, context = TAG) + _uiState.update { + it.copy( + isAbandoning = false, + abandonError = if (error is OnchainSendAbandonSyncError) { + AbandonError.SyncUnavailable + } else { + AbandonError.MayStillConfirm + }, + ) + } + } + } + } + suspend fun approveRecoveryFee( receipt: OnchainPreparedReceipt, warnings: List, @@ -264,8 +302,14 @@ data class SendPendingUiState( val currentTxid: String? = null, val recoveredTxid: String? = null, val recoveredTransfer: OnchainSendAttempt? = null, + val abandonableAttempt: OnchainSendAttempt? = null, + val isAbandoning: Boolean = false, + val abandoned: Boolean = false, + val abandonError: AbandonError? = null, ) +enum class AbandonError { MayStillConfirm, SyncUnavailable } + sealed interface RecoveryApproval { data class Fee(val receipt: OnchainPreparedReceipt) : RecoveryApproval data class Warning(val warning: SanityWarning) : RecoveryApproval diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 0076334f3d..34fb9ca2c9 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1633,6 +1633,11 @@ MAX The maximum spendable amount is a bit lower due to a required reserve balance. Reserve Balance + Cancel expired payment + Payment cancelled. Its coins are available again. + This payment\'s request has ended. Bitkit first syncs with the network and cancels the payment only if it never reached it. Its coins then become available again. + This payment may still reach the network, so it cannot be cancelled. + Bitkit could not sync with the network. Check your connection and try again. The new payment was not sent. Finish the previous payment before trying again. This payment is taking a bit longer than expected. You can continue using Bitkit. Funding transaction is unresolved. Check its transaction before trying again. diff --git a/app/src/test/java/to/bitkit/repositories/OnchainSendAttemptStoreTest.kt b/app/src/test/java/to/bitkit/repositories/OnchainSendAttemptStoreTest.kt index 6ae148f70c..0335550bc6 100644 --- a/app/src/test/java/to/bitkit/repositories/OnchainSendAttemptStoreTest.kt +++ b/app/src/test/java/to/bitkit/repositories/OnchainSendAttemptStoreTest.kt @@ -737,6 +737,57 @@ class OnchainSendAttemptStoreTest : BaseUnitTest() { assertEquals(txid, durable.txid) } + @Test + fun `abandon releases an unresolved Shop payment only after its proof is removed`() = test { + var saved: String? = null + val keychain = mock() + whenever(keychain.loadString(key, 0)).thenAnswer { saved } + whenever(keychain.upsertString(eq(key), any(), eq(0))).doSuspendableAnswer { saved = it.getArgument(1) } + whenever(keychain.delete(key, 0)).doSuspendableAnswer { saved = null } + val store = OnchainSendAttemptStore(testDispatcher, keychain, mock(), kotlin.time.Clock.System) + val attempt = store.admit( + walletId = WalletScope.default, requestId = PaykitPaymentRequestId("request", "payer"), + orderId = "order", address = "bcrt1qrecipient", amountSats = 1000uL, + isMaxAmount = false, feeRateSatsPerVByte = 1uL, isTransfer = false, + channelId = null, tags = emptyList(), beforeSendAttempt = {}, payerIdentity = "payer", + ) + val receipt = OnchainPreparedReceipt( + "ab".repeat(32), listOf(OnchainSendInput("11".repeat(32), 0u)), attempt.address, attempt.amountSats, + ) + store.retainPreparedReceipt(attempt.attemptId, 0, receipt, false) + val outcome = store.broadcastPreparedCandidate(attempt.attemptId, 0, receipt.txid) { beforeDispatch -> + beforeDispatch() + OnchainSendOutcome.Unknown(receipt.txid) + } + store.recordOutcome(attempt.attemptId, outcome, 0) + val unknown = requireNotNull(store.current()) + assertTrue(unknown.canBeAbandoned) + + assertFalse(store.abandonUnbroadcast(unknown) { false }) + assertEquals(unknown, store.current()) + assertFalse(store.abandonUnbroadcast(unknown.copy(amountSats = 999uL)) { true }) + assertEquals(unknown, store.current()) + assertTrue(store.abandonUnbroadcast(unknown) { true }) + assertNull(store.current()) + assertTrue(store.admitForTest(walletId = WalletScope.default).attemptId != attempt.attemptId) + } + + @Test + fun `ordinary payment is never abandoned`() = test { + var saved: String? = null + val keychain = mock() + whenever(keychain.loadString(key, 0)).thenAnswer { saved } + whenever(keychain.upsertString(eq(key), any(), eq(0))).doSuspendableAnswer { saved = it.getArgument(1) } + val store = OnchainSendAttemptStore(testDispatcher, keychain, mock(), kotlin.time.Clock.System) + val attempt = store.admitForTest(walletId = WalletScope.default) + store.recordOutcome(attempt.attemptId, OnchainSendOutcome.Unknown("ab".repeat(32)), attempt.walletIndex) + val unknown = requireNotNull(store.current()) + + assertFalse(unknown.canBeAbandoned) + assertFalse(store.abandonUnbroadcast(unknown) { true }) + assertEquals(unknown, store.current()) + } + private suspend fun OnchainSendAttemptStore.admitForTest( walletId: String = "wallet-1", contactPublicKey: String? = null, diff --git a/app/src/test/java/to/bitkit/repositories/PaykitPaymentProofRepoTest.kt b/app/src/test/java/to/bitkit/repositories/PaykitPaymentProofRepoTest.kt index de689004ea..4e48cb9291 100644 --- a/app/src/test/java/to/bitkit/repositories/PaykitPaymentProofRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/PaykitPaymentProofRepoTest.kt @@ -2514,6 +2514,53 @@ class PaykitPaymentProofRepoTest : BaseUnitTest(StandardTestDispatcher()) { verify(hwWalletRepo, never()).broadcastFunding(any(), org.mockito.kotlin.anyOrNull()) } + @Test + fun `ended Shop request releases a payment that never reached the network`() = test { + for (state in listOf( + PaymentRequestLifecycleState.CANCELED, + PaymentRequestLifecycleState.REJECTED, + PaymentRequestLifecycleState.PROPOSAL_EXPIRED, + )) { + val attempt = unknownShopAttempt("ab".repeat(32)) + storedProofs = listOf(startedOnchainProof(attempt)) + whenever(paykitSdkService.paymentRequests()).thenReturn(listOf(paymentRequestRecord(state = state))) + stubAbandon(attempt) + var checked: OnchainSendAttempt? = null + + val result = paymentProofRepo().abandonUnbroadcastOnchainPayment(attempt) { + checked = it + true + } + + assertTrue(result.isSuccess) + assertEquals(attempt, checked) + assertTrue(storedProofs.isEmpty()) + } + } + + @Test + fun `payment that may still confirm or whose request is open keeps its proof`() = test { + val attempt = unknownShopAttempt("ab".repeat(32)) + val proof = startedOnchainProof(attempt) + stubAbandon(attempt) + val cases = listOf( + Triple(paymentRequestRecord(state = PaymentRequestLifecycleState.ACCEPTED), true, true), + Triple(paymentRequestRecord(state = PaymentRequestLifecycleState.PROOF_SUBMITTED), true, true), + Triple(paymentRequestRecord(state = PaymentRequestLifecycleState.CANCELED), false, false), + ) + for ((record, neverReached, requestOpen) in cases) { + storedProofs = listOf(proof) + whenever(paykitSdkService.paymentRequests()).thenReturn(listOf(record)) + + val error = paymentProofRepo().abandonUnbroadcastOnchainPayment(attempt) { neverReached }.exceptionOrNull() + + assertEquals(requestOpen, (error as OnchainSendAbandonRefusedError).requestOpen) + assertEquals(listOf(proof), storedProofs) + } + whenever(paykitSdkService.paymentRequests()).thenReturn(emptyList()) + assertFalse(paymentProofRepo().onchainRequestHasEnded(attempt)) + } + private fun TestScope.observeRequestStateChanges() { paymentProofRepo(projectionStore).paymentRequestStateChanges(projectionIdentity) .onEach { requestStateChanges++ } @@ -2590,6 +2637,33 @@ class PaykitPaymentProofRepoTest : BaseUnitTest(StandardTestDispatcher()) { privatePaykitRepo = dagger.Lazy { privatePaykitRepo }, ) + private fun unknownShopAttempt(txid: String) = acceptedAttempt( + paymentRequest(MethodId.entries.first { it.isOnchain }.rawValue), txid, + ).copy( + evidence = OnchainSendEvidence.Unknown, + payerIdentity = LOCAL_IDENTITY, + originalInputs = listOf(OnchainSendInput("11".repeat(32), 0u)), + candidateTxids = listOf(txid), + ) + + private fun startedOnchainProof(attempt: OnchainSendAttempt) = PendingPaykitPaymentProof( + identity = LOCAL_IDENTITY, + requestId = requireNotNull(attempt.requestId), + paymentEndpointIdentifier = MethodId.entries.first { it.isOnchain }.rawValue, + kind = PaykitPaymentProofKind.Onchain, + paymentStarted = true, + paymentIdentifier = attempt.txid, + onchainAddress = attempt.address, + onchainAmountSats = attempt.amountSats, + paymentAppId = "bitkit", + ) + + private suspend fun stubAbandon(attempt: OnchainSendAttempt) { + whenever(lightningRepo.abandonUnbroadcastOnchainSend(any(), any())).doSuspendableAnswer { + it.getArgument Boolean>(1).invoke(attempt) + } + } + private fun acceptedAttempt(request: PaykitPaymentRequest, txid: String) = OnchainSendAttempt( walletId = WalletScope.default, attemptId = "attempt-1", @@ -2637,11 +2711,12 @@ class PaykitPaymentProofRepoTest : BaseUnitTest(StandardTestDispatcher()) { private fun paymentRequestRecord( paymentProofs: List = emptyList(), paymentRequestId: String = PAYMENT_REQUEST_ID, + state: PaymentRequestLifecycleState = PaymentRequestLifecycleState.PROPOSED, ) = PaymentRequestRecord( counterparty = COUNTERPARTY, paymentRequestId = paymentRequestId, localRole = PaymentRequestLocalRole.PAYER, - state = PaymentRequestLifecycleState.PROPOSED, + state = state, proposalStreamItemId = 1uL, proposalOutboundMessageId = null, proposalOutboundStatus = null, diff --git a/app/src/test/java/to/bitkit/ui/screens/wallets/send/SendPendingViewModelTest.kt b/app/src/test/java/to/bitkit/ui/screens/wallets/send/SendPendingViewModelTest.kt index f557d4d0fb..7a01584424 100644 --- a/app/src/test/java/to/bitkit/ui/screens/wallets/send/SendPendingViewModelTest.kt +++ b/app/src/test/java/to/bitkit/ui/screens/wallets/send/SendPendingViewModelTest.kt @@ -20,10 +20,13 @@ import to.bitkit.models.WalletScope import to.bitkit.repositories.ActivityRepo import to.bitkit.repositories.LightningRepo import to.bitkit.repositories.OnchainPreparedReceipt +import to.bitkit.repositories.OnchainSendAbandonSyncError import to.bitkit.repositories.OnchainSendAttempt import to.bitkit.repositories.OnchainSendEvidence import to.bitkit.repositories.OnchainSendInput import to.bitkit.repositories.OnchainSendOutcome +import to.bitkit.repositories.PaykitPaymentProofRepo +import to.bitkit.repositories.PaykitPaymentRequestId import to.bitkit.repositories.PendingPaymentRepo import to.bitkit.repositories.PendingPaymentResolution import to.bitkit.test.BaseUnitTest @@ -38,6 +41,7 @@ class SendPendingViewModelTest : BaseUnitTest() { private val pendingPaymentRepo = PendingPaymentRepo() private val activityRepo: ActivityRepo = mock() private val lightningRepo: LightningRepo = mock() + private val paykitPaymentProofRepo: PaykitPaymentProofRepo = mock() private val attemptUpdates = MutableStateFlow(0L) @@ -268,6 +272,40 @@ class SendPendingViewModelTest : BaseUnitTest() { org.mockito.kotlin.verify(activityRepo, org.mockito.kotlin.never()).syncActivities() } + @Test + fun `expired Shop payment can be cancelled once the network never saw it`() = test { + val original = pendingOriginal().copy( + requestId = PaykitPaymentRequestId("request", "payee"), orderId = "order", payerIdentity = "payer", + ) + whenever(lightningRepo.currentOnchainSendAttempt()).thenReturn(original) + whenever(paykitPaymentProofRepo.onchainRequestHasEnded(original)).thenReturn(true) + whenever(paykitPaymentProofRepo.abandonUnbroadcastOnchainPayment(any(), any())) + .thenReturn(Result.failure(OnchainSendAbandonSyncError()), Result.success(Unit)) + sut.initOnchain(original.txid, amount) + advanceUntilIdle() + assertEquals(original, sut.uiState.value.abandonableAttempt) + + sut.abandonExpired() + advanceUntilIdle() + assertEquals(AbandonError.SyncUnavailable, sut.uiState.value.abandonError) + assertEquals(false, sut.uiState.value.abandoned) + + sut.abandonExpired() + advanceUntilIdle() + assertEquals(true, sut.uiState.value.abandoned) + assertNull(sut.uiState.value.abandonableAttempt) + assertNull(sut.uiState.value.recoveryAttempt) + } + + @Test + fun `ordinary unknown payment offers no cancel`() = test { + val original = pendingOriginal() + whenever(lightningRepo.currentOnchainSendAttempt()).thenReturn(original) + sut.initOnchain(original.txid, amount) + advanceUntilIdle() + assertNull(sut.uiState.value.abandonableAttempt) + } + private fun pendingOriginal() = OnchainSendAttempt( walletId = WalletScope.default, attemptId = "original", requestId = null, orderId = null, address = "original-address", amountSats = 1_000uL, isMaxAmount = false, @@ -430,5 +468,6 @@ class SendPendingViewModelTest : BaseUnitTest() { pendingPaymentRepo = pendingPaymentRepo, activityRepo = activityRepo, lightningRepo = lightningRepo, + paykitPaymentProofRepo = paykitPaymentProofRepo, ) } diff --git a/changelog.d/next/1551.fixed.md b/changelog.d/next/1551.fixed.md new file mode 100644 index 0000000000..69ac092b72 --- /dev/null +++ b/changelog.d/next/1551.fixed.md @@ -0,0 +1 @@ +An on-chain payment whose request expired before it reached the network can now be cancelled, so it no longer blocks new on-chain payments. From 8505d2e196dc6e03f4947820eb22e983c832f7ee Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Fri, 16 Oct 2026 21:34:33 +0200 Subject: [PATCH 02/13] fix: recheck the cancel while payment pending stays open --- .../send/SendPendingObservationDeviceTest.kt | 5 +++- .../to/bitkit/repositories/LightningRepo.kt | 2 +- .../repositories/PaykitPaymentProofRepo.kt | 12 +++++----- .../wallets/send/SendPendingViewModel.kt | 16 ++++++++++--- .../OnchainSendAttemptStoreTest.kt | 5 +++- .../PaykitPaymentProofRepoTest.kt | 3 ++- .../wallets/send/SendPendingViewModelTest.kt | 24 ++++++++++++++++++- 7 files changed, 53 insertions(+), 14 deletions(-) diff --git a/app/src/androidTest/java/to/bitkit/ui/screens/wallets/send/SendPendingObservationDeviceTest.kt b/app/src/androidTest/java/to/bitkit/ui/screens/wallets/send/SendPendingObservationDeviceTest.kt index a6fdf4900e..438d0ebf8b 100644 --- a/app/src/androidTest/java/to/bitkit/ui/screens/wallets/send/SendPendingObservationDeviceTest.kt +++ b/app/src/androidTest/java/to/bitkit/ui/screens/wallets/send/SendPendingObservationDeviceTest.kt @@ -33,6 +33,7 @@ import to.bitkit.repositories.ContactPaymentSettingsRepo import to.bitkit.repositories.LightningRepo import to.bitkit.repositories.OnchainSendAttemptStore import to.bitkit.repositories.OnchainSendEvidence +import to.bitkit.repositories.PaykitPaymentProofRepo import to.bitkit.repositories.PendingPaymentRepo import to.bitkit.repositories.PrivatePaykitRepo import to.bitkit.repositories.PubkyRepo @@ -66,6 +67,8 @@ class SendPendingObservationDeviceTest { @Inject lateinit var activityRepo: ActivityRepo + @Inject lateinit var paykitPaymentProofRepo: PaykitPaymentProofRepo + @Inject lateinit var settingsStore: SettingsStore @Inject lateinit var pubkyRepo: PubkyRepo @@ -119,7 +122,7 @@ class SendPendingObservationDeviceTest { assertEquals(original.attemptId, pending.attemptId) assertEquals(original.originalInputs, store.current()?.originalInputs) assertEquals(original.candidateTxids, store.current()?.candidateTxids) - val vm = SendPendingViewModel(PendingPaymentRepo(), activityRepo, lightningRepo) + val vm = SendPendingViewModel(PendingPaymentRepo(), activityRepo, lightningRepo, paykitPaymentProofRepo) val settings = SettingsViewModel( context, settingsStore, diff --git a/app/src/main/java/to/bitkit/repositories/LightningRepo.kt b/app/src/main/java/to/bitkit/repositories/LightningRepo.kt index a909bb728d..ca940d14a8 100644 --- a/app/src/main/java/to/bitkit/repositories/LightningRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/LightningRepo.kt @@ -1861,7 +1861,7 @@ class LightningRepo @Inject constructor( suspend fun currentOnchainSendAttempt(): OnchainSendAttempt? = onchainSendAttemptStore.current() - /** Syncs the on-chain wallet, then checks that none of the attempt's candidates reached the mempool or the chain. */ + /** Syncs the on-chain wallet, then checks that none of the attempt's candidates reached the mempool or chain. */ suspend fun onchainCandidatesNeverReachedNetwork( attempt: OnchainSendAttempt, timeout: Duration = ABANDON_SYNC_TIMEOUT, diff --git a/app/src/main/java/to/bitkit/repositories/PaykitPaymentProofRepo.kt b/app/src/main/java/to/bitkit/repositories/PaykitPaymentProofRepo.kt index 316a8fffc7..a8c6af9a76 100644 --- a/app/src/main/java/to/bitkit/repositories/PaykitPaymentProofRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/PaykitPaymentProofRepo.kt @@ -473,12 +473,12 @@ class PaykitPaymentProofRepo @Inject constructor( it.kind == PaykitPaymentProofKind.Onchain && it.requestId == current.requestId && PubkyPublicKeyFormat.matches(it.identity, requireNotNull(current.payerIdentity)) } - val safe = original.all { - it.onchainWalletId == current.walletId && it.proofData == null && - !it.onchainAcceptanceVerified && - (it.paymentIdentifier == null || current.candidateTxids.any { txid -> - txid.equals(it.paymentIdentifier, ignoreCase = true) - }) + val safe = original.all { proof -> + val identifier = proof.paymentIdentifier + val isCandidate = identifier == null || + current.candidateTxids.any { it.equals(identifier, ignoreCase = true) } + proof.onchainWalletId == current.walletId && proof.proofData == null && + !proof.onchainAcceptanceVerified && isCandidate } if (!safe) return@withLock false for (proof in original) releaseDeniedPrivateConsumption(proof) diff --git a/app/src/main/java/to/bitkit/ui/screens/wallets/send/SendPendingViewModel.kt b/app/src/main/java/to/bitkit/ui/screens/wallets/send/SendPendingViewModel.kt index d0e237582f..67f3ec6a16 100644 --- a/app/src/main/java/to/bitkit/ui/screens/wallets/send/SendPendingViewModel.kt +++ b/app/src/main/java/to/bitkit/ui/screens/wallets/send/SendPendingViewModel.kt @@ -6,6 +6,7 @@ import androidx.lifecycle.viewModelScope import com.synonym.bitkitcore.ActivityFilter import com.synonym.bitkitcore.PaymentType import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.filter @@ -31,6 +32,7 @@ import to.bitkit.repositories.PendingPaymentResolution import to.bitkit.utils.Logger import to.bitkit.viewmodels.SanityWarning import javax.inject.Inject +import kotlin.time.Duration.Companion.seconds @HiltViewModel class SendPendingViewModel @Inject constructor( @@ -42,6 +44,7 @@ class SendPendingViewModel @Inject constructor( companion object { private const val TAG = "SendPendingViewModel" + private val ABANDON_CHECK_INTERVAL = 30.seconds } private val _uiState = MutableStateFlow(SendPendingUiState()) @@ -171,10 +174,17 @@ class SendPendingViewModel @Inject constructor( } } - private suspend fun offerAbandonIfRequestEnded(attempt: OnchainSendAttempt) { + // The request can end, or a failed check recover, while Payment Pending stays open. + private fun offerAbandonIfRequestEnded(attempt: OnchainSendAttempt) { if (!attempt.canBeAbandoned) return - if (runSuspendCatching { paykitPaymentProofRepo.onchainRequestHasEnded(attempt) }.getOrDefault(false)) { - _uiState.update { it.copy(abandonableAttempt = attempt) } + viewModelScope.launch { + while (_uiState.value.recoveryAttempt == attempt && _uiState.value.abandonableAttempt == null) { + if (runSuspendCatching { paykitPaymentProofRepo.onchainRequestHasEnded(attempt) }.getOrDefault(false)) { + _uiState.update { it.copy(abandonableAttempt = attempt) } + return@launch + } + delay(ABANDON_CHECK_INTERVAL) + } } } diff --git a/app/src/test/java/to/bitkit/repositories/OnchainSendAttemptStoreTest.kt b/app/src/test/java/to/bitkit/repositories/OnchainSendAttemptStoreTest.kt index 0335550bc6..977075691d 100644 --- a/app/src/test/java/to/bitkit/repositories/OnchainSendAttemptStoreTest.kt +++ b/app/src/test/java/to/bitkit/repositories/OnchainSendAttemptStoreTest.kt @@ -752,7 +752,10 @@ class OnchainSendAttemptStoreTest : BaseUnitTest() { channelId = null, tags = emptyList(), beforeSendAttempt = {}, payerIdentity = "payer", ) val receipt = OnchainPreparedReceipt( - "ab".repeat(32), listOf(OnchainSendInput("11".repeat(32), 0u)), attempt.address, attempt.amountSats, + txid = "ab".repeat(32), + inputs = listOf(OnchainSendInput("11".repeat(32), 0u)), + address = attempt.address, + amountSats = attempt.amountSats, ) store.retainPreparedReceipt(attempt.attemptId, 0, receipt, false) val outcome = store.broadcastPreparedCandidate(attempt.attemptId, 0, receipt.txid) { beforeDispatch -> diff --git a/app/src/test/java/to/bitkit/repositories/PaykitPaymentProofRepoTest.kt b/app/src/test/java/to/bitkit/repositories/PaykitPaymentProofRepoTest.kt index 4e48cb9291..f12e1579ca 100644 --- a/app/src/test/java/to/bitkit/repositories/PaykitPaymentProofRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/PaykitPaymentProofRepoTest.kt @@ -2638,7 +2638,8 @@ class PaykitPaymentProofRepoTest : BaseUnitTest(StandardTestDispatcher()) { ) private fun unknownShopAttempt(txid: String) = acceptedAttempt( - paymentRequest(MethodId.entries.first { it.isOnchain }.rawValue), txid, + request = paymentRequest(MethodId.entries.first { it.isOnchain }.rawValue), + txid = txid, ).copy( evidence = OnchainSendEvidence.Unknown, payerIdentity = LOCAL_IDENTITY, diff --git a/app/src/test/java/to/bitkit/ui/screens/wallets/send/SendPendingViewModelTest.kt b/app/src/test/java/to/bitkit/ui/screens/wallets/send/SendPendingViewModelTest.kt index 7a01584424..31b5bf2d7b 100644 --- a/app/src/test/java/to/bitkit/ui/screens/wallets/send/SendPendingViewModelTest.kt +++ b/app/src/test/java/to/bitkit/ui/screens/wallets/send/SendPendingViewModelTest.kt @@ -9,7 +9,9 @@ import kotlinx.coroutines.async import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.test.advanceTimeBy import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.runCurrent import org.junit.Before import org.junit.Test import org.mockito.kotlin.any @@ -34,6 +36,7 @@ import to.bitkit.viewmodels.SanityWarning import kotlin.test.assertEquals import kotlin.test.assertIs import kotlin.test.assertNull +import kotlin.time.Duration.Companion.seconds @OptIn(ExperimentalCoroutinesApi::class) class SendPendingViewModelTest : BaseUnitTest() { @@ -275,7 +278,9 @@ class SendPendingViewModelTest : BaseUnitTest() { @Test fun `expired Shop payment can be cancelled once the network never saw it`() = test { val original = pendingOriginal().copy( - requestId = PaykitPaymentRequestId("request", "payee"), orderId = "order", payerIdentity = "payer", + requestId = PaykitPaymentRequestId("request", "payee"), + orderId = "order", + payerIdentity = "payer", ) whenever(lightningRepo.currentOnchainSendAttempt()).thenReturn(original) whenever(paykitPaymentProofRepo.onchainRequestHasEnded(original)).thenReturn(true) @@ -297,6 +302,23 @@ class SendPendingViewModelTest : BaseUnitTest() { assertNull(sut.uiState.value.recoveryAttempt) } + @Test + fun `cancel appears once the request ends while Payment Pending stays open`() = test { + val original = pendingOriginal().copy( + requestId = PaykitPaymentRequestId("request", "payee"), + orderId = "order", + payerIdentity = "payer", + ) + whenever(lightningRepo.currentOnchainSendAttempt()).thenReturn(original) + whenever(paykitPaymentProofRepo.onchainRequestHasEnded(original)).thenReturn(false, true) + sut.initOnchain(original.txid, amount) + runCurrent() + assertNull(sut.uiState.value.abandonableAttempt) + advanceTimeBy(31.seconds) + runCurrent() + assertEquals(original, sut.uiState.value.abandonableAttempt) + } + @Test fun `ordinary unknown payment offers no cancel`() = test { val original = pendingOriginal() From bca68b0d4a14b6d12a2f2b421b6e6836e7e25f8c Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Fri, 16 Oct 2026 21:41:10 +0200 Subject: [PATCH 03/13] fix: release ended subscription periods and never restored attempts --- .../to/bitkit/repositories/LightningRepo.kt | 4 +- .../repositories/OnchainSendAttemptStore.kt | 6 +- .../repositories/PaykitPaymentProofRepo.kt | 63 +++++++++++++++---- .../wallets/send/SendPendingViewModel.kt | 2 +- .../OnchainSendAttemptStoreTest.kt | 1 + .../PaykitPaymentProofRepoTest.kt | 25 ++++++++ 6 files changed, 83 insertions(+), 18 deletions(-) diff --git a/app/src/main/java/to/bitkit/repositories/LightningRepo.kt b/app/src/main/java/to/bitkit/repositories/LightningRepo.kt index ca940d14a8..476c8847f1 100644 --- a/app/src/main/java/to/bitkit/repositories/LightningRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/LightningRepo.kt @@ -1866,10 +1866,10 @@ class LightningRepo @Inject constructor( attempt: OnchainSendAttempt, timeout: Duration = ABANDON_SYNC_TIMEOUT, ): Boolean { - val requestedAt = (System.currentTimeMillis() / 1000).toULong() + val requestedAt = (nowMillis() / 1000).toULong() withTimeoutOrNull(timeout) { sync() - while ((lightningService.status?.latestOnchainWalletSyncTimestamp ?: 0uL) <= requestedAt) { + while ((lightningService.status?.latestOnchainWalletSyncTimestamp ?: 0uL) < requestedAt) { delay(1.seconds) } } ?: throw OnchainSendAbandonSyncError() diff --git a/app/src/main/java/to/bitkit/repositories/OnchainSendAttemptStore.kt b/app/src/main/java/to/bitkit/repositories/OnchainSendAttemptStore.kt index 8d8a0a6f5b..05bbaf0cf0 100644 --- a/app/src/main/java/to/bitkit/repositories/OnchainSendAttemptStore.kt +++ b/app/src/main/java/to/bitkit/repositories/OnchainSendAttemptStore.kt @@ -115,9 +115,9 @@ data class OnchainSendAttempt( /** A Shop payment from the node wallet that dispatched but never had its acceptance confirmed. */ val canBeAbandoned: Boolean - get() = isUnresolved && requestId != null && !isTransfer && walletId == WalletScope.default && - !payerIdentity.isNullOrBlank() && initialDispatchAttempted && txid != null && - !originalInputs.isNullOrEmpty() && candidateTxids.isNotEmpty() + get() = isUnresolved && requestId != null && !isTransfer && !restoredFromBackup && + walletId == WalletScope.default && !payerIdentity.isNullOrBlank() && initialDispatchAttempted && + txid != null && !originalInputs.isNullOrEmpty() && candidateTxids.isNotEmpty() } class OnchainSendBlockedError(val attempt: OnchainSendAttempt? = null) : diff --git a/app/src/main/java/to/bitkit/repositories/PaykitPaymentProofRepo.kt b/app/src/main/java/to/bitkit/repositories/PaykitPaymentProofRepo.kt index a8c6af9a76..d5d83ffb43 100644 --- a/app/src/main/java/to/bitkit/repositories/PaykitPaymentProofRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/PaykitPaymentProofRepo.kt @@ -4,6 +4,7 @@ import com.synonym.paykit.BillingPeriod import com.synonym.paykit.PaymentProofRecord import com.synonym.paykit.PaymentRequestLifecycleState import com.synonym.paykit.PaymentRequestLocalRole +import com.synonym.paykit.PaymentRequestRecord import com.synonym.paykit.PubkyIdentityCapability import dagger.Lazy import kotlinx.coroutines.CoroutineDispatcher @@ -416,29 +417,67 @@ class PaykitPaymentProofRepo @Inject constructor( * deadline passed. A record that is missing, carries a proof or needs recovery never counts as ended. */ suspend fun onchainRequestHasEnded(attempt: OnchainSendAttempt): Boolean = withContext(ioDispatcher) { - val requestId = attempt.requestId ?: return@withContext false - if (!attempt.canBeAbandoned) return@withContext false - val identity = currentIdentity() ?: return@withContext false - if (!PubkyPublicKeyFormat.matches(identity, attempt.payerIdentity)) return@withContext false - val record = paykitSdkService.paymentRequests().firstOrNull { + val identity = currentIdentity()?.takeIf { PubkyPublicKeyFormat.matches(it, attempt.payerIdentity) } + val record = attempt.takeIf { it.canBeAbandoned && identity != null }?.let { payerRecord(it) } + val periodStartsAt = attempt.requestId?.billingPeriodStartsAt + when { + identity == null || record == null -> false + periodStartsAt != null -> recurringPeriodHasEnded(record, attempt, periodStartsAt) + record.paymentProofs.isNotEmpty() -> false + else -> oneTimeRequestHasEnded(record, identity) + } + } + + private suspend fun payerRecord(attempt: OnchainSendAttempt): PaymentRequestRecord? { + val requestId = attempt.requestId ?: return null + return paykitSdkService.paymentRequests().firstOrNull { it.localRole == PaymentRequestLocalRole.PAYER && it.paymentRequestId == requestId.paymentRequestId && PubkyPublicKeyFormat.matches(it.counterparty, requestId.counterparty) - } ?: return@withContext false - if (record.paymentProofs.isNotEmpty()) return@withContext false + } + } + + private suspend fun oneTimeRequestHasEnded(record: PaymentRequestRecord, identity: String): Boolean = when (record.state) { PaymentRequestLifecycleState.PROPOSAL_EXPIRED, PaymentRequestLifecycleState.REJECTED, PaymentRequestLifecycleState.CANCELED -> true PaymentRequestLifecycleState.PROPOSED, - PaymentRequestLifecycleState.ACCEPTED, - PaymentRequestLifecycleState.ACTIVE_RECURRING -> { + PaymentRequestLifecycleState.ACCEPTED -> { val now = Clock.System.now() val request = runCatching { record.toPaykitPaymentRequestHistory(now) }.getOrNull() - ?: return@withContext false - request.isExpired(now) || quoteDeadlinePassed(request, identity, now) + request != null && (request.isExpired(now) || quoteDeadlinePassed(request, identity, now)) } else -> false } + + private suspend fun recurringPeriodHasEnded( + record: PaymentRequestRecord, + attempt: OnchainSendAttempt, + periodStartsAt: String, + ): Boolean { + if (record.paymentProofs.any { it.billingPeriod?.startsAt == periodStartsAt }) return false + return when (record.state) { + PaymentRequestLifecycleState.CANCELED, + PaymentRequestLifecycleState.REJECTED -> true + PaymentRequestLifecycleState.ACTIVE_RECURRING -> activeRecurringPeriodPassed(record, attempt) + else -> false + } + } + + private suspend fun activeRecurringPeriodPassed( + record: PaymentRequestRecord, + attempt: OnchainSendAttempt, + ): Boolean { + val now = Clock.System.now() + val subscription = runCatching { record.toPaykitSubscription() }.getOrNull() ?: return false + val period = operationMutex.withLock { + loadProofs().lastOrNull { + it.kind == PaykitPaymentProofKind.Onchain && it.requestId == attempt.requestId && + PubkyPublicKeyFormat.matches(it.identity, attempt.payerIdentity) + }?.billingPeriod + } + val deadline = period?.let { runCatching { subscription.pricing.paymentDeadline(it) }.getOrNull() } + return subscription.isExpired(now) || deadline?.let { now > it } == true } private suspend fun quoteDeadlinePassed(request: PaykitPaymentRequest, identity: String, now: Instant): Boolean { @@ -488,7 +527,7 @@ class PaykitPaymentProofRepo @Inject constructor( } if (!released) throw OnchainSendBlockedError(attempt) Logger.info( - "Released on-chain payment ${attempt.txid} after its request ended without it reaching the network", + "Released on-chain payment '${attempt.txid}' after its request ended without it reaching the network", context = TAG, ) } diff --git a/app/src/main/java/to/bitkit/ui/screens/wallets/send/SendPendingViewModel.kt b/app/src/main/java/to/bitkit/ui/screens/wallets/send/SendPendingViewModel.kt index 67f3ec6a16..90ffb63118 100644 --- a/app/src/main/java/to/bitkit/ui/screens/wallets/send/SendPendingViewModel.kt +++ b/app/src/main/java/to/bitkit/ui/screens/wallets/send/SendPendingViewModel.kt @@ -200,7 +200,7 @@ class SendPendingViewModel @Inject constructor( it.copy(isAbandoning = false, abandoned = true, abandonableAttempt = null, recoveryAttempt = null) } }.onFailure { error -> - Logger.warn("Could not release on-chain payment ${attempt.txid}", error, context = TAG) + Logger.warn("Could not release on-chain payment '${attempt.txid}'", error, context = TAG) _uiState.update { it.copy( isAbandoning = false, diff --git a/app/src/test/java/to/bitkit/repositories/OnchainSendAttemptStoreTest.kt b/app/src/test/java/to/bitkit/repositories/OnchainSendAttemptStoreTest.kt index 977075691d..cb41c4c8bd 100644 --- a/app/src/test/java/to/bitkit/repositories/OnchainSendAttemptStoreTest.kt +++ b/app/src/test/java/to/bitkit/repositories/OnchainSendAttemptStoreTest.kt @@ -765,6 +765,7 @@ class OnchainSendAttemptStoreTest : BaseUnitTest() { store.recordOutcome(attempt.attemptId, outcome, 0) val unknown = requireNotNull(store.current()) assertTrue(unknown.canBeAbandoned) + assertFalse(unknown.copy(restoredFromBackup = true).canBeAbandoned) assertFalse(store.abandonUnbroadcast(unknown) { false }) assertEquals(unknown, store.current()) diff --git a/app/src/test/java/to/bitkit/repositories/PaykitPaymentProofRepoTest.kt b/app/src/test/java/to/bitkit/repositories/PaykitPaymentProofRepoTest.kt index f12e1579ca..d8242c189d 100644 --- a/app/src/test/java/to/bitkit/repositories/PaykitPaymentProofRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/PaykitPaymentProofRepoTest.kt @@ -2561,6 +2561,31 @@ class PaykitPaymentProofRepoTest : BaseUnitTest(StandardTestDispatcher()) { assertFalse(paymentProofRepo().onchainRequestHasEnded(attempt)) } + @Test + fun `recurring period counts as ended only without its own proof`() = test { + val periodStart = "2027-01-01T00:00:00Z" + val base = unknownShopAttempt("ab".repeat(32)) + val attempt = base.copy(requestId = requireNotNull(base.requestId).copy(billingPeriodStartsAt = periodStart)) + storedProofs = listOf(startedOnchainProof(attempt)) + val periodProof = mock { + on { billingPeriod } doReturn BillingPeriod(periodStart, "2027-02-01T00:00:00Z") + } + val olderProof = mock { + on { billingPeriod } doReturn BillingPeriod("2026-12-01T00:00:00Z", periodStart) + } + val repo = paymentProofRepo() + + whenever(paykitSdkService.paymentRequests()).thenReturn( + listOf(paymentRequestRecord(listOf(olderProof), state = PaymentRequestLifecycleState.CANCELED)) + ) + assertTrue(repo.onchainRequestHasEnded(attempt)) + + whenever(paykitSdkService.paymentRequests()).thenReturn( + listOf(paymentRequestRecord(listOf(periodProof), state = PaymentRequestLifecycleState.CANCELED)) + ) + assertFalse(repo.onchainRequestHasEnded(attempt)) + } + private fun TestScope.observeRequestStateChanges() { paymentProofRepo(projectionStore).paymentRequestStateChanges(projectionIdentity) .onEach { requestStateChanges++ } From cc506834c5dc1cd9809aa636d04420fd625e4a8c Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Fri, 16 Oct 2026 22:28:19 +0200 Subject: [PATCH 04/13] fix: finish an approved release after a restart and honor quote expiry --- .../to/bitkit/repositories/LightningRepo.kt | 11 +++- .../repositories/OnchainSendAttemptStore.kt | 16 +++++ .../repositories/PaykitPaymentProofRepo.kt | 51 +++++++++------- .../bitkit/repositories/LightningRepoTest.kt | 7 +++ .../OnchainSendAttemptStoreTest.kt | 10 +++- .../PaykitPaymentProofRepoTest.kt | 58 +++++++++++++++++++ 6 files changed, 127 insertions(+), 26 deletions(-) diff --git a/app/src/main/java/to/bitkit/repositories/LightningRepo.kt b/app/src/main/java/to/bitkit/repositories/LightningRepo.kt index 476c8847f1..22c9e7696e 100644 --- a/app/src/main/java/to/bitkit/repositories/LightningRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/LightningRepo.kt @@ -1866,16 +1866,20 @@ class LightningRepo @Inject constructor( attempt: OnchainSendAttempt, timeout: Duration = ABANDON_SYNC_TIMEOUT, ): Boolean { - val requestedAt = (nowMillis() / 1000).toULong() + // Only an on-chain sync that finishes after this call moves the timestamp past the one read here. + val previousSync = lightningService.status?.latestOnchainWalletSyncTimestamp ?: 0uL withTimeoutOrNull(timeout) { sync() - while ((lightningService.status?.latestOnchainWalletSyncTimestamp ?: 0uL) < requestedAt) { + while (!isOnchainSyncAfter(lightningService.status?.latestOnchainWalletSyncTimestamp, previousSync)) { delay(1.seconds) } } ?: throw OnchainSendAbandonSyncError() return lightningService.walletKnowsNoCandidate(attempt) } + suspend fun approveOnchainRelease(expected: OnchainSendAttempt): OnchainSendAttempt? = + onchainSendAttemptStore.approveRelease(expected) + suspend fun abandonUnbroadcastOnchainSend( expected: OnchainSendAttempt, removeOriginalProof: suspend (OnchainSendAttempt) -> Boolean, @@ -2789,3 +2793,6 @@ sealed interface ProbeOutcome { val routeFeeMsat: ULong?, ) : ProbeOutcome } + +/** A sync finished in the same second as the one read before a request is not a new sync. */ +internal fun isOnchainSyncAfter(latest: ULong?, previous: ULong): Boolean = (latest ?: 0uL) > previous diff --git a/app/src/main/java/to/bitkit/repositories/OnchainSendAttemptStore.kt b/app/src/main/java/to/bitkit/repositories/OnchainSendAttemptStore.kt index 05bbaf0cf0..680c48333d 100644 --- a/app/src/main/java/to/bitkit/repositories/OnchainSendAttemptStore.kt +++ b/app/src/main/java/to/bitkit/repositories/OnchainSendAttemptStore.kt @@ -96,6 +96,8 @@ data class OnchainSendAttempt( val verifiedWinningFeeSats: ULong? = null, // Write-ahead boundary: false is proof that this original prepared payment never dispatched. val initialDispatchAttempted: Boolean = true, + // Set once its request was found ended, so a release cut short after its proof was removed can finish. + val releaseApproved: Boolean = false, ) { val winningFeeRateSatsPerVByte: ULong get() = candidateFeeRates[txid?.lowercase()] ?: feeRateSatsPerVByte.takeIf { @@ -261,6 +263,20 @@ class OnchainSendAttemptStore @Inject constructor( } } + /** Records that the payment's request has ended before its original proof is removed. */ + suspend fun approveRelease(expected: OnchainSendAttempt): OnchainSendAttempt? = + withContext(ioDispatcher + NonCancellable) { + mutex.withLock { + val current = loadWithRetainedAccepted(expected.walletIndex) + val approved = expected.copy(releaseApproved = true) + when { + current == approved -> current + current != expected || !current.canBeAbandoned || current.attemptId in inFlightPreparations -> null + else -> approved.also { persist(it) } + } + } + } + /** * Releases a Shop payment whose request has ended once the caller has shown that none of its candidates * reached the network. The original proof is removed first, so a crash in between leaves an attempt that diff --git a/app/src/main/java/to/bitkit/repositories/PaykitPaymentProofRepo.kt b/app/src/main/java/to/bitkit/repositories/PaykitPaymentProofRepo.kt index d5d83ffb43..95e6a8eb94 100644 --- a/app/src/main/java/to/bitkit/repositories/PaykitPaymentProofRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/PaykitPaymentProofRepo.kt @@ -421,6 +421,7 @@ class PaykitPaymentProofRepo @Inject constructor( val record = attempt.takeIf { it.canBeAbandoned && identity != null }?.let { payerRecord(it) } val periodStartsAt = attempt.requestId?.billingPeriodStartsAt when { + identity != null && attempt.canBeAbandoned && attempt.releaseApproved -> true identity == null || record == null -> false periodStartsAt != null -> recurringPeriodHasEnded(record, attempt, periodStartsAt) record.paymentProofs.isNotEmpty() -> false @@ -477,7 +478,32 @@ class PaykitPaymentProofRepo @Inject constructor( }?.billingPeriod } val deadline = period?.let { runCatching { subscription.pricing.paymentDeadline(it) }.getOrNull() } - return subscription.isExpired(now) || deadline?.let { now > it } == true + val request = period?.let { + runCatching { subscription.requestsThrough(now, it.startsAt) }.getOrNull() + ?.firstOrNull { request -> request.billingPeriod?.startsAt == it.startsAt } + } + val identity = requireNotNull(attempt.payerIdentity) + return subscription.isExpired(now) || deadline?.let { now > it } == true || + (request != null && quoteDeadlinePassed(request, identity, now)) + } + + private suspend fun removeOriginalShopProofs(attempt: OnchainSendAttempt): Boolean = operationMutex.withLock { + val proofs = loadProofs() + val original = proofs.filter { + it.kind == PaykitPaymentProofKind.Onchain && it.requestId == attempt.requestId && + PubkyPublicKeyFormat.matches(it.identity, requireNotNull(attempt.payerIdentity)) + } + val safe = original.all { proof -> + val identifier = proof.paymentIdentifier + val isCandidate = identifier == null || + attempt.candidateTxids.any { it.equals(identifier, ignoreCase = true) } + proof.onchainWalletId == attempt.walletId && proof.proofData == null && + !proof.onchainAcceptanceVerified && isCandidate + } + if (!safe) return@withLock false + for (proof in original) releaseDeniedPrivateConsumption(proof) + if (original.isNotEmpty()) persist(proofs - original.toSet()) + true } private suspend fun quoteDeadlinePassed(request: PaykitPaymentRequest, identity: String, now: Instant): Boolean { @@ -505,26 +531,9 @@ class PaykitPaymentProofRepo @Inject constructor( runSuspendCatching { if (!onchainRequestHasEnded(attempt)) throw OnchainSendAbandonRefusedError(requestOpen = true) if (!neverReachedNetwork(attempt)) throw OnchainSendAbandonRefusedError(requestOpen = false) - val released = lightningRepo.abandonUnbroadcastOnchainSend(attempt) { current -> - operationMutex.withLock { - val proofs = loadProofs() - val original = proofs.filter { - it.kind == PaykitPaymentProofKind.Onchain && it.requestId == current.requestId && - PubkyPublicKeyFormat.matches(it.identity, requireNotNull(current.payerIdentity)) - } - val safe = original.all { proof -> - val identifier = proof.paymentIdentifier - val isCandidate = identifier == null || - current.candidateTxids.any { it.equals(identifier, ignoreCase = true) } - proof.onchainWalletId == current.walletId && proof.proofData == null && - !proof.onchainAcceptanceVerified && isCandidate - } - if (!safe) return@withLock false - for (proof in original) releaseDeniedPrivateConsumption(proof) - if (original.isNotEmpty()) persist(proofs - original.toSet()) - true - } - } + val approved = if (attempt.releaseApproved) attempt else lightningRepo.approveOnchainRelease(attempt) + val released = approved != null && + lightningRepo.abandonUnbroadcastOnchainSend(approved) { removeOriginalShopProofs(it) } if (!released) throw OnchainSendBlockedError(attempt) Logger.info( "Released on-chain payment '${attempt.txid}' after its request ended without it reaching the network", diff --git a/app/src/test/java/to/bitkit/repositories/LightningRepoTest.kt b/app/src/test/java/to/bitkit/repositories/LightningRepoTest.kt index a8a2e524a5..bcc66b2c1b 100644 --- a/app/src/test/java/to/bitkit/repositories/LightningRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/LightningRepoTest.kt @@ -3788,4 +3788,11 @@ class LightningRepoTest : BaseUnitTest() { // Verify start was only called once (no retry) verify(lightningService, times(1)).start(anyOrNull(), any()) } + + @Test + fun `only a sync finished after the request counts as fresh`() { + assertFalse(isOnchainSyncAfter(null, 0uL)) + assertFalse(isOnchainSyncAfter(1_800_000_000uL, 1_800_000_000uL)) + assertTrue(isOnchainSyncAfter(1_800_000_001uL, 1_800_000_000uL)) + } } diff --git a/app/src/test/java/to/bitkit/repositories/OnchainSendAttemptStoreTest.kt b/app/src/test/java/to/bitkit/repositories/OnchainSendAttemptStoreTest.kt index cb41c4c8bd..d5ccfcc0b2 100644 --- a/app/src/test/java/to/bitkit/repositories/OnchainSendAttemptStoreTest.kt +++ b/app/src/test/java/to/bitkit/repositories/OnchainSendAttemptStoreTest.kt @@ -769,9 +769,13 @@ class OnchainSendAttemptStoreTest : BaseUnitTest() { assertFalse(store.abandonUnbroadcast(unknown) { false }) assertEquals(unknown, store.current()) - assertFalse(store.abandonUnbroadcast(unknown.copy(amountSats = 999uL)) { true }) - assertEquals(unknown, store.current()) - assertTrue(store.abandonUnbroadcast(unknown) { true }) + val approved = requireNotNull(store.approveRelease(unknown)) + assertTrue(approved.releaseApproved) + assertEquals(approved, store.approveRelease(unknown)) + assertFalse(store.abandonUnbroadcast(unknown) { true }) + assertFalse(store.abandonUnbroadcast(approved.copy(amountSats = 999uL)) { true }) + assertEquals(approved, store.current()) + assertTrue(store.abandonUnbroadcast(approved) { true }) assertNull(store.current()) assertTrue(store.admitForTest(walletId = WalletScope.default).attemptId != attempt.attemptId) } diff --git a/app/src/test/java/to/bitkit/repositories/PaykitPaymentProofRepoTest.kt b/app/src/test/java/to/bitkit/repositories/PaykitPaymentProofRepoTest.kt index d8242c189d..7c0301f7b6 100644 --- a/app/src/test/java/to/bitkit/repositories/PaykitPaymentProofRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/PaykitPaymentProofRepoTest.kt @@ -15,6 +15,7 @@ import com.synonym.paykit.PaymentRequestAmount import com.synonym.paykit.PaymentRequestLifecycleState import com.synonym.paykit.PaymentRequestLocalRole import com.synonym.paykit.PaymentRequestRecord +import com.synonym.paykit.PaymentRequestRecurrence import com.synonym.paykit.PaymentRequestTerms import com.synonym.paykit.PrivateJsonObject import com.synonym.paykit.PubkyIdentityCapability @@ -81,6 +82,8 @@ import kotlin.test.assertFalse import kotlin.test.assertNull import kotlin.test.assertTrue import kotlin.time.Clock +import kotlin.time.Duration.Companion.days +import kotlin.time.Duration.Companion.minutes import kotlin.time.Duration.Companion.seconds import kotlin.time.Instant @@ -2586,6 +2589,58 @@ class PaykitPaymentProofRepoTest : BaseUnitTest(StandardTestDispatcher()) { assertFalse(repo.onchainRequestHasEnded(attempt)) } + private fun recurringShopRecord(quoteExpiresAt: Instant): Pair { + val start = Instant.fromEpochSeconds((Clock.System.now() - 10.days).epochSeconds) + val end = java.time.Instant.ofEpochSecond(start.epochSeconds).atZone(java.time.ZoneOffset.UTC).plusMonths(1) + .toEpochSecond().let(Instant::fromEpochSeconds) + val period = PaykitBillingPeriod(start, end) + val sdkPeriod = BillingPeriod(period.startsAt.toString(), period.endsAt.toString()) + val base = paymentRequestRecord(state = PaymentRequestLifecycleState.ACTIVE_RECURRING) + val onchain = MethodId.entries.first { it.isOnchain }.rawValue + return base.copy( + terms = requireNotNull(base.terms).copy( + paymentReference = mock { on { exportText() }.thenReturn("payment-reference") }, + recurrence = PaymentRequestRecurrence(1u, "month", sdkPeriod.startsAt, sdkPeriod.startsAt, null), + conversion = PaymentConversion.PerPeriod, + acceptedPaymentEndpointIdentifiers = listOf(onchain), + ), + conversionQuotes = listOf( + PaymentConversionQuoteRecord( + "quote-1", sdkPeriod, listOf(ConversionRate("btc", "1")), sdkPeriod.startsAt, + quoteExpiresAt.toString(), null, + ) + ), + ) to period + } + + private fun recurringAttempt(period: PaykitBillingPeriod) = unknownShopAttempt("ab".repeat(32)).let { + it.copy(requestId = requireNotNull(it.requestId).copy(billingPeriodStartsAt = period.startsAt.toString())) + } + + @Test + fun `expired quote ends an active subscription period`() = test { + for ((expiresAt, ended) in listOf(Clock.System.now() + 1.days to false, Clock.System.now() - 1.minutes to true)) { + val (record, period) = recurringShopRecord(expiresAt) + val attempt = recurringAttempt(period) + storedProofs = listOf(startedOnchainProof(attempt).copy(billingPeriod = period, conversionQuoteId = "quote-1")) + whenever(paykitSdkService.paymentRequests()).thenReturn(listOf(record)) + + assertEquals(ended, paymentProofRepo().onchainRequestHasEnded(attempt)) + } + } + + @Test + fun `approved release finishes after its proof was removed`() = test { + val (record, period) = recurringShopRecord(Clock.System.now() + 1.days) + val approved = recurringAttempt(period).copy(releaseApproved = true) + storedProofs = emptyList() + whenever(paykitSdkService.paymentRequests()).thenReturn(listOf(record)) + stubAbandon(approved) + + assertTrue(paymentProofRepo().onchainRequestHasEnded(approved)) + assertTrue(paymentProofRepo().abandonUnbroadcastOnchainPayment(approved) { true }.isSuccess) + } + private fun TestScope.observeRequestStateChanges() { paymentProofRepo(projectionStore).paymentRequestStateChanges(projectionIdentity) .onEach { requestStateChanges++ } @@ -2685,6 +2740,9 @@ class PaykitPaymentProofRepoTest : BaseUnitTest(StandardTestDispatcher()) { ) private suspend fun stubAbandon(attempt: OnchainSendAttempt) { + whenever(lightningRepo.approveOnchainRelease(any())).doSuspendableAnswer { + it.getArgument(0).copy(releaseApproved = true) + } whenever(lightningRepo.abandonUnbroadcastOnchainSend(any(), any())).doSuspendableAnswer { it.getArgument Boolean>(1).invoke(attempt) } From 3a88ab236e6d045b22746d6347f91f116c43ac6c Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Fri, 16 Oct 2026 22:32:48 +0200 Subject: [PATCH 05/13] test: wrap the quote expiry cases --- .../to/bitkit/repositories/PaykitPaymentProofRepoTest.kt | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/app/src/test/java/to/bitkit/repositories/PaykitPaymentProofRepoTest.kt b/app/src/test/java/to/bitkit/repositories/PaykitPaymentProofRepoTest.kt index 7c0301f7b6..0cb01aadbb 100644 --- a/app/src/test/java/to/bitkit/repositories/PaykitPaymentProofRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/PaykitPaymentProofRepoTest.kt @@ -2619,7 +2619,11 @@ class PaykitPaymentProofRepoTest : BaseUnitTest(StandardTestDispatcher()) { @Test fun `expired quote ends an active subscription period`() = test { - for ((expiresAt, ended) in listOf(Clock.System.now() + 1.days to false, Clock.System.now() - 1.minutes to true)) { + val cases = listOf( + Clock.System.now() + 1.days to false, + Clock.System.now() - 1.minutes to true, + ) + for ((expiresAt, ended) in cases) { val (record, period) = recurringShopRecord(expiresAt) val attempt = recurringAttempt(period) storedProofs = listOf(startedOnchainProof(attempt).copy(billingPeriod = period, conversionQuoteId = "quote-1")) From ba619c7d574694160789750be824bdc5b035a323 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Fri, 16 Oct 2026 22:34:37 +0200 Subject: [PATCH 06/13] test: keep the quote expiry test within the line limit --- .../java/to/bitkit/repositories/PaykitPaymentProofRepoTest.kt | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/app/src/test/java/to/bitkit/repositories/PaykitPaymentProofRepoTest.kt b/app/src/test/java/to/bitkit/repositories/PaykitPaymentProofRepoTest.kt index 0cb01aadbb..e01ae91901 100644 --- a/app/src/test/java/to/bitkit/repositories/PaykitPaymentProofRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/PaykitPaymentProofRepoTest.kt @@ -2626,7 +2626,9 @@ class PaykitPaymentProofRepoTest : BaseUnitTest(StandardTestDispatcher()) { for ((expiresAt, ended) in cases) { val (record, period) = recurringShopRecord(expiresAt) val attempt = recurringAttempt(period) - storedProofs = listOf(startedOnchainProof(attempt).copy(billingPeriod = period, conversionQuoteId = "quote-1")) + storedProofs = listOf( + startedOnchainProof(attempt).copy(billingPeriod = period, conversionQuoteId = "quote-1"), + ) whenever(paykitSdkService.paymentRequests()).thenReturn(listOf(record)) assertEquals(ended, paymentProofRepo().onchainRequestHasEnded(attempt)) From 2e03fd484cf3bb842e1562808bfa5efbf26be3fe Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Fri, 16 Oct 2026 22:59:45 +0200 Subject: [PATCH 07/13] fix: keep a wallet restore working over a release-approved payment --- .../to/bitkit/repositories/OnchainSendAttemptStore.kt | 4 +++- .../bitkit/repositories/OnchainSendAttemptStoreTest.kt | 10 +++++++--- 2 files changed, 10 insertions(+), 4 deletions(-) diff --git a/app/src/main/java/to/bitkit/repositories/OnchainSendAttemptStore.kt b/app/src/main/java/to/bitkit/repositories/OnchainSendAttemptStore.kt index 680c48333d..a12ef534d6 100644 --- a/app/src/main/java/to/bitkit/repositories/OnchainSendAttemptStore.kt +++ b/app/src/main/java/to/bitkit/repositories/OnchainSendAttemptStore.kt @@ -117,7 +117,7 @@ data class OnchainSendAttempt( /** A Shop payment from the node wallet that dispatched but never had its acceptance confirmed. */ val canBeAbandoned: Boolean - get() = isUnresolved && requestId != null && !isTransfer && !restoredFromBackup && + get() = isUnresolved && requestId != null && !isTransfer && (!restoredFromBackup || releaseApproved) && walletId == WalletScope.default && !payerIdentity.isNullOrBlank() && initialDispatchAttempted && txid != null && !originalInputs.isNullOrEmpty() && candidateTxids.isNotEmpty() } @@ -561,6 +561,8 @@ class OnchainSendAttemptStore @Inject constructor( preparationPending = attempt.preparationPending, verifiedWinningFeeSats = attempt.verifiedWinningFeeSats, initialDispatchAttempted = attempt.initialDispatchAttempted, + // A local release approval is not part of the shared backup record. + releaseApproved = attempt.releaseApproved, ) == attempt check(sameOperation && existing.candidateTxids.containsAll(attempt.candidateTxids)) { "Cannot change a restored on-chain operation's original context" diff --git a/app/src/test/java/to/bitkit/repositories/OnchainSendAttemptStoreTest.kt b/app/src/test/java/to/bitkit/repositories/OnchainSendAttemptStoreTest.kt index d5ccfcc0b2..4dd8424fdb 100644 --- a/app/src/test/java/to/bitkit/repositories/OnchainSendAttemptStoreTest.kt +++ b/app/src/test/java/to/bitkit/repositories/OnchainSendAttemptStoreTest.kt @@ -772,10 +772,14 @@ class OnchainSendAttemptStoreTest : BaseUnitTest() { val approved = requireNotNull(store.approveRelease(unknown)) assertTrue(approved.releaseApproved) assertEquals(approved, store.approveRelease(unknown)) + // A restore of the same operation keeps the local approval and its release. + store.restoreActive(unknown.copy(restoredFromBackup = true)) + val restored = requireNotNull(store.current()) + assertTrue(restored.releaseApproved && restored.canBeAbandoned) assertFalse(store.abandonUnbroadcast(unknown) { true }) - assertFalse(store.abandonUnbroadcast(approved.copy(amountSats = 999uL)) { true }) - assertEquals(approved, store.current()) - assertTrue(store.abandonUnbroadcast(approved) { true }) + assertFalse(store.abandonUnbroadcast(restored.copy(amountSats = 999uL)) { true }) + assertEquals(restored, store.current()) + assertTrue(store.abandonUnbroadcast(restored) { true }) assertNull(store.current()) assertTrue(store.admitForTest(walletId = WalletScope.default).attemptId != attempt.attemptId) } From 82ad38d816bd0648003ad5dc90c86b890f54a47f Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Sat, 17 Oct 2026 02:24:59 +0200 Subject: [PATCH 08/13] fix: cancel an expired payment by sending its coins back to the wallet --- .../to/bitkit/repositories/LightningRepo.kt | 15 ++- .../repositories/OnchainSendAttemptStore.kt | 40 +++++++- .../repositories/PaykitPaymentProofRepo.kt | 41 ++++++-- .../to/bitkit/services/LightningService.kt | 29 ++++++ .../screens/wallets/send/SendPendingScreen.kt | 23 ++++- .../wallets/send/SendPendingViewModel.kt | 54 +++++++---- app/src/main/res/values/strings.xml | 6 +- .../OnchainSendAttemptStoreTest.kt | 2 + .../PaykitPaymentProofRepoTest.kt | 94 +++++++++++++++++-- .../wallets/send/SendPendingViewModelTest.kt | 6 +- changelog.d/next/1551.fixed.md | 2 +- 11 files changed, 263 insertions(+), 49 deletions(-) diff --git a/app/src/main/java/to/bitkit/repositories/LightningRepo.kt b/app/src/main/java/to/bitkit/repositories/LightningRepo.kt index 22c9e7696e..aa82bfd0f6 100644 --- a/app/src/main/java/to/bitkit/repositories/LightningRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/LightningRepo.kt @@ -135,7 +135,7 @@ class LightningRepo @Inject constructor( private val vssBackupClientLdk: VssBackupClientLdk, private val urlValidator: UrlValidator, private val electrumProbeService: ElectrumProbeService, -) { +) : OnchainReleasing { private val _lightningState = MutableStateFlow(LightningState()) val lightningState = _lightningState.asStateFlow() @@ -1862,9 +1862,12 @@ class LightningRepo @Inject constructor( suspend fun currentOnchainSendAttempt(): OnchainSendAttempt? = onchainSendAttemptStore.current() /** Syncs the on-chain wallet, then checks that none of the attempt's candidates reached the mempool or chain. */ + override suspend fun onchainCandidatesNeverReachedNetwork(attempt: OnchainSendAttempt): Boolean = + onchainCandidatesNeverReachedNetwork(attempt, ABANDON_SYNC_TIMEOUT) + suspend fun onchainCandidatesNeverReachedNetwork( attempt: OnchainSendAttempt, - timeout: Duration = ABANDON_SYNC_TIMEOUT, + timeout: Duration, ): Boolean { // Only an on-chain sync that finishes after this call moves the timestamp past the one read here. val previousSync = lightningService.status?.latestOnchainWalletSyncTimestamp ?: 0uL @@ -1877,6 +1880,14 @@ class LightningRepo @Inject constructor( return lightningService.walletKnowsNoCandidate(attempt) } + override suspend fun walletKnowsTransaction(txid: String): Boolean = lightningService.walletKnowsTransaction(txid) + + override suspend fun prepareRelease(attempt: OnchainSendAttempt): PreparedOnchainSend = + lightningService.prepareRelease(attempt) + + suspend fun recordOnchainRelease(expected: OnchainSendAttempt, txid: String): OnchainSendAttempt? = + onchainSendAttemptStore.recordRelease(expected, txid) + suspend fun approveOnchainRelease(expected: OnchainSendAttempt): OnchainSendAttempt? = onchainSendAttemptStore.approveRelease(expected) diff --git a/app/src/main/java/to/bitkit/repositories/OnchainSendAttemptStore.kt b/app/src/main/java/to/bitkit/repositories/OnchainSendAttemptStore.kt index a12ef534d6..efd6fe5aea 100644 --- a/app/src/main/java/to/bitkit/repositories/OnchainSendAttemptStore.kt +++ b/app/src/main/java/to/bitkit/repositories/OnchainSendAttemptStore.kt @@ -98,6 +98,8 @@ data class OnchainSendAttempt( val initialDispatchAttempted: Boolean = true, // Set once its request was found ended, so a release cut short after its proof was removed can finish. val releaseApproved: Boolean = false, + // The transaction spending the original's coins back to the wallet, saved before it is broadcast. + val releaseTxid: String? = null, ) { val winningFeeRateSatsPerVByte: ULong get() = candidateFeeRates[txid?.lowercase()] ?: feeRateSatsPerVByte.takeIf { @@ -115,6 +117,9 @@ data class OnchainSendAttempt( val blocksNextSend: Boolean get() = isUnresolved || (hasPositiveEvidence && !localFollowupComplete) + val highestCandidateFee: ULong + get() = maxOf(candidateMiningFees.values.maxOrNull() ?: 0uL, verifiedWinningFeeSats ?: 0uL) + /** A Shop payment from the node wallet that dispatched but never had its acceptance confirmed. */ val canBeAbandoned: Boolean get() = isUnresolved && requestId != null && !isTransfer && (!restoredFromBackup || releaseApproved) && @@ -130,6 +135,26 @@ class OnchainSendAbandonRefusedError(val requestOpen: Boolean) : class OnchainSendAbandonSyncError : AppError("The on-chain wallet could not sync before releasing a payment.") +class OnchainSendAbandonPendingError : AppError("The release transaction was sent but is not confirmed yet.") + +/** + * A release must spend at least one of the original's coins and pay more than any candidate, so it replaces a + * candidate a node may still hold, and no candidate can confirm once it does. + */ +fun releaseOutbids(receipt: OnchainPreparedReceipt, attempt: OnchainSendAttempt): Boolean { + val original = attempt.originalInputs.orEmpty().toSet() + val fee = receipt.miningFeeSats ?: return false + if (receipt.inputs.none { it in original }) return false + return fee > attempt.highestCandidateFee +} + +/** The wallet side of cancelling: proving no candidate reached the network, and spending its coins back. */ +interface OnchainReleasing { + suspend fun onchainCandidatesNeverReachedNetwork(attempt: OnchainSendAttempt): Boolean + suspend fun walletKnowsTransaction(txid: String): Boolean + suspend fun prepareRelease(attempt: OnchainSendAttempt): PreparedOnchainSend +} + class OnchainSendAttemptUnreadableError(cause: Throwable) : AppError("Failed to read on-chain send attempt", cause) class OnchainSendNotDispatchedError(cause: Throwable) : AppError("On-chain send did not reach the backend", cause) @@ -277,6 +302,18 @@ class OnchainSendAttemptStore @Inject constructor( } } + /** Saves the release transaction before it is broadcast, so a later cancel can find it in the wallet. */ + suspend fun recordRelease(expected: OnchainSendAttempt, txid: String): OnchainSendAttempt? = + withContext(ioDispatcher + NonCancellable) { + mutex.withLock { + val current = loadWithRetainedAccepted(expected.walletIndex) + val isCandidate = current?.candidateTxids?.any { it.equals(txid, ignoreCase = true) } == true + val releasable = current == expected && current.canBeAbandoned && current.releaseApproved + if (!releasable || isCandidate) return@withLock null + current.copy(releaseTxid = txid.lowercase()).also { persist(it) } + } + } + /** * Releases a Shop payment whose request has ended once the caller has shown that none of its candidates * reached the network. The original proof is removed first, so a crash in between leaves an attempt that @@ -561,8 +598,9 @@ class OnchainSendAttemptStore @Inject constructor( preparationPending = attempt.preparationPending, verifiedWinningFeeSats = attempt.verifiedWinningFeeSats, initialDispatchAttempted = attempt.initialDispatchAttempted, - // A local release approval is not part of the shared backup record. + // A local release approval and its transaction are not part of the shared backup record. releaseApproved = attempt.releaseApproved, + releaseTxid = attempt.releaseTxid, ) == attempt check(sameOperation && existing.candidateTxids.containsAll(attempt.candidateTxids)) { "Cannot change a restored on-chain operation's original context" diff --git a/app/src/main/java/to/bitkit/repositories/PaykitPaymentProofRepo.kt b/app/src/main/java/to/bitkit/repositories/PaykitPaymentProofRepo.kt index 95e6a8eb94..58962d6eba 100644 --- a/app/src/main/java/to/bitkit/repositories/PaykitPaymentProofRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/PaykitPaymentProofRepo.kt @@ -526,22 +526,45 @@ class PaykitPaymentProofRepo @Inject constructor( */ suspend fun abandonUnbroadcastOnchainPayment( attempt: OnchainSendAttempt, - neverReachedNetwork: suspend (OnchainSendAttempt) -> Boolean, + wallet: OnchainReleasing, + approveRelease: suspend (OnchainPreparedReceipt) -> Unit, ): Result = withContext(ioDispatcher) { runSuspendCatching { if (!onchainRequestHasEnded(attempt)) throw OnchainSendAbandonRefusedError(requestOpen = true) - if (!neverReachedNetwork(attempt)) throw OnchainSendAbandonRefusedError(requestOpen = false) + val pendingRelease = attempt.releaseTxid + if (pendingRelease != null && wallet.walletKnowsTransaction(pendingRelease)) { + return@runSuspendCatching finishRelease(attempt) + } + if (!wallet.onchainCandidatesNeverReachedNetwork(attempt)) { + throw OnchainSendAbandonRefusedError(requestOpen = false) + } val approved = if (attempt.releaseApproved) attempt else lightningRepo.approveOnchainRelease(attempt) - val released = approved != null && - lightningRepo.abandonUnbroadcastOnchainSend(approved) { removeOriginalShopProofs(it) } - if (!released) throw OnchainSendBlockedError(attempt) - Logger.info( - "Released on-chain payment '${attempt.txid}' after its request ended without it reaching the network", - context = TAG, - ) + approved ?: throw OnchainSendBlockedError(attempt) + val prepared = wallet.prepareRelease(approved) + check(releaseOutbids(prepared.receipt, approved)) + approveRelease(prepared.receipt) + val recorded = lightningRepo.recordOnchainRelease(approved, prepared.receipt.txid) + ?: throw OnchainSendBlockedError(attempt) + when (prepared.broadcast()) { + is OnchainSendOutcome.Accepted -> finishRelease(recorded) + // A conflicting candidate may be in a mempool; keep the guard until a sync resolves it. + is OnchainSendOutcome.Rejected -> throw OnchainSendAbandonRefusedError(requestOpen = false) + is OnchainSendOutcome.Unknown -> throw OnchainSendAbandonPendingError() + } } } + /** Removes the original proof, then the attempt, once its release reached the network. */ + private suspend fun finishRelease(attempt: OnchainSendAttempt) { + if (!lightningRepo.abandonUnbroadcastOnchainSend(attempt) { removeOriginalShopProofs(it) }) { + throw OnchainSendBlockedError(attempt) + } + Logger.info( + "Released on-chain payment '${attempt.txid}' through '${attempt.releaseTxid}' after its request ended", + context = TAG, + ) + } + suspend fun completeRecoveredOnchainPayment(request: PaykitPaymentRequest, attempt: OnchainSendAttempt): Boolean = withContext(ioDispatcher) { val proof = operationMutex.withLock { diff --git a/app/src/main/java/to/bitkit/services/LightningService.kt b/app/src/main/java/to/bitkit/services/LightningService.kt index 4c5390da79..9783b95204 100644 --- a/app/src/main/java/to/bitkit/services/LightningService.kt +++ b/app/src/main/java/to/bitkit/services/LightningService.kt @@ -75,6 +75,7 @@ import to.bitkit.repositories.OnchainSendInput import to.bitkit.repositories.OnchainSendAttempt import to.bitkit.repositories.OnchainSendOutcome import to.bitkit.repositories.PreparedOnchainSend +import to.bitkit.repositories.releaseOutbids import to.bitkit.utils.AppError import to.bitkit.utils.LdkError import to.bitkit.utils.LdkLogWriter @@ -157,6 +158,7 @@ class LightningService internal constructor( companion object { private const val TAG = "LightningService" + private const val RELEASE_FEE_ATTEMPTS = 4 private const val NODE_ID_PREVIEW_LEN = 20 private const val SCORING_BASE_PENALTY_MSAT = 40_000uL @@ -1043,6 +1045,33 @@ class LightningService internal constructor( } } + suspend fun walletKnowsTransaction(txid: String): Boolean { + val originalNode = node ?: return false + return callOnchainSend { originalNode.getTransactionDetails(txid) != null } + } + + /** + * Spends the original's coins back to the wallet at a fee above every candidate's, so the release replaces any + * copy of the original a node still holds, and no candidate can confirm once it does. + */ + suspend fun prepareRelease(attempt: OnchainSendAttempt): PreparedOnchainSend { + val original = attempt.originalInputs.orEmpty().toSet() + val utxos = listSpendableOutputs().getOrThrow().filter { + OnchainSendInput(it.outpoint.txid, it.outpoint.vout) in original + } + if (utxos.isEmpty() || attempt.walletIndex != currentWalletIndex) throw ServiceError.NodeNotSetup() + val total = utxos.sumOf { it.valueSats } + var feeRate = maxOf(attempt.feeRateSatsPerVByte, attempt.candidateFeeRates.values.maxOrNull() ?: 0uL) + 2uL + val address = newAddress() + repeat(RELEASE_FEE_ATTEMPTS) { + val prepared = prepareOnchainSend(address, total / 2uL, feeRate, utxos, walletIndex = attempt.walletIndex) + check(prepared.receipt.inputs.any { it in original }) + if (releaseOutbids(prepared.receipt, attempt)) return prepared + feeRate *= 2uL + } + throw ServiceError.NodeNotSetup() + } + internal suspend fun callOnchainSend(block: suspend () -> T): T = ServiceQueue.LDK.background(ldkQueue) { runSuspendCatching { block() } }.getOrThrow() diff --git a/app/src/main/java/to/bitkit/ui/screens/wallets/send/SendPendingScreen.kt b/app/src/main/java/to/bitkit/ui/screens/wallets/send/SendPendingScreen.kt index 282c631f3a..5baa7bcc25 100644 --- a/app/src/main/java/to/bitkit/ui/screens/wallets/send/SendPendingScreen.kt +++ b/app/src/main/java/to/bitkit/ui/screens/wallets/send/SendPendingScreen.kt @@ -115,9 +115,9 @@ fun SendPendingScreen( AbandonExpiredPayment( isAbandoned = uiState.abandoned, showConfirmation = showAbandonConfirmation, - onConfirm = { + onConfirm = { requiresPin, useBiometrics -> showAbandonConfirmation = false - viewModel.abandonExpired() + viewModel.abandonExpired(requiresPin, useBiometrics) }, onDismiss = { showAbandonConfirmation = false }, onClose = onClose, @@ -159,10 +159,15 @@ fun SendPendingScreen( private fun AbandonExpiredPayment( isAbandoned: Boolean, showConfirmation: Boolean, - onConfirm: () -> Unit, + onConfirm: (requiresPin: Boolean, useBiometrics: Boolean) -> Unit, onDismiss: () -> Unit, onClose: () -> Unit, ) { + val settings = settingsViewModel ?: return + val isPinEnabled by settings.isPinEnabled.collectAsStateWithLifecycle() + val pinForPayments by settings.isPinForPaymentsEnabled.collectAsStateWithLifecycle() + val isBiometricEnabled by settings.isBiometricEnabled.collectAsStateWithLifecycle() + val isBiometrySupported = rememberBiometricAuthSupported() val abandonedToast = stringResource(R.string.wallet__send_pending__abandon_done) LaunchedEffect(isAbandoned) { if (isAbandoned) { @@ -176,7 +181,7 @@ private fun AbandonExpiredPayment( text = stringResource(R.string.wallet__send_pending__abandon_note), confirmText = stringResource(R.string.common__continue), dismissText = stringResource(R.string.common__cancel), - onConfirm = onConfirm, + onConfirm = { onConfirm(isPinEnabled && pinForPayments, isBiometricEnabled && isBiometrySupported) }, onDismiss = onDismiss, ) } @@ -188,6 +193,7 @@ private fun SendPendingUiState.abandonErrorMessage(): String? = abandonError?.le when (it) { AbandonError.MayStillConfirm -> R.string.wallet__send_pending__abandon_refused AbandonError.SyncUnavailable -> R.string.wallet__send_pending__abandon_sync + AbandonError.ReleasePending -> R.string.wallet__send_pending__abandon_pending } ) } @@ -252,6 +258,15 @@ private fun RecoveryAuthorization( onClick = { viewModel.answerRecoveryApproval(false) }) }, ) + is RecoveryApproval.Release -> AppAlertDialog( + title = stringResource(R.string.wallet__send_pending__abandon), + text = stringResource(R.string.wallet__send_pending__abandon_release) + .replace("{fee}", requireNotNull(approval.receipt.miningFeeSats).toString()), + confirmText = stringResource(R.string.wallet__send_yes), + dismissText = stringResource(R.string.common__cancel), + onConfirm = { viewModel.answerRecoveryApproval(true) }, + onDismiss = { viewModel.answerRecoveryApproval(false) }, + ) is RecoveryApproval.Warning -> AppAlertDialog( title = stringResource(R.string.common__are_you_sure), text = stringResource(approval.warning.message), diff --git a/app/src/main/java/to/bitkit/ui/screens/wallets/send/SendPendingViewModel.kt b/app/src/main/java/to/bitkit/ui/screens/wallets/send/SendPendingViewModel.kt index 90ffb63118..c77dbe1bf9 100644 --- a/app/src/main/java/to/bitkit/ui/screens/wallets/send/SendPendingViewModel.kt +++ b/app/src/main/java/to/bitkit/ui/screens/wallets/send/SendPendingViewModel.kt @@ -23,6 +23,7 @@ import to.bitkit.repositories.ActivityRepo import to.bitkit.repositories.LightningRepo import to.bitkit.repositories.OnchainRecoveryFeeRate import to.bitkit.repositories.OnchainPreparedReceipt +import to.bitkit.repositories.OnchainSendAbandonPendingError import to.bitkit.repositories.OnchainSendAbandonSyncError import to.bitkit.repositories.OnchainSendAttempt import to.bitkit.repositories.OnchainSendOutcome @@ -188,30 +189,44 @@ class SendPendingViewModel @Inject constructor( } } - fun abandonExpired() { + fun abandonExpired(requiresPin: Boolean, useBiometrics: Boolean) { val attempt = _uiState.value.abandonableAttempt ?: return if (_uiState.value.isAbandoning) return _uiState.update { it.copy(isAbandoning = true, abandonError = null) } viewModelScope.launch { - paykitPaymentProofRepo.abandonUnbroadcastOnchainPayment(attempt) { - lightningRepo.onchainCandidatesNeverReachedNetwork(it) - }.onSuccess { - _uiState.update { - it.copy(isAbandoning = false, abandoned = true, abandonableAttempt = null, recoveryAttempt = null) - } - }.onFailure { error -> - Logger.warn("Could not release on-chain payment '${attempt.txid}'", error, context = TAG) - _uiState.update { - it.copy( - isAbandoning = false, - abandonError = if (error is OnchainSendAbandonSyncError) { - AbandonError.SyncUnavailable - } else { - AbandonError.MayStillConfirm - }, - ) + try { + releaseExpired(attempt, requiresPin, useBiometrics) + } finally { + _uiState.update { it.copy(isAbandoning = false) } + } + } + } + + private suspend fun releaseExpired(attempt: OnchainSendAttempt, requiresPin: Boolean, useBiometrics: Boolean) { + paykitPaymentProofRepo.abandonUnbroadcastOnchainPayment(attempt, lightningRepo) { receipt -> + withContext(Dispatchers.Main.immediate) { + requestRecoveryApproval(RecoveryApproval.Release(receipt)) + if (requiresPin) { + requestRecoveryApproval(if (useBiometrics) RecoveryApproval.Biometrics else RecoveryApproval.Pin) } } + }.onSuccess { + _uiState.update { + it.copy(isAbandoning = false, abandoned = true, abandonableAttempt = null, recoveryAttempt = null) + } + }.onFailure { error -> + Logger.warn("Could not release on-chain payment '${attempt.txid}'", error, context = TAG) + _uiState.update { + it.copy( + isAbandoning = false, + abandonError = when (error) { + is CancellationException -> null + is OnchainSendAbandonSyncError -> AbandonError.SyncUnavailable + is OnchainSendAbandonPendingError -> AbandonError.ReleasePending + else -> AbandonError.MayStillConfirm + }, + ) + } } } @@ -318,11 +333,12 @@ data class SendPendingUiState( val abandonError: AbandonError? = null, ) -enum class AbandonError { MayStillConfirm, SyncUnavailable } +enum class AbandonError { MayStillConfirm, SyncUnavailable, ReleasePending } sealed interface RecoveryApproval { data class Fee(val receipt: OnchainPreparedReceipt) : RecoveryApproval data class Warning(val warning: SanityWarning) : RecoveryApproval data object Biometrics : RecoveryApproval data object Pin : RecoveryApproval + data class Release(val receipt: OnchainPreparedReceipt) : RecoveryApproval } diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 34fb9ca2c9..5e068753c2 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1634,9 +1634,11 @@ The maximum spendable amount is a bit lower due to a required reserve balance. Reserve Balance Cancel expired payment - Payment cancelled. Its coins are available again. - This payment\'s request has ended. Bitkit first syncs with the network and cancels the payment only if it never reached it. Its coins then become available again. + Payment cancelled. Its coins are back in your wallet. + This payment\'s request has ended. Bitkit first checks the network, then sends the payment\'s coins back to your wallet so the original payment can never confirm. This costs a network fee. + The cancellation was sent but is not confirmed yet. Open this payment again later to finish it. This payment may still reach the network, so it cannot be cancelled. + Send this payment\'s coins back to your wallet? Network fee: {fee} sats. Bitkit could not sync with the network. Check your connection and try again. The new payment was not sent. Finish the previous payment before trying again. This payment is taking a bit longer than expected. You can continue using Bitkit. diff --git a/app/src/test/java/to/bitkit/repositories/OnchainSendAttemptStoreTest.kt b/app/src/test/java/to/bitkit/repositories/OnchainSendAttemptStoreTest.kt index 4dd8424fdb..4f044a47f8 100644 --- a/app/src/test/java/to/bitkit/repositories/OnchainSendAttemptStoreTest.kt +++ b/app/src/test/java/to/bitkit/repositories/OnchainSendAttemptStoreTest.kt @@ -769,9 +769,11 @@ class OnchainSendAttemptStoreTest : BaseUnitTest() { assertFalse(store.abandonUnbroadcast(unknown) { false }) assertEquals(unknown, store.current()) + assertNull(store.recordRelease(unknown, "77".repeat(32)), "A release needs the approval first") val approved = requireNotNull(store.approveRelease(unknown)) assertTrue(approved.releaseApproved) assertEquals(approved, store.approveRelease(unknown)) + assertNull(store.recordRelease(approved, "ab".repeat(32)), "A candidate cannot be its own release") // A restore of the same operation keeps the local approval and its release. store.restoreActive(unknown.copy(restoredFromBackup = true)) val restored = requireNotNull(store.current()) diff --git a/app/src/test/java/to/bitkit/repositories/PaykitPaymentProofRepoTest.kt b/app/src/test/java/to/bitkit/repositories/PaykitPaymentProofRepoTest.kt index e01ae91901..13d3ebb872 100644 --- a/app/src/test/java/to/bitkit/repositories/PaykitPaymentProofRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/PaykitPaymentProofRepoTest.kt @@ -53,6 +53,7 @@ import org.mockito.kotlin.eq import org.mockito.kotlin.isNull import org.mockito.kotlin.mock import org.mockito.kotlin.never +import org.mockito.kotlin.reset import org.mockito.kotlin.times import org.mockito.kotlin.verify import org.mockito.kotlin.whenever @@ -2528,15 +2529,13 @@ class PaykitPaymentProofRepoTest : BaseUnitTest(StandardTestDispatcher()) { storedProofs = listOf(startedOnchainProof(attempt)) whenever(paykitSdkService.paymentRequests()).thenReturn(listOf(paymentRequestRecord(state = state))) stubAbandon(attempt) - var checked: OnchainSendAttempt? = null + val wallet = FakeReleaseWallet() - val result = paymentProofRepo().abandonUnbroadcastOnchainPayment(attempt) { - checked = it - true - } + val result = paymentProofRepo().abandonUnbroadcastOnchainPayment(attempt, wallet) {} assertTrue(result.isSuccess) - assertEquals(attempt, checked) + assertEquals(attempt, wallet.checked) + assertEquals(1, wallet.broadcasts) assertTrue(storedProofs.isEmpty()) } } @@ -2555,7 +2554,9 @@ class PaykitPaymentProofRepoTest : BaseUnitTest(StandardTestDispatcher()) { storedProofs = listOf(proof) whenever(paykitSdkService.paymentRequests()).thenReturn(listOf(record)) - val error = paymentProofRepo().abandonUnbroadcastOnchainPayment(attempt) { neverReached }.exceptionOrNull() + val error = paymentProofRepo() + .abandonUnbroadcastOnchainPayment(attempt, FakeReleaseWallet(neverReached = neverReached)) {} + .exceptionOrNull() assertEquals(requestOpen, (error as OnchainSendAbandonRefusedError).requestOpen) assertEquals(listOf(proof), storedProofs) @@ -2644,7 +2645,81 @@ class PaykitPaymentProofRepoTest : BaseUnitTest(StandardTestDispatcher()) { stubAbandon(approved) assertTrue(paymentProofRepo().onchainRequestHasEnded(approved)) - assertTrue(paymentProofRepo().abandonUnbroadcastOnchainPayment(approved) { true }.isSuccess) + assertTrue(paymentProofRepo().abandonUnbroadcastOnchainPayment(approved, FakeReleaseWallet()) {}.isSuccess) + } + + @Test + fun `rejected or unknown release keeps the guard and proof until the wallet sees it`() = test { + val outcomes = listOf<(String) -> OnchainSendOutcome>( + { OnchainSendOutcome.Rejected(it, "conflict") }, + { OnchainSendOutcome.Unknown(it) }, + ) + for (outcome in outcomes) { + reset(lightningRepo) + val attempt = unknownShopAttempt("ab".repeat(32)) + val proof = startedOnchainProof(attempt) + storedProofs = listOf(proof) + whenever(paykitSdkService.paymentRequests()) + .thenReturn(listOf(paymentRequestRecord(state = PaymentRequestLifecycleState.CANCELED))) + stubAbandon(attempt) + val wallet = FakeReleaseWallet(outcome = outcome) + + assertTrue(paymentProofRepo().abandonUnbroadcastOnchainPayment(attempt, wallet) {}.isFailure) + assertEquals(listOf(proof), storedProofs) + verify(lightningRepo, never()).abandonUnbroadcastOnchainSend(any(), any()) + + // Once a sync puts the release in the wallet, a later cancel finishes without another release. + val pending = attempt.copy(releaseApproved = true, releaseTxid = wallet.releaseTxid) + wallet.known = setOf(wallet.releaseTxid) + assertTrue(paymentProofRepo().abandonUnbroadcastOnchainPayment(pending, wallet) {}.isSuccess) + assertEquals(1, wallet.prepared) + assertTrue(storedProofs.isEmpty()) + } + } + + @Test + fun `release must spend an original coin and outbid every candidate`() { + val attempt = unknownShopAttempt("ab".repeat(32)).copy(candidateMiningFees = mapOf("ab".repeat(32) to 300uL)) + val inputs = requireNotNull(attempt.originalInputs) + fun receipt(spent: List, fee: ULong) = + OnchainPreparedReceipt("77".repeat(32), spent, "bcrt1qrelease", 500uL, miningFeeSats = fee) + assertTrue(releaseOutbids(receipt(inputs, 301uL), attempt)) + assertFalse(releaseOutbids(receipt(inputs, 300uL), attempt)) + assertFalse(releaseOutbids(receipt(listOf(OnchainSendInput("99".repeat(32), 7u)), 301uL), attempt)) + } + + private class FakeReleaseWallet( + var neverReached: Boolean = true, + var known: Set = emptySet(), + val outcome: (String) -> OnchainSendOutcome = { OnchainSendOutcome.Accepted(it) }, + ) : OnchainReleasing { + val releaseTxid = "77".repeat(32) + var prepared = 0 + var broadcasts = 0 + var checked: OnchainSendAttempt? = null + + override suspend fun onchainCandidatesNeverReachedNetwork(attempt: OnchainSendAttempt): Boolean { + checked = attempt + return neverReached + } + + override suspend fun walletKnowsTransaction(txid: String): Boolean = txid in known + + override suspend fun prepareRelease(attempt: OnchainSendAttempt): PreparedOnchainSend { + prepared++ + val receipt = OnchainPreparedReceipt( + txid = releaseTxid, + inputs = attempt.originalInputs.orEmpty(), + address = "bcrt1qrelease", + amountSats = 500uL, + miningFeeSats = attempt.highestCandidateFee + 1uL, + ) + return PreparedOnchainSend(receipt) { beforeDispatch -> + beforeDispatch() + broadcasts++ + outcome(releaseTxid) + } + } } private fun TestScope.observeRequestStateChanges() { @@ -2749,6 +2824,9 @@ class PaykitPaymentProofRepoTest : BaseUnitTest(StandardTestDispatcher()) { whenever(lightningRepo.approveOnchainRelease(any())).doSuspendableAnswer { it.getArgument(0).copy(releaseApproved = true) } + whenever(lightningRepo.recordOnchainRelease(any(), any())).doSuspendableAnswer { + it.getArgument(0).copy(releaseTxid = it.getArgument(1)) + } whenever(lightningRepo.abandonUnbroadcastOnchainSend(any(), any())).doSuspendableAnswer { it.getArgument Boolean>(1).invoke(attempt) } diff --git a/app/src/test/java/to/bitkit/ui/screens/wallets/send/SendPendingViewModelTest.kt b/app/src/test/java/to/bitkit/ui/screens/wallets/send/SendPendingViewModelTest.kt index 31b5bf2d7b..12efc451a9 100644 --- a/app/src/test/java/to/bitkit/ui/screens/wallets/send/SendPendingViewModelTest.kt +++ b/app/src/test/java/to/bitkit/ui/screens/wallets/send/SendPendingViewModelTest.kt @@ -284,18 +284,18 @@ class SendPendingViewModelTest : BaseUnitTest() { ) whenever(lightningRepo.currentOnchainSendAttempt()).thenReturn(original) whenever(paykitPaymentProofRepo.onchainRequestHasEnded(original)).thenReturn(true) - whenever(paykitPaymentProofRepo.abandonUnbroadcastOnchainPayment(any(), any())) + whenever(paykitPaymentProofRepo.abandonUnbroadcastOnchainPayment(any(), any(), any())) .thenReturn(Result.failure(OnchainSendAbandonSyncError()), Result.success(Unit)) sut.initOnchain(original.txid, amount) advanceUntilIdle() assertEquals(original, sut.uiState.value.abandonableAttempt) - sut.abandonExpired() + sut.abandonExpired(requiresPin = false, useBiometrics = false) advanceUntilIdle() assertEquals(AbandonError.SyncUnavailable, sut.uiState.value.abandonError) assertEquals(false, sut.uiState.value.abandoned) - sut.abandonExpired() + sut.abandonExpired(requiresPin = false, useBiometrics = false) advanceUntilIdle() assertEquals(true, sut.uiState.value.abandoned) assertNull(sut.uiState.value.abandonableAttempt) diff --git a/changelog.d/next/1551.fixed.md b/changelog.d/next/1551.fixed.md index 69ac092b72..272894b859 100644 --- a/changelog.d/next/1551.fixed.md +++ b/changelog.d/next/1551.fixed.md @@ -1 +1 @@ -An on-chain payment whose request expired before it reached the network can now be cancelled, so it no longer blocks new on-chain payments. +An on-chain payment whose request ended before it reached the network can now be cancelled, which sends its coins back to the wallet so it no longer blocks new on-chain payments. From ab1046c2c76538e12cf15635e74f17ed5532707d Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Sat, 17 Oct 2026 02:36:22 +0200 Subject: [PATCH 09/13] fix: accept the requested sync and release small payments --- .../to/bitkit/repositories/LightningRepo.kt | 17 ++++++++++++----- .../java/to/bitkit/services/LightningService.kt | 12 ++++++++++-- .../to/bitkit/repositories/LightningRepoTest.kt | 2 ++ 3 files changed, 24 insertions(+), 7 deletions(-) diff --git a/app/src/main/java/to/bitkit/repositories/LightningRepo.kt b/app/src/main/java/to/bitkit/repositories/LightningRepo.kt index aa82bfd0f6..275fc797b8 100644 --- a/app/src/main/java/to/bitkit/repositories/LightningRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/LightningRepo.kt @@ -1869,11 +1869,14 @@ class LightningRepo @Inject constructor( attempt: OnchainSendAttempt, timeout: Duration, ): Boolean { - // Only an on-chain sync that finishes after this call moves the timestamp past the one read here. + // The requested sync runs on the serial LDK queue after any sync already in flight, so its success proves a + // fresh view. Failing that, an on-chain sync that finishes later than the one read here also counts. val previousSync = lightningService.status?.latestOnchainWalletSyncTimestamp ?: 0uL withTimeoutOrNull(timeout) { - sync() - while (!isOnchainSyncAfter(lightningService.status?.latestOnchainWalletSyncTimestamp, previousSync)) { + val requestedSyncSucceeded = runSuspendCatching { lightningService.sync() }.isSuccess + while (true) { + val latestSync = lightningService.status?.latestOnchainWalletSyncTimestamp + if (isOnchainSyncAfter(latestSync, previousSync, requestedSyncSucceeded)) break delay(1.seconds) } } ?: throw OnchainSendAbandonSyncError() @@ -2805,5 +2808,9 @@ sealed interface ProbeOutcome { ) : ProbeOutcome } -/** A sync finished in the same second as the one read before a request is not a new sync. */ -internal fun isOnchainSyncAfter(latest: ULong?, previous: ULong): Boolean = (latest ?: 0uL) > previous +/** + * The sync requested for a check proves a fresh view once it succeeds. Otherwise only a sync that finished later than + * the one read before the request counts; one finished in the same second may predate it. + */ +internal fun isOnchainSyncAfter(latest: ULong?, previous: ULong, requestedSyncSucceeded: Boolean = false): Boolean = + requestedSyncSucceeded || (latest ?: 0uL) > previous diff --git a/app/src/main/java/to/bitkit/services/LightningService.kt b/app/src/main/java/to/bitkit/services/LightningService.kt index 9783b95204..2977025a54 100644 --- a/app/src/main/java/to/bitkit/services/LightningService.kt +++ b/app/src/main/java/to/bitkit/services/LightningService.kt @@ -159,6 +159,9 @@ class LightningService internal constructor( companion object { private const val TAG = "LightningService" private const val RELEASE_FEE_ATTEMPTS = 4 + + /** A small fixed payment to a new wallet address; the rest returns as change, which pays the fee. */ + private const val RELEASE_AMOUNT_SATS = 1_000uL private const val NODE_ID_PREVIEW_LEN = 20 private const val SCORING_BASE_PENALTY_MSAT = 40_000uL @@ -1060,11 +1063,16 @@ class LightningService internal constructor( OnchainSendInput(it.outpoint.txid, it.outpoint.vout) in original } if (utxos.isEmpty() || attempt.walletIndex != currentWalletIndex) throw ServiceError.NodeNotSetup() - val total = utxos.sumOf { it.valueSats } var feeRate = maxOf(attempt.feeRateSatsPerVByte, attempt.candidateFeeRates.values.maxOrNull() ?: 0uL) + 2uL val address = newAddress() repeat(RELEASE_FEE_ATTEMPTS) { - val prepared = prepareOnchainSend(address, total / 2uL, feeRate, utxos, walletIndex = attempt.walletIndex) + val prepared = prepareOnchainSend( + address = address, + sats = RELEASE_AMOUNT_SATS, + satsPerVByte = feeRate, + utxosToSpend = utxos, + walletIndex = attempt.walletIndex, + ) check(prepared.receipt.inputs.any { it in original }) if (releaseOutbids(prepared.receipt, attempt)) return prepared feeRate *= 2uL diff --git a/app/src/test/java/to/bitkit/repositories/LightningRepoTest.kt b/app/src/test/java/to/bitkit/repositories/LightningRepoTest.kt index bcc66b2c1b..f8459b1074 100644 --- a/app/src/test/java/to/bitkit/repositories/LightningRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/LightningRepoTest.kt @@ -3794,5 +3794,7 @@ class LightningRepoTest : BaseUnitTest() { assertFalse(isOnchainSyncAfter(null, 0uL)) assertFalse(isOnchainSyncAfter(1_800_000_000uL, 1_800_000_000uL)) assertTrue(isOnchainSyncAfter(1_800_000_001uL, 1_800_000_000uL)) + // The requested sync's own success counts even when it finished in the same second. + assertTrue(isOnchainSyncAfter(1_800_000_000uL, 1_800_000_000uL, requestedSyncSucceeded = true)) } } From 4a3f12bbe198f7bd4084fbd7ae7da99f23a1b4e2 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Sat, 17 Oct 2026 02:38:21 +0200 Subject: [PATCH 10/13] fix: resume a cancel from the attempt's saved release --- .../wallets/send/SendPendingViewModel.kt | 5 +++- .../wallets/send/SendPendingViewModelTest.kt | 23 +++++++++++++++++++ 2 files changed, 27 insertions(+), 1 deletion(-) diff --git a/app/src/main/java/to/bitkit/ui/screens/wallets/send/SendPendingViewModel.kt b/app/src/main/java/to/bitkit/ui/screens/wallets/send/SendPendingViewModel.kt index c77dbe1bf9..cd9ccf35b7 100644 --- a/app/src/main/java/to/bitkit/ui/screens/wallets/send/SendPendingViewModel.kt +++ b/app/src/main/java/to/bitkit/ui/screens/wallets/send/SendPendingViewModel.kt @@ -202,7 +202,10 @@ class SendPendingViewModel @Inject constructor( } } - private suspend fun releaseExpired(attempt: OnchainSendAttempt, requiresPin: Boolean, useBiometrics: Boolean) { + private suspend fun releaseExpired(shown: OnchainSendAttempt, requiresPin: Boolean, useBiometrics: Boolean) { + // An earlier try may have saved its approval and release since the screen loaded the attempt. + val attempt = runSuspendCatching { lightningRepo.currentOnchainSendAttempt() }.getOrNull() + ?.takeIf { it.attemptId == shown.attemptId } ?: shown paykitPaymentProofRepo.abandonUnbroadcastOnchainPayment(attempt, lightningRepo) { receipt -> withContext(Dispatchers.Main.immediate) { requestRecoveryApproval(RecoveryApproval.Release(receipt)) diff --git a/app/src/test/java/to/bitkit/ui/screens/wallets/send/SendPendingViewModelTest.kt b/app/src/test/java/to/bitkit/ui/screens/wallets/send/SendPendingViewModelTest.kt index 12efc451a9..1b6569d865 100644 --- a/app/src/test/java/to/bitkit/ui/screens/wallets/send/SendPendingViewModelTest.kt +++ b/app/src/test/java/to/bitkit/ui/screens/wallets/send/SendPendingViewModelTest.kt @@ -16,7 +16,9 @@ import org.junit.Before import org.junit.Test import org.mockito.kotlin.any import org.mockito.kotlin.doReturn +import org.mockito.kotlin.eq import org.mockito.kotlin.mock +import org.mockito.kotlin.verify import org.mockito.kotlin.whenever import to.bitkit.models.WalletScope import to.bitkit.repositories.ActivityRepo @@ -319,6 +321,27 @@ class SendPendingViewModelTest : BaseUnitTest() { assertEquals(original, sut.uiState.value.abandonableAttempt) } + @Test + fun `a second cancel uses the attempt the first one saved`() = test { + val original = pendingOriginal().copy( + requestId = PaykitPaymentRequestId("request", "payee"), + orderId = "order", + payerIdentity = "payer", + ) + val saved = original.copy(releaseApproved = true, releaseTxid = "77".repeat(32)) + whenever(lightningRepo.currentOnchainSendAttempt()).thenReturn(original, saved) + whenever(paykitPaymentProofRepo.onchainRequestHasEnded(original)).thenReturn(true) + whenever(paykitPaymentProofRepo.abandonUnbroadcastOnchainPayment(any(), any(), any())) + .thenReturn(Result.success(Unit)) + sut.initOnchain(original.txid, amount) + advanceUntilIdle() + + sut.abandonExpired(requiresPin = false, useBiometrics = false) + advanceUntilIdle() + + verify(paykitPaymentProofRepo).abandonUnbroadcastOnchainPayment(eq(saved), any(), any()) + } + @Test fun `ordinary unknown payment offers no cancel`() = test { val original = pendingOriginal() From 0c7b84458de653318aadeed24ba013820552ab03 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Sat, 17 Oct 2026 02:45:09 +0200 Subject: [PATCH 11/13] fix: cancel restored payments and finish when a coin is already spent --- .../to/bitkit/repositories/LightningRepo.kt | 3 +++ .../repositories/OnchainSendAttemptStore.kt | 5 ++++- .../repositories/PaykitPaymentProofRepo.kt | 3 +++ .../java/to/bitkit/services/LightningService.kt | 10 ++++++++++ .../screens/wallets/send/SendPendingScreen.kt | 4 ++-- .../wallets/send/SendPendingViewModel.kt | 14 +++++++++++--- .../repositories/OnchainSendAttemptStoreTest.kt | 3 ++- .../repositories/PaykitPaymentProofRepoTest.kt | 17 +++++++++++++++++ .../wallets/send/SendPendingViewModelTest.kt | 3 ++- 9 files changed, 54 insertions(+), 8 deletions(-) diff --git a/app/src/main/java/to/bitkit/repositories/LightningRepo.kt b/app/src/main/java/to/bitkit/repositories/LightningRepo.kt index 275fc797b8..7f5e931835 100644 --- a/app/src/main/java/to/bitkit/repositories/LightningRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/LightningRepo.kt @@ -1885,6 +1885,9 @@ class LightningRepo @Inject constructor( override suspend fun walletKnowsTransaction(txid: String): Boolean = lightningService.walletKnowsTransaction(txid) + override suspend fun originalCoinSpentElsewhere(attempt: OnchainSendAttempt): Boolean = + lightningService.originalCoinSpentElsewhere(attempt) + override suspend fun prepareRelease(attempt: OnchainSendAttempt): PreparedOnchainSend = lightningService.prepareRelease(attempt) diff --git a/app/src/main/java/to/bitkit/repositories/OnchainSendAttemptStore.kt b/app/src/main/java/to/bitkit/repositories/OnchainSendAttemptStore.kt index efd6fe5aea..8765120660 100644 --- a/app/src/main/java/to/bitkit/repositories/OnchainSendAttemptStore.kt +++ b/app/src/main/java/to/bitkit/repositories/OnchainSendAttemptStore.kt @@ -122,7 +122,7 @@ data class OnchainSendAttempt( /** A Shop payment from the node wallet that dispatched but never had its acceptance confirmed. */ val canBeAbandoned: Boolean - get() = isUnresolved && requestId != null && !isTransfer && (!restoredFromBackup || releaseApproved) && + get() = isUnresolved && requestId != null && !isTransfer && walletId == WalletScope.default && !payerIdentity.isNullOrBlank() && initialDispatchAttempted && txid != null && !originalInputs.isNullOrEmpty() && candidateTxids.isNotEmpty() } @@ -152,6 +152,9 @@ fun releaseOutbids(receipt: OnchainPreparedReceipt, attempt: OnchainSendAttempt) interface OnchainReleasing { suspend fun onchainCandidatesNeverReachedNetwork(attempt: OnchainSendAttempt): Boolean suspend fun walletKnowsTransaction(txid: String): Boolean + + /** Whether a transaction that is not a candidate already spent one of the original's coins. */ + suspend fun originalCoinSpentElsewhere(attempt: OnchainSendAttempt): Boolean suspend fun prepareRelease(attempt: OnchainSendAttempt): PreparedOnchainSend } diff --git a/app/src/main/java/to/bitkit/repositories/PaykitPaymentProofRepo.kt b/app/src/main/java/to/bitkit/repositories/PaykitPaymentProofRepo.kt index 58962d6eba..c78d0dba3e 100644 --- a/app/src/main/java/to/bitkit/repositories/PaykitPaymentProofRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/PaykitPaymentProofRepo.kt @@ -538,6 +538,9 @@ class PaykitPaymentProofRepo @Inject constructor( if (!wallet.onchainCandidatesNeverReachedNetwork(attempt)) { throw OnchainSendAbandonRefusedError(requestOpen = false) } + // No candidate is known, yet a coin is spent: an earlier release, or another transaction, already made + // the original unconfirmable, including one whose ID a restart or a restore lost. + if (wallet.originalCoinSpentElsewhere(attempt)) return@runSuspendCatching finishRelease(attempt) val approved = if (attempt.releaseApproved) attempt else lightningRepo.approveOnchainRelease(attempt) approved ?: throw OnchainSendBlockedError(attempt) val prepared = wallet.prepareRelease(approved) diff --git a/app/src/main/java/to/bitkit/services/LightningService.kt b/app/src/main/java/to/bitkit/services/LightningService.kt index 2977025a54..fd21a7d300 100644 --- a/app/src/main/java/to/bitkit/services/LightningService.kt +++ b/app/src/main/java/to/bitkit/services/LightningService.kt @@ -1048,6 +1048,16 @@ class LightningService internal constructor( } } + /** Whether one of the original's coins is no longer spendable, so a transaction the wallet knows spent it. */ + suspend fun originalCoinSpentElsewhere(attempt: OnchainSendAttempt): Boolean { + val original = attempt.originalInputs.orEmpty() + if (original.isEmpty()) return false + val spendable = listSpendableOutputs().getOrThrow() + .map { OnchainSendInput(it.outpoint.txid, it.outpoint.vout) } + .toSet() + return original.any { it !in spendable } + } + suspend fun walletKnowsTransaction(txid: String): Boolean { val originalNode = node ?: return false return callOnchainSend { originalNode.getTransactionDetails(txid) != null } diff --git a/app/src/main/java/to/bitkit/ui/screens/wallets/send/SendPendingScreen.kt b/app/src/main/java/to/bitkit/ui/screens/wallets/send/SendPendingScreen.kt index 5baa7bcc25..93fdb80d8e 100644 --- a/app/src/main/java/to/bitkit/ui/screens/wallets/send/SendPendingScreen.kt +++ b/app/src/main/java/to/bitkit/ui/screens/wallets/send/SendPendingScreen.kt @@ -428,7 +428,7 @@ internal fun SendPendingContent( if (canRetry) { SecondaryButton( text = stringResource(R.string.wallet__send_pending__retry_title), - enabled = !isRecovering, + enabled = !isRecovering && !isAbandoning, onClick = onRetry, modifier = Modifier.fillMaxWidth(), ) @@ -437,7 +437,7 @@ internal fun SendPendingContent( if (canAbandon) { SecondaryButton( text = stringResource(R.string.wallet__send_pending__abandon), - enabled = !isAbandoning, + enabled = !isAbandoning && !isRecovering, onClick = onAbandon, modifier = Modifier .fillMaxWidth() diff --git a/app/src/main/java/to/bitkit/ui/screens/wallets/send/SendPendingViewModel.kt b/app/src/main/java/to/bitkit/ui/screens/wallets/send/SendPendingViewModel.kt index cd9ccf35b7..33c37c0a6c 100644 --- a/app/src/main/java/to/bitkit/ui/screens/wallets/send/SendPendingViewModel.kt +++ b/app/src/main/java/to/bitkit/ui/screens/wallets/send/SendPendingViewModel.kt @@ -179,9 +179,17 @@ class SendPendingViewModel @Inject constructor( private fun offerAbandonIfRequestEnded(attempt: OnchainSendAttempt) { if (!attempt.canBeAbandoned) return viewModelScope.launch { - while (_uiState.value.recoveryAttempt == attempt && _uiState.value.abandonableAttempt == null) { - if (runSuspendCatching { paykitPaymentProofRepo.onchainRequestHasEnded(attempt) }.getOrDefault(false)) { - _uiState.update { it.copy(abandonableAttempt = attempt) } + var current = attempt + while (_uiState.value.recoveryAttempt?.attemptId == attempt.attemptId && + _uiState.value.abandonableAttempt == null + ) { + // A retry from this screen can add a candidate; offer the attempt as it is stored now. + current = runSuspendCatching { lightningRepo.currentOnchainSendAttempt() }.getOrNull() + ?.takeIf { it.attemptId == attempt.attemptId } ?: current + val hasEnded = current.canBeAbandoned && + runSuspendCatching { paykitPaymentProofRepo.onchainRequestHasEnded(current) }.getOrDefault(false) + if (hasEnded) { + _uiState.update { it.copy(abandonableAttempt = current) } return@launch } delay(ABANDON_CHECK_INTERVAL) diff --git a/app/src/test/java/to/bitkit/repositories/OnchainSendAttemptStoreTest.kt b/app/src/test/java/to/bitkit/repositories/OnchainSendAttemptStoreTest.kt index 4f044a47f8..8b2b016520 100644 --- a/app/src/test/java/to/bitkit/repositories/OnchainSendAttemptStoreTest.kt +++ b/app/src/test/java/to/bitkit/repositories/OnchainSendAttemptStoreTest.kt @@ -765,7 +765,8 @@ class OnchainSendAttemptStoreTest : BaseUnitTest() { store.recordOutcome(attempt.attemptId, outcome, 0) val unknown = requireNotNull(store.current()) assertTrue(unknown.canBeAbandoned) - assertFalse(unknown.copy(restoredFromBackup = true).canBeAbandoned) + // The release transaction, not the restore marker, keeps a restored attempt safe to cancel. + assertTrue(unknown.copy(restoredFromBackup = true).canBeAbandoned) assertFalse(store.abandonUnbroadcast(unknown) { false }) assertEquals(unknown, store.current()) diff --git a/app/src/test/java/to/bitkit/repositories/PaykitPaymentProofRepoTest.kt b/app/src/test/java/to/bitkit/repositories/PaykitPaymentProofRepoTest.kt index 13d3ebb872..5ca785b9ad 100644 --- a/app/src/test/java/to/bitkit/repositories/PaykitPaymentProofRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/PaykitPaymentProofRepoTest.kt @@ -2677,6 +2677,20 @@ class PaykitPaymentProofRepoTest : BaseUnitTest(StandardTestDispatcher()) { } } + @Test + fun `a coin already spent elsewhere finishes the cancel without another release`() = test { + val attempt = unknownShopAttempt("ab".repeat(32)).copy(restoredFromBackup = true) + storedProofs = listOf(startedOnchainProof(attempt)) + whenever(paykitSdkService.paymentRequests()) + .thenReturn(listOf(paymentRequestRecord(state = PaymentRequestLifecycleState.CANCELED))) + stubAbandon(attempt) + val wallet = FakeReleaseWallet(spentElsewhere = true) + + assertTrue(paymentProofRepo().abandonUnbroadcastOnchainPayment(attempt, wallet) {}.isSuccess) + assertEquals(0, wallet.prepared) + assertTrue(storedProofs.isEmpty()) + } + @Test fun `release must spend an original coin and outbid every candidate`() { val attempt = unknownShopAttempt("ab".repeat(32)).copy(candidateMiningFees = mapOf("ab".repeat(32) to 300uL)) @@ -2691,6 +2705,7 @@ class PaykitPaymentProofRepoTest : BaseUnitTest(StandardTestDispatcher()) { private class FakeReleaseWallet( var neverReached: Boolean = true, var known: Set = emptySet(), + var spentElsewhere: Boolean = false, val outcome: (String) -> OnchainSendOutcome = { OnchainSendOutcome.Accepted(it) }, ) : OnchainReleasing { val releaseTxid = "77".repeat(32) @@ -2705,6 +2720,8 @@ class PaykitPaymentProofRepoTest : BaseUnitTest(StandardTestDispatcher()) { override suspend fun walletKnowsTransaction(txid: String): Boolean = txid in known + override suspend fun originalCoinSpentElsewhere(attempt: OnchainSendAttempt): Boolean = spentElsewhere + override suspend fun prepareRelease(attempt: OnchainSendAttempt): PreparedOnchainSend { prepared++ val receipt = OnchainPreparedReceipt( diff --git a/app/src/test/java/to/bitkit/ui/screens/wallets/send/SendPendingViewModelTest.kt b/app/src/test/java/to/bitkit/ui/screens/wallets/send/SendPendingViewModelTest.kt index 1b6569d865..b27dc311fc 100644 --- a/app/src/test/java/to/bitkit/ui/screens/wallets/send/SendPendingViewModelTest.kt +++ b/app/src/test/java/to/bitkit/ui/screens/wallets/send/SendPendingViewModelTest.kt @@ -329,7 +329,8 @@ class SendPendingViewModelTest : BaseUnitTest() { payerIdentity = "payer", ) val saved = original.copy(releaseApproved = true, releaseTxid = "77".repeat(32)) - whenever(lightningRepo.currentOnchainSendAttempt()).thenReturn(original, saved) + // Read once when the screen opens, once by the cancel poll, then by the cancel itself. + whenever(lightningRepo.currentOnchainSendAttempt()).thenReturn(original, original, saved) whenever(paykitPaymentProofRepo.onchainRequestHasEnded(original)).thenReturn(true) whenever(paykitPaymentProofRepo.abandonUnbroadcastOnchainPayment(any(), any(), any())) .thenReturn(Result.success(Unit)) From 03c7e733f9e30fa7a065e238a04a7af077446e6a Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Sat, 17 Oct 2026 03:54:04 +0200 Subject: [PATCH 12/13] fix: show progress and failures while a cancel checks the network --- .../main/java/to/bitkit/repositories/LightningRepo.kt | 8 +++++--- .../ui/screens/wallets/send/SendPendingScreen.kt | 10 ++++++++++ app/src/main/res/values/strings.xml | 1 + 3 files changed, 16 insertions(+), 3 deletions(-) diff --git a/app/src/main/java/to/bitkit/repositories/LightningRepo.kt b/app/src/main/java/to/bitkit/repositories/LightningRepo.kt index 7f5e931835..05695bec44 100644 --- a/app/src/main/java/to/bitkit/repositories/LightningRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/LightningRepo.kt @@ -1872,12 +1872,14 @@ class LightningRepo @Inject constructor( // The requested sync runs on the serial LDK queue after any sync already in flight, so its success proves a // fresh view. Failing that, an on-chain sync that finishes later than the one read here also counts. val previousSync = lightningService.status?.latestOnchainWalletSyncTimestamp ?: 0uL + // The sync blocks on the LDK queue and ignores cancellation, so it runs apart and the wait keeps its limit. + val requestedSyncSucceeded = AtomicBoolean(false) + scope.launch { if (runSuspendCatching { lightningService.sync() }.isSuccess) requestedSyncSucceeded.set(true) } withTimeoutOrNull(timeout) { - val requestedSyncSucceeded = runSuspendCatching { lightningService.sync() }.isSuccess while (true) { val latestSync = lightningService.status?.latestOnchainWalletSyncTimestamp - if (isOnchainSyncAfter(latestSync, previousSync, requestedSyncSucceeded)) break - delay(1.seconds) + if (isOnchainSyncAfter(latestSync, previousSync, requestedSyncSucceeded.get())) break + delay(500.milliseconds) } } ?: throw OnchainSendAbandonSyncError() return lightningService.walletKnowsNoCandidate(attempt) diff --git a/app/src/main/java/to/bitkit/ui/screens/wallets/send/SendPendingScreen.kt b/app/src/main/java/to/bitkit/ui/screens/wallets/send/SendPendingScreen.kt index 93fdb80d8e..c79e96804b 100644 --- a/app/src/main/java/to/bitkit/ui/screens/wallets/send/SendPendingScreen.kt +++ b/app/src/main/java/to/bitkit/ui/screens/wallets/send/SendPendingScreen.kt @@ -113,6 +113,7 @@ fun SendPendingScreen( var showAbandonConfirmation by remember { mutableStateOf(false) } AbandonExpiredPayment( + abandonError = uiState.abandonErrorMessage(), isAbandoned = uiState.abandoned, showConfirmation = showAbandonConfirmation, onConfirm = { requiresPin, useBiometrics -> @@ -157,6 +158,7 @@ fun SendPendingScreen( @Composable private fun AbandonExpiredPayment( + abandonError: String?, isAbandoned: Boolean, showConfirmation: Boolean, onConfirm: (requiresPin: Boolean, useBiometrics: Boolean) -> Unit, @@ -169,6 +171,10 @@ private fun AbandonExpiredPayment( val isBiometricEnabled by settings.isBiometricEnabled.collectAsStateWithLifecycle() val isBiometrySupported = rememberBiometricAuthSupported() val abandonedToast = stringResource(R.string.wallet__send_pending__abandon_done) + // The cancel can take up to 30 s; a failure is announced, not only shown above the hourglass. + LaunchedEffect(abandonError) { + abandonError?.let { ToastEventBus.send(type = Toast.ToastType.WARNING, title = it) } + } LaunchedEffect(isAbandoned) { if (isAbandoned) { ToastEventBus.send(type = Toast.ToastType.SUCCESS, title = abandonedToast) @@ -443,6 +449,10 @@ internal fun SendPendingContent( .fillMaxWidth() .testTag("AbandonExpiredOnchainPayment"), ) + if (isAbandoning) { + VerticalSpacer(8.dp) + BodyM(stringResource(R.string.wallet__send_pending__abandon_checking), color = Colors.White64) + } VerticalSpacer(16.dp) } Row(horizontalArrangement = Arrangement.spacedBy(16.dp)) { diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 5e068753c2..66e3c571cd 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1634,6 +1634,7 @@ The maximum spendable amount is a bit lower due to a required reserve balance. Reserve Balance Cancel expired payment + Checking the network. This can take up to 30 seconds. Payment cancelled. Its coins are back in your wallet. This payment\'s request has ended. Bitkit first checks the network, then sends the payment\'s coins back to your wallet so the original payment can never confirm. This costs a network fee. The cancellation was sent but is not confirmed yet. Open this payment again later to finish it. From f22c59644ccbdb341939d80277ed669b1827d099 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Sat, 17 Oct 2026 16:05:47 +0200 Subject: [PATCH 13/13] fix: outbid every candidate after a restore and finish a cut-short cancel --- .../models/ActiveOnchainAttemptBackup.kt | 9 +++ .../repositories/PaykitPaymentProofRepo.kt | 7 ++- .../OnchainSendAttemptStoreTest.kt | 3 +- .../PaykitPaymentProofRepoTest.kt | 62 +++++++++++++++++++ 4 files changed, 77 insertions(+), 4 deletions(-) diff --git a/app/src/main/java/to/bitkit/models/ActiveOnchainAttemptBackup.kt b/app/src/main/java/to/bitkit/models/ActiveOnchainAttemptBackup.kt index 89c4c9956c..b8f465f7bd 100644 --- a/app/src/main/java/to/bitkit/models/ActiveOnchainAttemptBackup.kt +++ b/app/src/main/java/to/bitkit/models/ActiveOnchainAttemptBackup.kt @@ -30,6 +30,8 @@ data class ActiveOnchainAttemptBackup( val candidateTxids: List = emptyList(), val feeRateSatsPerVByte: String, val candidateFeeRates: Map? = null, + // Lets a release after a restore still outbid every candidate's absolute fee. + val candidateMiningFees: Map? = null, val followup: Followup? = null, val transfer: Transfer? = null, ) { @@ -75,6 +77,10 @@ data class ActiveOnchainAttemptBackup( require(txid.matches(HEX) && txid in candidateTxids) unsigned(rate).also { require(it > 0uL && it <= UInt.MAX_VALUE.toULong()) } } + val restoredMiningFees = candidateMiningFees.orEmpty().mapValues { (txid, fee) -> + require(txid.matches(HEX) && txid in candidateTxids) + unsigned(fee) + } val inputs = originalInputs?.map { input -> require(input.txid.matches(HEX) && unsigned(input.vout) <= UInt.MAX_VALUE.toULong()) OnchainSendInput(input.txid, unsigned(input.vout).toUInt()) @@ -128,6 +134,7 @@ data class ActiveOnchainAttemptBackup( channelId = followup?.channelId, tags = followup?.tags.orEmpty(), evidence = evidence, txid = txid, refusalReason = rejectionReason, localFollowupComplete = false, originalInputs = inputs, candidateTxids = candidateTxids, candidateFeeRates = restoredFeeRates, + candidateMiningFees = restoredMiningFees, transferContext = transferContext, backupFollowup = followup, restoredFromBackup = true, ) @@ -175,6 +182,8 @@ data class ActiveOnchainAttemptBackup( candidateTxids = attempt.candidateTxids, feeRateSatsPerVByte = attempt.feeRateSatsPerVByte.toString(), candidateFeeRates = attempt.candidateFeeRates.takeIf { it.isNotEmpty() } ?.mapValues { it.value.toString() }, + candidateMiningFees = attempt.candidateMiningFees.takeIf { it.isNotEmpty() } + ?.mapValues { it.value.toString() }, followup = attempt.backupFollowup, transfer = attempt.transferContext?.let { Transfer( diff --git a/app/src/main/java/to/bitkit/repositories/PaykitPaymentProofRepo.kt b/app/src/main/java/to/bitkit/repositories/PaykitPaymentProofRepo.kt index c78d0dba3e..fffb95d660 100644 --- a/app/src/main/java/to/bitkit/repositories/PaykitPaymentProofRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/PaykitPaymentProofRepo.kt @@ -538,11 +538,12 @@ class PaykitPaymentProofRepo @Inject constructor( if (!wallet.onchainCandidatesNeverReachedNetwork(attempt)) { throw OnchainSendAbandonRefusedError(requestOpen = false) } - // No candidate is known, yet a coin is spent: an earlier release, or another transaction, already made - // the original unconfirmable, including one whose ID a restart or a restore lost. - if (wallet.originalCoinSpentElsewhere(attempt)) return@runSuspendCatching finishRelease(attempt) + // Approve before anything changes, so a cancel cut short after the proof is removed can still finish. val approved = if (attempt.releaseApproved) attempt else lightningRepo.approveOnchainRelease(attempt) approved ?: throw OnchainSendBlockedError(attempt) + // No candidate is known, yet a coin is spent: an earlier release, or another transaction, already made + // the original unconfirmable, including one whose ID a restart or a restore lost. + if (wallet.originalCoinSpentElsewhere(approved)) return@runSuspendCatching finishRelease(approved) val prepared = wallet.prepareRelease(approved) check(releaseOutbids(prepared.receipt, approved)) approveRelease(prepared.receipt) diff --git a/app/src/test/java/to/bitkit/repositories/OnchainSendAttemptStoreTest.kt b/app/src/test/java/to/bitkit/repositories/OnchainSendAttemptStoreTest.kt index 8b2b016520..5a2097c0fc 100644 --- a/app/src/test/java/to/bitkit/repositories/OnchainSendAttemptStoreTest.kt +++ b/app/src/test/java/to/bitkit/repositories/OnchainSendAttemptStoreTest.kt @@ -72,7 +72,8 @@ class OnchainSendAttemptStoreTest : BaseUnitTest() { val accepted = reopened.recordOutcome(attempt.attemptId, OnchainSendOutcome.Accepted(replacement.txid), 0) val backup = ActiveOnchainAttemptBackup.from(accepted, "regtest", "11".repeat(32)) val restored = backup.restored("regtest", backup.wallet.binding, attempt.walletId, 0) - assertTrue(restored.candidateMiningFees.isEmpty()) + // The backup carries each candidate's mining fee, so a release after a restore still outbids it. + assertEquals(pending.candidateMiningFees, restored.candidateMiningFees) reopened.restoreActive(restored) assertEquals(accepted.candidateMiningFees, reopened.current()?.candidateMiningFees) } diff --git a/app/src/test/java/to/bitkit/repositories/PaykitPaymentProofRepoTest.kt b/app/src/test/java/to/bitkit/repositories/PaykitPaymentProofRepoTest.kt index 5ca785b9ad..87918fd97a 100644 --- a/app/src/test/java/to/bitkit/repositories/PaykitPaymentProofRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/PaykitPaymentProofRepoTest.kt @@ -58,6 +58,7 @@ import org.mockito.kotlin.times import org.mockito.kotlin.verify import org.mockito.kotlin.whenever import to.bitkit.data.keychain.Keychain +import to.bitkit.models.ActiveOnchainAttemptBackup import to.bitkit.models.HwConnectedDevice import to.bitkit.models.HwFundingSignedTx import to.bitkit.models.HwFundingTransaction @@ -2691,6 +2692,67 @@ class PaykitPaymentProofRepoTest : BaseUnitTest(StandardTestDispatcher()) { assertTrue(storedProofs.isEmpty()) } + @Test + fun `release after a restore must still pay more than the candidate it replaces`() { + val txid = "ab".repeat(32) + // Candidate: one P2WPKH input, a P2TR recipient and P2WPKH change, ~153 vB at 30 sat/vB = 4,590 sats. + val beforeWipe = unknownShopAttempt(txid).copy( + attemptId = "0b8c6d1e-2f3a-4b5c-8d9e-0f1a2b3c4d5e", + feeRateSatsPerVByte = 30uL, + candidateFeeRates = mapOf(txid to 30uL), + candidateMiningFees = mapOf(txid to 4_590uL), + backupFollowup = ActiveOnchainAttemptBackup.Followup( + feeSats = "0", + tags = emptyList(), + createdAtMillis = "0", + ), + ) + val backup = ActiveOnchainAttemptBackup.from(beforeWipe, "regtest", "11".repeat(32)) + val restored = backup.restored("regtest", backup.wallet.binding, WalletScope.default, 0) + // Release: the same input, two P2WPKH outputs, ~141 vB at prepareRelease's first rate (30 + 2) = 4,512 sats. + val release = OnchainPreparedReceipt( + txid = "77".repeat(32), + inputs = requireNotNull(restored.originalInputs), + address = "bcrt1qrelease", + amountSats = 1_000uL, + miningFeeSats = 4_512uL, + ) + assertFalse(releaseOutbids(release, beforeWipe), "Before the wipe a 4,512-sat release is refused") + assertFalse( + releaseOutbids(release, restored), + "After a restore a 4,512-sat release must still not outbid the 4,590-sat candidate", + ) + } + + @Test + fun `a cancel cut short on the spent-elsewhere path can still finish`() = test { + val (record, period) = recurringShopRecord(Clock.System.now() - 1.minutes) + var stored = recurringAttempt(period) + storedProofs = listOf(startedOnchainProof(stored).copy(billingPeriod = period, conversionQuoteId = "quote-1")) + whenever(paykitSdkService.paymentRequests()).thenReturn(listOf(record)) + whenever(lightningRepo.approveOnchainRelease(any())).doSuspendableAnswer { + it.getArgument(0).copy(releaseApproved = true).also { approved -> stored = approved } + } + var crash = true + whenever(lightningRepo.abandonUnbroadcastOnchainSend(any(), any())).doSuspendableAnswer { + val removed = it.getArgument Boolean>(1).invoke(it.getArgument(0)) + if (crash) { + crash = false + error("Crashed after the proof was removed, before the attempt was deleted") + } + removed + } + val wallet = FakeReleaseWallet(spentElsewhere = true) + val repo = paymentProofRepo() + assertTrue(repo.onchainRequestHasEnded(stored)) + + assertTrue(repo.abandonUnbroadcastOnchainPayment(stored, wallet) {}.isFailure) + assertTrue(storedProofs.isEmpty()) + + val retry = repo.abandonUnbroadcastOnchainPayment(stored, wallet) {} + assertTrue(retry.isSuccess, "The next cancel must finish, but failed with '${retry.exceptionOrNull()}'") + } + @Test fun `release must spend an original coin and outbid every candidate`() { val attempt = unknownShopAttempt("ab".repeat(32)).copy(candidateMiningFees = mapOf("ab".repeat(32) to 300uL))