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/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/LightningRepo.kt b/app/src/main/java/to/bitkit/repositories/LightningRepo.kt index ff36cbda28..05695bec44 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() @@ -1861,6 +1861,49 @@ 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, + ): Boolean { + // 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) { + while (true) { + val latestSync = lightningService.status?.latestOnchainWalletSyncTimestamp + if (isOnchainSyncAfter(latestSync, previousSync, requestedSyncSucceeded.get())) break + delay(500.milliseconds) + } + } ?: throw OnchainSendAbandonSyncError() + return lightningService.walletKnowsNoCandidate(attempt) + } + + 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) + + suspend fun recordOnchainRelease(expected: OnchainSendAttempt, txid: String): OnchainSendAttempt? = + onchainSendAttemptStore.recordRelease(expected, txid) + + suspend fun approveOnchainRelease(expected: OnchainSendAttempt): OnchainSendAttempt? = + onchainSendAttemptStore.approveRelease(expected) + + 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 +2624,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. */ @@ -2768,3 +2812,10 @@ sealed interface ProbeOutcome { val routeFeeMsat: ULong?, ) : ProbeOutcome } + +/** + * 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/repositories/OnchainSendAttemptStore.kt b/app/src/main/java/to/bitkit/repositories/OnchainSendAttemptStore.kt index 907ffe1c08..8765120660 100644 --- a/app/src/main/java/to/bitkit/repositories/OnchainSendAttemptStore.kt +++ b/app/src/main/java/to/bitkit/repositories/OnchainSendAttemptStore.kt @@ -96,6 +96,10 @@ 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, + // 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 { @@ -112,11 +116,48 @@ 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 && + 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 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 + + /** 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 +} + 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 +291,53 @@ 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) } + } + } + } + + /** 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 + * 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, @@ -513,6 +601,9 @@ class OnchainSendAttemptStore @Inject constructor( preparationPending = attempt.preparationPending, verifiedWinningFeeSats = attempt.verifiedWinningFeeSats, initialDispatchAttempted = attempt.initialDispatchAttempted, + // 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 6878087a7c..fffb95d660 100644 --- a/app/src/main/java/to/bitkit/repositories/PaykitPaymentProofRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/PaykitPaymentProofRepo.kt @@ -2,6 +2,9 @@ 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.PaymentRequestRecord import com.synonym.paykit.PubkyIdentityCapability import dagger.Lazy import kotlinx.coroutines.CoroutineDispatcher @@ -409,6 +412,163 @@ 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 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 && attempt.canBeAbandoned && attempt.releaseApproved -> true + 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) + } + } + + private suspend fun oneTimeRequestHasEnded(record: PaymentRequestRecord, identity: String): Boolean = + when (record.state) { + PaymentRequestLifecycleState.PROPOSAL_EXPIRED, + PaymentRequestLifecycleState.REJECTED, + PaymentRequestLifecycleState.CANCELED -> true + PaymentRequestLifecycleState.PROPOSED, + PaymentRequestLifecycleState.ACCEPTED -> { + val now = Clock.System.now() + val request = runCatching { record.toPaykitPaymentRequestHistory(now) }.getOrNull() + 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() } + 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 { + 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, + wallet: OnchainReleasing, + approveRelease: suspend (OnchainPreparedReceipt) -> Unit, + ): Result = withContext(ioDispatcher) { + runSuspendCatching { + if (!onchainRequestHasEnded(attempt)) throw OnchainSendAbandonRefusedError(requestOpen = true) + val pendingRelease = attempt.releaseTxid + if (pendingRelease != null && wallet.walletKnowsTransaction(pendingRelease)) { + return@runSuspendCatching finishRelease(attempt) + } + if (!wallet.onchainCandidatesNeverReachedNetwork(attempt)) { + throw OnchainSendAbandonRefusedError(requestOpen = false) + } + // 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) + 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 80cf654b01..fd21a7d300 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,10 @@ 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 @@ -1028,6 +1033,63 @@ 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 } + } + } + + /** 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 } + } + + /** + * 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() + var feeRate = maxOf(attempt.feeRateSatsPerVByte, attempt.candidateFeeRates.values.maxOrNull() ?: 0uL) + 2uL + val address = newAddress() + repeat(RELEASE_FEE_ATTEMPTS) { + 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 + } + 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 96d6d8326b..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 @@ -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,19 @@ fun SendPendingScreen( } } + var showAbandonConfirmation by remember { mutableStateOf(false) } + AbandonExpiredPayment( + abandonError = uiState.abandonErrorMessage(), + isAbandoned = uiState.abandoned, + showConfirmation = showAbandonConfirmation, + onConfirm = { requiresPin, useBiometrics -> + showAbandonConfirmation = false + viewModel.abandonExpired(requiresPin, useBiometrics) + }, + onDismiss = { showAbandonConfirmation = false }, + onClose = onClose, + ) + uiState.recoveredTxid?.let { winner -> LaunchedEffect(winner) { onRecovered(winner, uiState.amount) } } @@ -131,12 +147,63 @@ 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( + abandonError: String?, + isAbandoned: Boolean, + showConfirmation: Boolean, + 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) + // 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) + 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(isPinEnabled && pinForPayments, isBiometricEnabled && isBiometrySupported) }, + 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 + AbandonError.ReleasePending -> R.string.wallet__send_pending__abandon_pending + } + ) +} + @Composable private fun SendPendingUiState.recoveryErrorMessage(): String? = recoveryError?.let { stringResource( @@ -197,6 +264,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), @@ -295,6 +371,9 @@ internal fun SendPendingContent( isRecovering: Boolean = false, recoveryError: String? = null, onRetry: () -> Unit = {}, + canAbandon: Boolean = false, + isAbandoning: Boolean = false, + onAbandon: () -> Unit = {}, ) { Column( modifier = modifier @@ -355,12 +434,27 @@ 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(), ) VerticalSpacer(16.dp) } + if (canAbandon) { + SecondaryButton( + text = stringResource(R.string.wallet__send_pending__abandon), + enabled = !isAbandoning && !isRecovering, + onClick = onAbandon, + modifier = Modifier + .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)) { 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..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 @@ -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 @@ -22,23 +23,29 @@ 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 +import to.bitkit.repositories.PaykitPaymentProofRepo import to.bitkit.repositories.PendingPaymentRepo 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( private val pendingPaymentRepo: PendingPaymentRepo, private val activityRepo: ActivityRepo, private val lightningRepo: LightningRepo, + private val paykitPaymentProofRepo: PaykitPaymentProofRepo, ) : ViewModel() { companion object { private const val TAG = "SendPendingViewModel" + private val ABANDON_CHECK_INTERVAL = 30.seconds } private val _uiState = MutableStateFlow(SendPendingUiState()) @@ -78,6 +85,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 +175,72 @@ class SendPendingViewModel @Inject constructor( } } + // The request can end, or a failed check recover, while Payment Pending stays open. + private fun offerAbandonIfRequestEnded(attempt: OnchainSendAttempt) { + if (!attempt.canBeAbandoned) return + viewModelScope.launch { + 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) + } + } + } + + 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 { + try { + releaseExpired(attempt, requiresPin, useBiometrics) + } finally { + _uiState.update { it.copy(isAbandoning = false) } + } + } + } + + 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)) + 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 + }, + ) + } + } + } + suspend fun approveRecoveryFee( receipt: OnchainPreparedReceipt, warnings: List, @@ -264,11 +338,18 @@ 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, 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 0076334f3d..66e3c571cd 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1633,6 +1633,14 @@ MAX 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. + 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. Funding transaction is unresolved. Check its transaction before trying again. diff --git a/app/src/test/java/to/bitkit/repositories/LightningRepoTest.kt b/app/src/test/java/to/bitkit/repositories/LightningRepoTest.kt index a8a2e524a5..f8459b1074 100644 --- a/app/src/test/java/to/bitkit/repositories/LightningRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/LightningRepoTest.kt @@ -3788,4 +3788,13 @@ 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)) + // 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)) + } } diff --git a/app/src/test/java/to/bitkit/repositories/OnchainSendAttemptStoreTest.kt b/app/src/test/java/to/bitkit/repositories/OnchainSendAttemptStoreTest.kt index 6ae148f70c..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) } @@ -737,6 +738,72 @@ 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( + 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 -> + beforeDispatch() + OnchainSendOutcome.Unknown(receipt.txid) + } + store.recordOutcome(attempt.attemptId, outcome, 0) + val unknown = requireNotNull(store.current()) + assertTrue(unknown.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()) + 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()) + assertTrue(restored.releaseApproved && restored.canBeAbandoned) + assertFalse(store.abandonUnbroadcast(unknown) { 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) + } + + @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..87918fd97a 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 @@ -52,10 +53,12 @@ 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 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 @@ -81,6 +84,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 @@ -2514,6 +2519,288 @@ 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) + val wallet = FakeReleaseWallet() + + val result = paymentProofRepo().abandonUnbroadcastOnchainPayment(attempt, wallet) {} + + assertTrue(result.isSuccess) + assertEquals(attempt, wallet.checked) + assertEquals(1, wallet.broadcasts) + 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, FakeReleaseWallet(neverReached = neverReached)) {} + .exceptionOrNull() + + assertEquals(requestOpen, (error as OnchainSendAbandonRefusedError).requestOpen) + assertEquals(listOf(proof), storedProofs) + } + whenever(paykitSdkService.paymentRequests()).thenReturn(emptyList()) + 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 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 { + 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"), + ) + 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, 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 `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 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)) + 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(), + var spentElsewhere: Boolean = false, + 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 originalCoinSpentElsewhere(attempt: OnchainSendAttempt): Boolean = spentElsewhere + + 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() { paymentProofRepo(projectionStore).paymentRequestStateChanges(projectionIdentity) .onEach { requestStateChanges++ } @@ -2590,6 +2877,40 @@ class PaykitPaymentProofRepoTest : BaseUnitTest(StandardTestDispatcher()) { privatePaykitRepo = dagger.Lazy { privatePaykitRepo }, ) + private fun unknownShopAttempt(txid: String) = acceptedAttempt( + request = paymentRequest(MethodId.entries.first { it.isOnchain }.rawValue), + txid = 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.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) + } + } + private fun acceptedAttempt(request: PaykitPaymentRequest, txid: String) = OnchainSendAttempt( walletId = WalletScope.default, attemptId = "attempt-1", @@ -2637,11 +2958,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..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 @@ -9,21 +9,28 @@ 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 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 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 @@ -31,6 +38,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() { @@ -38,6 +46,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 +277,81 @@ 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(), any())) + .thenReturn(Result.failure(OnchainSendAbandonSyncError()), Result.success(Unit)) + sut.initOnchain(original.txid, amount) + advanceUntilIdle() + assertEquals(original, sut.uiState.value.abandonableAttempt) + + sut.abandonExpired(requiresPin = false, useBiometrics = false) + advanceUntilIdle() + assertEquals(AbandonError.SyncUnavailable, sut.uiState.value.abandonError) + assertEquals(false, sut.uiState.value.abandoned) + + sut.abandonExpired(requiresPin = false, useBiometrics = false) + advanceUntilIdle() + assertEquals(true, sut.uiState.value.abandoned) + assertNull(sut.uiState.value.abandonableAttempt) + 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 `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)) + // 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)) + 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() + 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 +514,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..272894b859 --- /dev/null +++ b/changelog.d/next/1551.fixed.md @@ -0,0 +1 @@ +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.