From 1ce38ca438ae60f6a0638e23718070d0ceb6a6db Mon Sep 17 00:00:00 2001 From: benk10 Date: Mon, 19 Oct 2026 12:01:32 -0600 Subject: [PATCH 01/13] fix: deliver completed Paykit proofs promptly --- .../repositories/PaykitPaymentProofRepo.kt | 34 +++++++--- .../to/bitkit/services/PaykitSdkService.kt | 50 +++++++++++--- .../PaykitPaymentProofRepoTest.kt | 68 ++++++++++++++++++- changelog.d/next/1590.fixed.md | 1 + .../payment-requests/success-dismissal.xml | 2 + 5 files changed, 134 insertions(+), 21 deletions(-) create mode 100644 changelog.d/next/1590.fixed.md diff --git a/app/src/main/java/to/bitkit/repositories/PaykitPaymentProofRepo.kt b/app/src/main/java/to/bitkit/repositories/PaykitPaymentProofRepo.kt index ce10f2eda3..2de37ba643 100644 --- a/app/src/main/java/to/bitkit/repositories/PaykitPaymentProofRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/PaykitPaymentProofRepo.kt @@ -17,6 +17,7 @@ import kotlinx.coroutines.flow.drop import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.withContext @@ -32,6 +33,7 @@ import org.lightningdevkit.ldknode.PaymentDetails import org.lightningdevkit.ldknode.PaymentDirection import org.lightningdevkit.ldknode.PaymentKind import org.lightningdevkit.ldknode.PaymentStatus +import to.bitkit.async.appScope import to.bitkit.di.IoDispatcher import to.bitkit.ext.fromHex import to.bitkit.ext.runSuspendCatching @@ -41,6 +43,7 @@ import to.bitkit.models.PaykitPaymentStateBackup import to.bitkit.models.PaykitRequestPayment import to.bitkit.models.PubkyPublicKeyFormat import to.bitkit.models.WalletScope +import to.bitkit.services.PaykitSdkOperationLock import to.bitkit.services.PaykitSdkService import to.bitkit.utils.Logger import to.bitkit.utils.ServiceError @@ -127,6 +130,7 @@ class PaykitPaymentProofRepo @Inject constructor( } private val operationMutex = Mutex() + private val deliveryScope = appScope(ioDispatcher, TAG) // Process-local activity handoffs only; restored proofs must reconstruct their activity. private val observedHardwareProofs = mutableSetOf() @@ -1411,14 +1415,15 @@ class PaykitPaymentProofRepo @Inject constructor( billingPeriod = proof.billingPeriod, ) Logger.info("Queued a Paykit payment proof for private delivery", context = TAG) - runSuspendCatching { paykitSdkService.processPendingPrivateMessages() } - .onFailure { - Logger.warn( - "Paykit payment proof remains queued for private delivery", - it, - context = TAG, - ) - } + } + runSuspendCatching { + paykitSdkService.processOutboundPrivateMessages( + proof.requestId.counterparty, + PaykitSdkOperationLock.Priority.Interactive, + expectedIdentity = proof.identity, + ) + }.onFailure { + Logger.warn("Retained Paykit payment proof for private delivery", it, context = TAG) } if (proof.kind != PaykitPaymentProofKind.Usdt) { runSuspendCatching { @@ -1485,7 +1490,18 @@ class PaykitPaymentProofRepo @Inject constructor( context = TAG, ) }.isSuccess - if (didPersist && !awaitDelivery) return true + if (didPersist && !awaitDelivery) { + deliveryScope.launch { + operationMutex.withLock { + runSuspendCatching { + for (proof in completedProofs) { + if (proof in loadProofs()) submitReady(proof) + } + }.onFailure { Logger.warn("Failed to queue a Paykit payment proof", it, context = TAG) } + } + } + return true + } var hasUndeliveredProof = false completedProofs.forEach { proof -> val wasDelivered = runSuspendCatching { submitReady(proof) } diff --git a/app/src/main/java/to/bitkit/services/PaykitSdkService.kt b/app/src/main/java/to/bitkit/services/PaykitSdkService.kt index 992b8aef32..64091546dd 100644 --- a/app/src/main/java/to/bitkit/services/PaykitSdkService.kt +++ b/app/src/main/java/to/bitkit/services/PaykitSdkService.kt @@ -113,6 +113,7 @@ import to.bitkit.repositories.PublicPaykitRepo import to.bitkit.services.PaykitSdkOperationLock.Priority import to.bitkit.utils.AppError import to.bitkit.utils.Logger +import java.util.UUID import javax.inject.Inject import javax.inject.Singleton import kotlin.coroutines.cancellation.CancellationException @@ -120,6 +121,7 @@ import kotlin.time.Duration import kotlin.time.Duration.Companion.minutes import kotlin.time.Duration.Companion.seconds import kotlin.time.Instant +import kotlin.time.TimeSource data class PaykitPreparedPrivateContactPayment( val resolution: PaykitPrivateContactPaymentResolution, @@ -963,10 +965,10 @@ class PaykitSdkService @Inject constructor( var report: OutboundPrivateSendReport? do { if (deferDuringPayment) isPaymentSubmissionActive.first { !it } - report = operationLock.withLock(priority) { + report = measuredOperation("processOutboundPrivateMessages", priority) { if (deferDuringPayment) { check(runtimeGeneration == generation) { "Paykit runtime changed before peer delivery" } - if (isPaymentSubmissionActive.value) return@withLock null + if (isPaymentSubmissionActive.value) return@measuredOperation null } withStateRevisionTracking { handle -> if (expectedIdentity != null) { @@ -1032,7 +1034,7 @@ class PaykitSdkService @Inject constructor( priority: Priority, ): List { isSetup.await() - return operationLock.withLock(priority) { + return measuredOperation("allPaymentRequests", priority) { withPaykitKey { handle -> if (expectedIdentity != null) { val identity = completeSdkCall { handle.identityStatus() }?.publicKey @@ -1059,7 +1061,7 @@ class PaykitSdkService @Inject constructor( internal suspend fun identityStatus(priority: Priority): IdentityStatus? { isSetup.await() - return operationLock.withLock(priority) { + return measuredOperation("identityStatus", priority) { withPaykitKey { completeSdkCall { it.identityStatus() } } } } @@ -1121,7 +1123,7 @@ class PaykitSdkService @Inject constructor( expectedProposalStreamItemId: ULong, ): PaymentRequestRecord { isSetup.await() - return operationLock.withLock(Priority.Interactive) { + return measuredOperation("acceptPaymentRequest", Priority.Interactive) { withStateRevisionTracking { handle -> completeSdkCall { handle.claimAndAcceptPaymentRequest(counterparty, paymentRequestId, expectedProposalStreamItemId) @@ -1132,7 +1134,7 @@ class PaykitSdkService @Inject constructor( suspend fun claimPaymentRequestForExecution(counterparty: String, paymentRequestId: String): PaymentRequestRecord { isSetup.await() - return operationLock.withLock(Priority.Interactive) { + return measuredOperation("claimPaymentRequestForExecution", Priority.Interactive) { withStateRevisionTracking { completeSdkCall { it.claimPaymentRequestForExecution(counterparty, paymentRequestId) } } @@ -1149,7 +1151,7 @@ class PaykitSdkService @Inject constructor( conversionQuoteId: String? = null, ): PaymentRequestRecord { isSetup.await() - return operationLock.withLock { + return measuredOperation("submitPaymentProof") { withStateRevisionTracking { handle -> completeSdkCall { handle.submitPaymentProof( @@ -1475,8 +1477,31 @@ class PaykitSdkService @Inject constructor( _backupStateVersion.update { it + 1 } } + private suspend fun measuredOperation( + name: String, + priority: Priority = Priority.Ordered, + operation: suspend () -> T, + ): T { + val trace = UUID.randomUUID() + val started = TimeSource.Monotonic.markNow() + return operationLock.withLock(priority) { + val queue = started.elapsedNow() + val admitted = TimeSource.Monotonic.markNow() + try { + operation() + } finally { + Logger.debug( + "Finished Paykit operation '$name' trace='$trace' queue='$queue' work='${admitted.elapsedNow()}'", + context = TAG, + ) + } + } + } + private suspend fun withPaykitKey(block: suspend (PaykitSdk) -> T): T { + val started = TimeSource.Monotonic.markNow() refreshPaykitKey() + Logger.debug("Prepared Paykit key duration='${started.elapsedNow()}'", context = TAG) return runSuspendCatching { block(handle()) } .onFailure(::invalidatePaykitKeyIfNeeded) .getOrThrow() @@ -1491,9 +1516,14 @@ class PaykitSdkService @Inject constructor( private suspend fun withStateRevisionTracking(block: suspend (PaykitSdk) -> T): T = withPaykitKey { handle -> withPaykitBackupStateTracking( readRevision = { - runSuspendCatching { completeSdkCall { handle.backupStateRevision() } } - .onFailure(::invalidatePaykitKeyIfNeeded) - .getOrThrow() + val started = TimeSource.Monotonic.markNow() + try { + runSuspendCatching { completeSdkCall { handle.backupStateRevision() } } + .onFailure(::invalidatePaykitKeyIfNeeded) + .getOrThrow() + } finally { + Logger.debug("Read Paykit backup revision duration='${started.elapsedNow()}'", context = TAG) + } }, readStateRevision = { handle.stateRevision() }, readObservedSnapshot = { diff --git a/app/src/test/java/to/bitkit/repositories/PaykitPaymentProofRepoTest.kt b/app/src/test/java/to/bitkit/repositories/PaykitPaymentProofRepoTest.kt index 039ec40272..c3b278ded1 100644 --- a/app/src/test/java/to/bitkit/repositories/PaykitPaymentProofRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/PaykitPaymentProofRepoTest.kt @@ -5,6 +5,7 @@ import com.synonym.bitkitcore.BroadcastException import com.synonym.paykit.BillingPeriod import com.synonym.paykit.ConversionRate import com.synonym.paykit.IdentityStatus +import com.synonym.paykit.OutboundPrivateSendReport import com.synonym.paykit.PaykitException import com.synonym.paykit.PaymentConversion import com.synonym.paykit.PaymentConversionQuoteRecord @@ -69,6 +70,7 @@ import to.bitkit.models.PaykitPaymentStateBackup import to.bitkit.models.PaykitRequestPricing import to.bitkit.models.WalletScope import to.bitkit.services.CoreService +import to.bitkit.services.PaykitSdkOperationLock import to.bitkit.services.PaykitSdkService import to.bitkit.test.BaseUnitTest import to.bitkit.ui.screens.wallets.send.HwSendRequest @@ -202,8 +204,18 @@ class PaykitPaymentProofRepoTest : BaseUnitTest(StandardTestDispatcher()) { assertEquals(0L, testScheduler.currentTime - started) verify(lightningRepo).completeAcceptedShopFollowup(request.id, txid) - paymentProofRepo(PaykitPaymentProofStore(diskKeychain)).reconcile() + runCurrent() + verify(paykitSdkService).submitPaymentProof(any(), any(), any(), any(), any(), isNull(), isNull()) + assertTrue(file.exists()) + advanceUntilIdle() + assertEquals(5.seconds.inWholeMilliseconds, testScheduler.currentTime - started) assertFalse(file.exists()) + verify(paykitSdkService).processOutboundPrivateMessages( + COUNTERPARTY, + PaykitSdkOperationLock.Priority.Interactive, + LOCAL_IDENTITY, + ) + verify(paykitSdkService, never()).processPendingPrivateMessages() } @Test @@ -520,7 +532,11 @@ class PaykitPaymentProofRepoTest : BaseUnitTest(StandardTestDispatcher()) { proofCaptor.lastValue, ) assertTrue(storedProofs.isEmpty()) - verify(paykitSdkService).processPendingPrivateMessages() + verify(paykitSdkService).processOutboundPrivateMessages( + COUNTERPARTY, + PaykitSdkOperationLock.Priority.Interactive, + LOCAL_IDENTITY, + ) } @Test @@ -676,6 +692,54 @@ class PaykitPaymentProofRepoTest : BaseUnitTest(StandardTestDispatcher()) { verify(paykitSdkService, never()).submitPaymentProof(any(), any(), any(), any(), any(), isNull(), isNull()) } + @Test + fun `already queued proof retries only its recipient after delivery failure`() = test { + val existingProofJson = mock { + on { exportText() } doReturn """{"type":"${PaykitPaymentProofKind.Lightning.type}","data":"$PREIMAGE"}""" + } + val existingProof = mock { + on { paymentEndpointIdentifier } doReturn MethodId.Bolt11.rawValue + on { paymentAppId } doReturn "bitkit" + on { proof } doReturn existingProofJson + } + whenever(paykitSdkService.paymentRequests()).thenReturn(listOf(paymentRequestRecord(listOf(existingProof)))) + var deliveryAttempts = 0 + whenever( + paykitSdkService.processOutboundPrivateMessages( + COUNTERPARTY, + PaykitSdkOperationLock.Priority.Interactive, + LOCAL_IDENTITY, + ) + ) + .doSuspendableAnswer { + if (++deliveryAttempts == 1) throw AppError("delivery unavailable") + OutboundPrivateSendReport(emptyList(), emptyList(), emptyList(), emptyList(), emptyList()) + } + val proof = PendingPaykitPaymentProof( + identity = LOCAL_IDENTITY, + requestId = paymentRequest(MethodId.Bolt11.rawValue).id, + paymentEndpointIdentifier = MethodId.Bolt11.rawValue, + paymentAppId = "bitkit", + kind = PaykitPaymentProofKind.Lightning, + paymentStarted = true, + paymentIdentifier = PAYMENT_HASH, + proofData = PREIMAGE, + ) + storedProofs = listOf(proof) + val repo = paymentProofRepo() + + assertTrue(repo.submit(proof).getOrThrow()) + assertTrue(repo.submit(proof).getOrThrow()) + + verify(paykitSdkService, times(2)).processOutboundPrivateMessages( + COUNTERPARTY, + PaykitSdkOperationLock.Priority.Interactive, + LOCAL_IDENTITY, + ) + verify(paykitSdkService, never()).processPendingPrivateMessages() + verify(paykitSdkService, never()).submitPaymentProof(any(), any(), any(), any(), any(), isNull(), isNull()) + } + @Test fun `queued onchain proof blocks same Shop request switched to Lightning`() = test { val existingProofJson = mock { diff --git a/changelog.d/next/1590.fixed.md b/changelog.d/next/1590.fixed.md new file mode 100644 index 0000000000..b2e69d8ae2 --- /dev/null +++ b/changelog.d/next/1590.fixed.md @@ -0,0 +1 @@ +Reduce delays when delivering payment confirmations to Paykit recipients. diff --git a/journeys/payment-requests/success-dismissal.xml b/journeys/payment-requests/success-dismissal.xml index af5f0f1a4e..2f0d80fa7d 100644 --- a/journeys/payment-requests/success-dismissal.xml +++ b/journeys/payment-requests/success-dismissal.xml @@ -10,6 +10,8 @@ Tap Close and observe the screen for five seconds Verify the send sheet is closed and no send error toast appears Verify the payer's Activity contains exactly one outgoing payment for this request + On the requester, verify the original request records the completed Lightning payment without another payment from the payer + On the payer, reopen the app and verify the completed request does not send another payment On the requester, send the payer a new Lightning Payment Request for 5,000 sats On the payer, verify the Payment Request confirmation screen (id "PaymentRequestConfirm") shows the new amount and Spending is selected Close the confirmation without paying and observe the screen for five seconds From f0427c6a1191de12c7466a6c1aaf955df0363edb Mon Sep 17 00:00:00 2001 From: benk10 Date: Mon, 19 Oct 2026 12:04:16 -0600 Subject: [PATCH 02/13] chore: rename changelog fragment --- changelog.d/next/{1590.fixed.md => 1591.fixed.md} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename changelog.d/next/{1590.fixed.md => 1591.fixed.md} (100%) diff --git a/changelog.d/next/1590.fixed.md b/changelog.d/next/1591.fixed.md similarity index 100% rename from changelog.d/next/1590.fixed.md rename to changelog.d/next/1591.fixed.md From 697aa6405752b0a0a562ad062df550231686ebe7 Mon Sep 17 00:00:00 2001 From: benk10 Date: Mon, 19 Oct 2026 13:58:39 -0600 Subject: [PATCH 03/13] fix: release proof state lock before private delivery --- .../repositories/PaykitPaymentProofRepo.kt | 19 ++++---- .../to/bitkit/services/PaykitSdkService.kt | 18 +++++--- .../PaykitPaymentProofRepoTest.kt | 45 +++++++++++++++++-- 3 files changed, 64 insertions(+), 18 deletions(-) diff --git a/app/src/main/java/to/bitkit/repositories/PaykitPaymentProofRepo.kt b/app/src/main/java/to/bitkit/repositories/PaykitPaymentProofRepo.kt index 2de37ba643..4d82de4599 100644 --- a/app/src/main/java/to/bitkit/repositories/PaykitPaymentProofRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/PaykitPaymentProofRepo.kt @@ -1416,14 +1416,17 @@ class PaykitPaymentProofRepo @Inject constructor( ) Logger.info("Queued a Paykit payment proof for private delivery", context = TAG) } - runSuspendCatching { - paykitSdkService.processOutboundPrivateMessages( - proof.requestId.counterparty, - PaykitSdkOperationLock.Priority.Interactive, - expectedIdentity = proof.identity, - ) - }.onFailure { - Logger.warn("Retained Paykit payment proof for private delivery", it, context = TAG) + // Submission has durably queued the proof; transport does not need the local proof-state lock. + deliveryScope.launch { + runSuspendCatching { + paykitSdkService.processOutboundPrivateMessages( + proof.requestId.counterparty, + PaykitSdkOperationLock.Priority.Background, + expectedIdentity = proof.identity, + ) + }.onFailure { + Logger.warn("Retained Paykit payment proof for private delivery", it, context = TAG) + } } if (proof.kind != PaykitPaymentProofKind.Usdt) { runSuspendCatching { diff --git a/app/src/main/java/to/bitkit/services/PaykitSdkService.kt b/app/src/main/java/to/bitkit/services/PaykitSdkService.kt index 64091546dd..131df7b803 100644 --- a/app/src/main/java/to/bitkit/services/PaykitSdkService.kt +++ b/app/src/main/java/to/bitkit/services/PaykitSdkService.kt @@ -89,6 +89,7 @@ import kotlinx.coroutines.sync.withPermit import kotlinx.coroutines.withContext import kotlinx.coroutines.withTimeoutOrNull import org.lightningdevkit.ldknode.Network +import to.bitkit.BuildConfig import to.bitkit.async.BaseCoroutineScope import to.bitkit.data.PubkyStore import to.bitkit.data.SettingsStore @@ -1490,10 +1491,13 @@ class PaykitSdkService @Inject constructor( try { operation() } finally { - Logger.debug( - "Finished Paykit operation '$name' trace='$trace' queue='$queue' work='${admitted.elapsedNow()}'", - context = TAG, - ) + if (BuildConfig.DEBUG) { + Logger.debug( + "Finished Paykit operation '$name' trace='$trace' " + + "queue='$queue' work='${admitted.elapsedNow()}'", + context = TAG, + ) + } } } } @@ -1501,7 +1505,7 @@ class PaykitSdkService @Inject constructor( private suspend fun withPaykitKey(block: suspend (PaykitSdk) -> T): T { val started = TimeSource.Monotonic.markNow() refreshPaykitKey() - Logger.debug("Prepared Paykit key duration='${started.elapsedNow()}'", context = TAG) + if (BuildConfig.DEBUG) Logger.debug("Prepared Paykit key duration='${started.elapsedNow()}'", context = TAG) return runSuspendCatching { block(handle()) } .onFailure(::invalidatePaykitKeyIfNeeded) .getOrThrow() @@ -1522,7 +1526,9 @@ class PaykitSdkService @Inject constructor( .onFailure(::invalidatePaykitKeyIfNeeded) .getOrThrow() } finally { - Logger.debug("Read Paykit backup revision duration='${started.elapsedNow()}'", context = TAG) + if (BuildConfig.DEBUG) { + Logger.debug("Read Paykit backup revision duration='${started.elapsedNow()}'", context = TAG) + } } }, readStateRevision = { handle.stateRevision() }, diff --git a/app/src/test/java/to/bitkit/repositories/PaykitPaymentProofRepoTest.kt b/app/src/test/java/to/bitkit/repositories/PaykitPaymentProofRepoTest.kt index c3b278ded1..cf61ac4ffa 100644 --- a/app/src/test/java/to/bitkit/repositories/PaykitPaymentProofRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/PaykitPaymentProofRepoTest.kt @@ -212,12 +212,46 @@ class PaykitPaymentProofRepoTest : BaseUnitTest(StandardTestDispatcher()) { assertFalse(file.exists()) verify(paykitSdkService).processOutboundPrivateMessages( COUNTERPARTY, - PaykitSdkOperationLock.Priority.Interactive, + PaykitSdkOperationLock.Priority.Background, LOCAL_IDENTITY, ) verify(paykitSdkService, never()).processPendingPrivateMessages() } + @Test + fun `proof delivery does not hold the local proof state lock`() = test { + val request = paymentRequest(MethodId.Bolt11.rawValue) + val record = paymentRequestRecord() + whenever(paykitSdkService.paymentRequests()).thenReturn(listOf(record)) + whenever(paykitSdkService.submitPaymentProof(any(), any(), any(), any(), any(), isNull(), isNull())) + .thenReturn(record) + val deliveryStarted = CompletableDeferred() + val releaseDelivery = CompletableDeferred() + whenever( + paykitSdkService.processOutboundPrivateMessages( + COUNTERPARTY, + PaykitSdkOperationLock.Priority.Background, + LOCAL_IDENTITY, + ) + ).doSuspendableAnswer { + deliveryStarted.complete(Unit) + releaseDelivery.await() + OutboundPrivateSendReport(emptyList(), emptyList(), emptyList(), emptyList(), emptyList()) + } + val repo = paymentProofRepo() + repo.prepare(request, MethodId.Bolt11.rawValue, "bitkit", PaykitPaymentProofKind.Lightning).getOrThrow() + repo.associateLightningPayment(request, PAYMENT_HASH, MethodId.Bolt11.rawValue, "bitkit").getOrThrow() + repo.completeLightningPayment(PAYMENT_HASH, PREIMAGE) + deliveryStarted.await() + try { + withTimeout(1_000) { repo.backupSnapshot() } + } finally { + releaseDelivery.complete(Unit) + } + advanceUntilIdle() + verify(paykitSdkService, times(1)).submitPaymentProof(any(), any(), any(), any(), any(), isNull(), isNull()) + } + @Test fun `failed persistence and delivery retain the unfinished payment guard`() = test { val txid = "ab".repeat(32) @@ -532,9 +566,10 @@ class PaykitPaymentProofRepoTest : BaseUnitTest(StandardTestDispatcher()) { proofCaptor.lastValue, ) assertTrue(storedProofs.isEmpty()) + runCurrent() verify(paykitSdkService).processOutboundPrivateMessages( COUNTERPARTY, - PaykitSdkOperationLock.Priority.Interactive, + PaykitSdkOperationLock.Priority.Background, LOCAL_IDENTITY, ) } @@ -707,7 +742,7 @@ class PaykitPaymentProofRepoTest : BaseUnitTest(StandardTestDispatcher()) { whenever( paykitSdkService.processOutboundPrivateMessages( COUNTERPARTY, - PaykitSdkOperationLock.Priority.Interactive, + PaykitSdkOperationLock.Priority.Background, LOCAL_IDENTITY, ) ) @@ -729,11 +764,13 @@ class PaykitPaymentProofRepoTest : BaseUnitTest(StandardTestDispatcher()) { val repo = paymentProofRepo() assertTrue(repo.submit(proof).getOrThrow()) + runCurrent() assertTrue(repo.submit(proof).getOrThrow()) + runCurrent() verify(paykitSdkService, times(2)).processOutboundPrivateMessages( COUNTERPARTY, - PaykitSdkOperationLock.Priority.Interactive, + PaykitSdkOperationLock.Priority.Background, LOCAL_IDENTITY, ) verify(paykitSdkService, never()).processPendingPrivateMessages() From 1d4b4c2ab45fbc14ae0c72d38138fa30c039c85c Mon Sep 17 00:00:00 2001 From: benk10 Date: Mon, 19 Oct 2026 15:54:58 -0600 Subject: [PATCH 04/13] fix: keep proof snapshots independent of remote enqueue --- .../repositories/PaykitPaymentProofRepo.kt | 6 ++- .../PaykitPaymentProofRepoTest.kt | 42 ++++++++++++++++++- 2 files changed, 44 insertions(+), 4 deletions(-) diff --git a/app/src/main/java/to/bitkit/repositories/PaykitPaymentProofRepo.kt b/app/src/main/java/to/bitkit/repositories/PaykitPaymentProofRepo.kt index 4d82de4599..8850d23aff 100644 --- a/app/src/main/java/to/bitkit/repositories/PaykitPaymentProofRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/PaykitPaymentProofRepo.kt @@ -130,6 +130,7 @@ class PaykitPaymentProofRepo @Inject constructor( } private val operationMutex = Mutex() + private val persistenceMutex = Mutex() private val deliveryScope = appScope(ioDispatcher, TAG) // Process-local activity handoffs only; restored proofs must reconstruct their activity. @@ -162,7 +163,7 @@ class PaykitPaymentProofRepo @Inject constructor( ) suspend fun backupSnapshot(): List = withContext(ioDispatcher) { - operationMutex.withLock { + persistenceMutex.withLock { val proofs = store.load() check(proofs.none { it.hardwareDispatchDenied }) { "Hardware dispatch denial cleanup is pending" } proofs.map { PaykitPaymentStateBackup.Proof(it) } @@ -1569,7 +1570,8 @@ class PaykitPaymentProofRepo @Inject constructor( } private suspend fun persist(proofs: List) { - store.save(proofs) + // Snapshots wait for durable local writes, not the operation's remote SDK calls. + persistenceMutex.withLock { store.save(proofs) } observedHardwareProofs.retainAll(proofs.toSet()) } } diff --git a/app/src/test/java/to/bitkit/repositories/PaykitPaymentProofRepoTest.kt b/app/src/test/java/to/bitkit/repositories/PaykitPaymentProofRepoTest.kt index cf61ac4ffa..36762704e7 100644 --- a/app/src/test/java/to/bitkit/repositories/PaykitPaymentProofRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/PaykitPaymentProofRepoTest.kt @@ -23,6 +23,7 @@ import com.synonym.paykit.PubkyIdentityCapability import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.async import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.launchIn @@ -219,12 +220,42 @@ class PaykitPaymentProofRepoTest : BaseUnitTest(StandardTestDispatcher()) { } @Test - fun `proof delivery does not hold the local proof state lock`() = test { + fun `proof snapshot waits for local persistence to finish`() = test { + val request = paymentRequest(MethodId.Bolt11.rawValue) + val repo = paymentProofRepo() + val saveStarted = CompletableDeferred() + val releaseSave = CompletableDeferred() + whenever(store.save(any())).doSuspendableAnswer { + val proofs = it.getArgument>(0) + saveStarted.complete(Unit) + releaseSave.await() + storedProofs = proofs + } + val preparation = async { + repo.prepare(request, MethodId.Bolt11.rawValue, "bitkit", PaykitPaymentProofKind.Lightning).getOrThrow() + } + saveStarted.await() + val snapshot = async { repo.backupSnapshot() } + runCurrent() + assertFalse(snapshot.isCompleted) + releaseSave.complete(Unit) + preparation.await() + assertEquals(request.id, snapshot.await().single().restored().requestId) + } + + @Test + fun `proof snapshots do not wait for remote enqueue or delivery`() = test { val request = paymentRequest(MethodId.Bolt11.rawValue) val record = paymentRequestRecord() whenever(paykitSdkService.paymentRequests()).thenReturn(listOf(record)) + val submissionStarted = CompletableDeferred() + val releaseSubmission = CompletableDeferred() whenever(paykitSdkService.submitPaymentProof(any(), any(), any(), any(), any(), isNull(), isNull())) - .thenReturn(record) + .doSuspendableAnswer { + submissionStarted.complete(Unit) + releaseSubmission.await() + record + } val deliveryStarted = CompletableDeferred() val releaseDelivery = CompletableDeferred() whenever( @@ -242,6 +273,13 @@ class PaykitPaymentProofRepoTest : BaseUnitTest(StandardTestDispatcher()) { repo.prepare(request, MethodId.Bolt11.rawValue, "bitkit", PaykitPaymentProofKind.Lightning).getOrThrow() repo.associateLightningPayment(request, PAYMENT_HASH, MethodId.Bolt11.rawValue, "bitkit").getOrThrow() repo.completeLightningPayment(PAYMENT_HASH, PREIMAGE) + submissionStarted.await() + try { + val snapshot = withTimeout(1_000) { repo.backupSnapshot() } + assertEquals(PREIMAGE, snapshot.single().restored().proofData) + } finally { + releaseSubmission.complete(Unit) + } deliveryStarted.await() try { withTimeout(1_000) { repo.backupSnapshot() } From 8bd50d4fa38e01bed7cc91a6b8a90f6544bed694 Mon Sep 17 00:00:00 2001 From: benk10 Date: Mon, 19 Oct 2026 18:20:30 -0600 Subject: [PATCH 05/13] fix: claim inline Paykit requests during acceptance --- .../java/to/bitkit/viewmodels/AppViewModel.kt | 16 +++-- .../viewmodels/AppViewModelSendFlowTest.kt | 62 ++++++++++++++++--- 2 files changed, 66 insertions(+), 12 deletions(-) diff --git a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt index 89c3f602d1..f74530cd58 100644 --- a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt @@ -5507,7 +5507,9 @@ class AppViewModel @Inject constructor( } val preparedPaymentProofRequest = proofPreparation.getOrNull() - contactPaymentContext?.incomingPaymentRequest?.let { request -> + contactPaymentContext?.incomingPaymentRequest?.takeIf { + !it.requiresAcceptance || requiresPrivatePaymentListConsumption(contactPaymentContext) + }?.let { request -> paykitPaymentRequestRepo.claimForPayment(request).onFailure { cancelPaymentProofPreparation(preparedPaymentProofRequest) handlePaymentPreparationFailure(it, contactPaymentContext) @@ -6031,18 +6033,21 @@ class AppViewModel @Inject constructor( private suspend fun prepareContactPayment( contactPaymentContext: ContactPaymentContext?, - beforePrivateConsumption: suspend () -> Result = { Result.success(Unit) }, + beforePrivateConsumption: (suspend () -> Result)? = null, ): Boolean { if (isPreparedContactPayment(contactPaymentContext)) return true if (!validateIncomingPaymentRequest(contactPaymentContext)) return false - contactPaymentContext?.incomingPaymentRequest?.let { request -> + contactPaymentContext?.incomingPaymentRequest?.takeIf { + beforePrivateConsumption != null || !it.requiresAcceptance || + requiresPrivatePaymentListConsumption(contactPaymentContext) + }?.let { request -> paykitPaymentRequestRepo.claimForPayment(request).onFailure { handlePaymentPreparationFailure(it, contactPaymentContext) return false } } - beforePrivateConsumption().onFailure { + beforePrivateConsumption?.invoke()?.onFailure { releasePrivatePaymentListIfNeeded(contactPaymentContext) handlePaymentPreparationFailure(it, contactPaymentContext) return false @@ -7366,6 +7371,9 @@ class AppViewModel @Inject constructor( } } + private fun requiresPrivatePaymentListConsumption(context: ContactPaymentContext?): Boolean = + !_sendUiState.value.paykitUsesUsdt && context?.privatePaymentContext?.paymentListVersion != null + private suspend fun consumePrivatePaymentListIfNeeded(context: ContactPaymentContext?): Result { context ?: return Result.success(Unit) if (_sendUiState.value.paykitUsesUsdt) return Result.success(Unit) diff --git a/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt b/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt index 94257e20d6..d5084e2e43 100644 --- a/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt +++ b/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt @@ -9166,6 +9166,42 @@ class AppViewModelSendFlowTest : BaseUnitTest() { } } + @Test + fun `inline request acceptance claims execution without a separate claim`() = test { + val address = "bcrt1qpaymentrequest" + val request = paymentRequest() + val privateContext = privatePaymentContext(null) + balanceState.value = BalanceState(maxSendOnchainSats = 100_000u) + whenever(paykitPaymentRequestRepo.accept(request)).thenReturn(Result.success(Unit)) + whenever(privatePaykitRepo.consumePrivatePaymentList(testPublicKey, privateContext)) + .thenReturn(Result.success(Unit)) + stubSuccessfulOnchainSend(address, request.amount.atomic) + setActiveContactPaymentContext(testPublicKey, privateContext, request) + setSendState( + SendUiState( + address = address, + amount = request.amount.atomic, + payMethod = SendMethod.ONCHAIN, + speed = TransactionSpeed.Medium, + isPaymentRequest = true, + ), + ) + + confirmCurrentPayment() + + verify(paykitPaymentRequestRepo, never()).claimForPayment(request) + verify(paykitPaymentRequestRepo).accept(request) + verify(paykitPaymentProofRepo).markOnchainPaymentStarted( + eq(request), + eq(address), + eq(request.amount.atomic), + any(), + anyOrNull(), + anyOrNull(), + anyOrNull(), + ) + } + @Test fun `duplicate payment request confirmation submits only once`() = test { val address = "bcrt1qpaymentrequest" @@ -9193,8 +9229,11 @@ class AppViewModelSendFlowTest : BaseUnitTest() { runCurrent() advanceUntilIdle() - verify(paykitPaymentRequestRepo).accept(request) - verify(privatePaykitRepo).consumePrivatePaymentList(testPublicKey, privateContext) + inOrder(paykitPaymentRequestRepo, privatePaykitRepo) { + verify(paykitPaymentRequestRepo).claimForPayment(request) + verify(privatePaykitRepo).consumePrivatePaymentList(testPublicKey, privateContext) + verify(paykitPaymentRequestRepo).accept(request) + } verify(lightningRepo).sendOnChain( address = any(), sats = any(), @@ -9392,7 +9431,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { } @Test - fun `execution claim failure blocks versionless request without consuming private details`() = test { + fun `atomic acceptance claim failure blocks versionless request before sending`() = test { val request = paymentRequest() val privateContext = privatePaymentContext(null) pubkyPublicKey.value = testPublicKey @@ -9408,9 +9447,8 @@ class AppViewModelSendFlowTest : BaseUnitTest() { Result.success(Unit) } balanceState.value = BalanceState(maxSendOnchainSats = 100_000u) - whenever(paykitPaymentRequestRepo.claimForPayment(request)) + whenever(paykitPaymentRequestRepo.accept(request)) .thenReturn(Result.failure(IllegalStateException("request claimed by another app"))) - whenever(paykitPaymentRequestRepo.accept(request)).thenReturn(Result.success(Unit)) whenever(privatePaykitRepo.consumePrivatePaymentList(testPublicKey, privateContext)) .thenReturn(Result.success(Unit)) stubOnchainSend( @@ -9439,11 +9477,19 @@ class AppViewModelSendFlowTest : BaseUnitTest() { verify(paykitPaymentRequestRepo, never()).refresh(PaykitPaymentRequestRefreshMode.FULL) finishRefresh.complete(Unit) advanceUntilIdle() - verify(privatePaykitRepo, never()).consumePrivatePaymentList(any(), any()) - verify(paykitPaymentRequestRepo, never()).accept(request) + verify(paykitPaymentRequestRepo, never()).claimForPayment(request) + verify(paykitPaymentRequestRepo).accept(request) verify(paykitPaymentProofRepo, never()).failOnchainPayment(any()) verify(paykitPaymentProofRepo).cancelPreparation(request) - verify(privatePaykitRepo, never()).releasePrivatePaymentList(any(), any()) + verify(paykitPaymentProofRepo, never()).markOnchainPaymentStarted( + any(), + any(), + any(), + any(), + anyOrNull(), + anyOrNull(), + anyOrNull(), + ) } @Test From a20a9a020fc2d5f9459f06b8c885574ebf1d51ea Mon Sep 17 00:00:00 2001 From: benk10 Date: Mon, 19 Oct 2026 19:08:01 -0600 Subject: [PATCH 06/13] fix: defer background Paykit refresh during payments --- .../repositories/PaykitPaymentRequestRepo.kt | 19 ++++-- .../java/to/bitkit/repositories/PubkyRepo.kt | 4 +- .../to/bitkit/services/PaykitSdkService.kt | 23 ++++++- .../java/to/bitkit/viewmodels/AppViewModel.kt | 2 +- .../PaykitPaymentRequestRepoTest.kt | 32 ++++++++- .../bitkit/services/PaykitSdkServiceTest.kt | 68 +++++++++++++++++++ .../viewmodels/AppViewModelSendFlowTest.kt | 2 +- 7 files changed, 137 insertions(+), 13 deletions(-) diff --git a/app/src/main/java/to/bitkit/repositories/PaykitPaymentRequestRepo.kt b/app/src/main/java/to/bitkit/repositories/PaykitPaymentRequestRepo.kt index 430448a937..20eb56fcb7 100644 --- a/app/src/main/java/to/bitkit/repositories/PaykitPaymentRequestRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/PaykitPaymentRequestRepo.kt @@ -565,7 +565,7 @@ class PaykitPaymentRequestRepo @Inject constructor( private suspend fun refresh( mode: PaykitPaymentRequestRefreshMode, forceFresh: Boolean, - messagePriority: Priority = Priority.Ordered, + messagePriority: Priority = Priority.Background, ): Result { val generation = stateGeneration.get() val expectedIdentity = activeIdentity @@ -589,7 +589,13 @@ class PaykitPaymentRequestRepo @Inject constructor( } ?: return@refresh val snapshot = runSuspendCatching { val priority = if (forceFresh) Priority.Ordered else Priority.Background - fetchRequestSnapshot(expectedIdentity, mode, priority, messagePriority, observationOnly = true) + fetchRequestSnapshot( + expectedIdentity, + mode, + priority, + if (forceFresh) Priority.Ordered else messagePriority, + observationOnly = true, + ) } operationMutex.withLock { if (!isCurrentState(generation, expectedIdentity)) return@withLock @@ -612,7 +618,9 @@ class PaykitPaymentRequestRepo @Inject constructor( } } }.onFailure { - Logger.warn("Failed to refresh incoming Paykit payment requests", it, context = TAG) + if (it != PaykitPaymentRequestError.OperationInProgress) { + Logger.warn("Failed to refresh incoming Paykit payment requests", it, context = TAG) + } } } } @@ -1701,7 +1709,10 @@ class PaykitPaymentRequestRepo @Inject constructor( } } .onSuccess(::logOutboundFailures) - .onFailure { Logger.warn("Failed to deliver pending Paykit private messages", it, context = TAG) } + .onFailure { + if (it == PaykitPaymentRequestError.OperationInProgress) throw it + Logger.warn("Failed to deliver pending Paykit private messages", it, context = TAG) + } .getOrDefault(emptyList()) private fun logOutboundFailures(reports: List) { diff --git a/app/src/main/java/to/bitkit/repositories/PubkyRepo.kt b/app/src/main/java/to/bitkit/repositories/PubkyRepo.kt index 64d56f16f3..72ffd14344 100644 --- a/app/src/main/java/to/bitkit/repositories/PubkyRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/PubkyRepo.kt @@ -1241,7 +1241,9 @@ class PubkyRepo @Inject constructor( shouldMarkLoadCompleted = true }.onFailure { shouldMarkLoadCompleted = isCurrent(signIn) - Logger.error("Failed to load contacts", it, context = TAG) + if (it != PaykitPaymentRequestError.OperationInProgress) { + Logger.error("Failed to load contacts", it, context = TAG) + } } } while (reload && isCurrent(signIn)) } finally { diff --git a/app/src/main/java/to/bitkit/services/PaykitSdkService.kt b/app/src/main/java/to/bitkit/services/PaykitSdkService.kt index 131df7b803..ad721928c2 100644 --- a/app/src/main/java/to/bitkit/services/PaykitSdkService.kt +++ b/app/src/main/java/to/bitkit/services/PaykitSdkService.kt @@ -109,6 +109,7 @@ import to.bitkit.models.PubkyAuthRequestError import to.bitkit.models.PubkyPublicKeyFormat import to.bitkit.repositories.Endpoint import to.bitkit.repositories.PaykitBillingPeriod +import to.bitkit.repositories.PaykitPaymentRequestError import to.bitkit.repositories.PubkyContactError import to.bitkit.repositories.PublicPaykitRepo import to.bitkit.services.PaykitSdkOperationLock.Priority @@ -932,7 +933,8 @@ class PaykitSdkService @Inject constructor( ): List { isSetup.await() return operationLock.withLock(priority) { - withStateRevisionTracking { handle -> + checkRefreshAdmission(priority) + withStateRevisionTracking(beforeOperation = { checkRefreshAdmission(priority) }) { handle -> completeSdkCall { handle.receivePrivateMessagesFromLinkedPeers() } } } @@ -994,7 +996,8 @@ class PaykitSdkService @Inject constructor( ): List { isSetup.await() return operationLock.withLock(priority) { - withStateRevisionTracking { handle -> + checkRefreshAdmission(priority) + withStateRevisionTracking(beforeOperation = { checkRefreshAdmission(priority) }) { handle -> completeSdkCall { handle.processPendingPrivateMessages() } } } @@ -1011,7 +1014,9 @@ class PaykitSdkService @Inject constructor( val generation = runtimeGeneration return operationLock.withLock(priority) { check(runtimeGeneration == generation) { "Paykit runtime changed before observation" } + checkRefreshAdmission(priority) withPaykitKey { handle -> + checkRefreshAdmission(priority) val observation = completeSdkCall { handle.observeState() } check(PubkyPublicKeyFormat.matches(observation.publicKey, expectedIdentity)) { "Paykit observation identity changed" @@ -1517,7 +1522,16 @@ class PaykitSdkService @Inject constructor( cachedBackupState = null } - private suspend fun withStateRevisionTracking(block: suspend (PaykitSdk) -> T): T = withPaykitKey { handle -> + private fun checkRefreshAdmission(priority: Priority) { + if (priority == Priority.Background && isPaymentSubmissionActive.value) { + throw PaykitPaymentRequestError.OperationInProgress + } + } + + private suspend fun withStateRevisionTracking( + beforeOperation: suspend () -> Unit = {}, + block: suspend (PaykitSdk) -> T, + ): T = withPaykitKey { handle -> withPaykitBackupStateTracking( readRevision = { val started = TimeSource.Monotonic.markNow() @@ -1540,6 +1554,7 @@ class PaykitSdkService @Inject constructor( cachedSnapshot = cachedBackupState, onSnapshot = { cachedBackupState = it }, onChange = ::notifyBackupStateChanged, + beforeOperation = beforeOperation, ) { block(handle) } @@ -1681,10 +1696,12 @@ internal suspend fun withPaykitBackupStateTracking( cachedSnapshot: PaykitBackupStateSnapshot? = null, onSnapshot: (PaykitBackupStateSnapshot?) -> Unit = {}, onChange: () -> Unit, + beforeOperation: suspend () -> Unit = {}, operation: suspend () -> T, ): T { val previousRevision = cachedSnapshot?.backupRevision ?: runSuspendCatching { readRevision() }.getOrNull() + beforeOperation() var succeeded = false return try { operation().also { succeeded = true } diff --git a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt index f74530cd58..a063e8499c 100644 --- a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt @@ -1140,7 +1140,7 @@ class AppViewModel @Inject constructor( private suspend fun refreshIncomingPaykitPaymentRequests( mode: PaykitPaymentRequestRefreshMode = PaykitPaymentRequestRefreshMode.FULL, forceFresh: Boolean = false, - messagePriority: Priority = Priority.Ordered, + messagePriority: Priority = Priority.Background, onlyWhileActive: Boolean = false, reconcileProofs: Boolean = mode == PaykitPaymentRequestRefreshMode.FULL, ): Result { diff --git a/app/src/test/java/to/bitkit/repositories/PaykitPaymentRequestRepoTest.kt b/app/src/test/java/to/bitkit/repositories/PaykitPaymentRequestRepoTest.kt index 6a89a8adb2..d2746a53c9 100644 --- a/app/src/test/java/to/bitkit/repositories/PaykitPaymentRequestRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/PaykitPaymentRequestRepoTest.kt @@ -10,6 +10,7 @@ import com.synonym.paykit.ConversionRate import com.synonym.paykit.IdentityStatus import com.synonym.paykit.LinkedPeerRecord import com.synonym.paykit.LinkedPeerState +import com.synonym.paykit.OutboundPrivateCounterpartySendReport import com.synonym.paykit.OutboundPrivateMessageStatus import com.synonym.paykit.OutboundPrivateSendFailure import com.synonym.paykit.OutboundPrivateSendReport @@ -481,8 +482,33 @@ class PaykitPaymentRequestRepoTest : BaseUnitTest(StandardTestDispatcher()) { verify(paykitSdkService, times(3)).observeState(LOCAL_IDENTITY, Priority.Background) verify(paykitSdkService, never()).allPaymentRequests(anyOrNull(), any()) verify(paykitSdkService, never()).linkedPeers(any()) - verify(paykitSdkService).processPendingPrivateMessages(Priority.Ordered) - verify(paykitSdkService, times(2)).receivePrivateMessagesFromLinkedPeers(Priority.Ordered) + verify(paykitSdkService).processPendingPrivateMessages(Priority.Background) + verify(paykitSdkService, times(2)).receivePrivateMessagesFromLinkedPeers(Priority.Background) + } + + @Test + fun `deferred refresh preserves requests releases its lock and retries without skipping recovery`() = test { + val record = paymentRequestRecord() + whenever(paykitSdkService.allPaymentRequests(anyOrNull())).thenReturn(listOf(record)) + sut.refresh(PaykitPaymentRequestRefreshMode.STORED).getOrThrow() + val pending = sut.pendingRequests.value + var paymentActive = true + doSuspendableAnswer { + if (paymentActive) throw PaykitPaymentRequestError.OperationInProgress + emptyList() + }.whenever(paykitSdkService).processPendingPrivateMessages(Priority.Background) + clearInvocations(paykitSdkService) + + assertSame(PaykitPaymentRequestError.OperationInProgress, sut.refresh().exceptionOrNull()) + assertEquals(pending, sut.pendingRequests.value) + verify(paykitSdkService, never()).receivePrivateMessagesFromLinkedPeers(any()) + sut.refreshAfterStateChange().getOrThrow() + verify(paykitSdkService).observeState(LOCAL_IDENTITY, Priority.Ordered) + + paymentActive = false + whenever(paykitSdkService.allPaymentRequests(anyOrNull())).thenReturn(emptyList()) + sut.refresh().getOrThrow() + assertTrue(sut.pendingRequests.value.isEmpty()) } @Test @@ -613,7 +639,7 @@ class PaykitPaymentRequestRepoTest : BaseUnitTest(StandardTestDispatcher()) { sut.refresh().getOrThrow() verify(paykitSdkService, times(2)).allPaymentRequests(anyOrNull()) - verify(paykitSdkService).processPendingPrivateMessages(Priority.Ordered) + verify(paykitSdkService, times(2)).processPendingPrivateMessages(Priority.Background) } @Test diff --git a/app/src/test/java/to/bitkit/services/PaykitSdkServiceTest.kt b/app/src/test/java/to/bitkit/services/PaykitSdkServiceTest.kt index 0cc2a226d9..df777ef411 100644 --- a/app/src/test/java/to/bitkit/services/PaykitSdkServiceTest.kt +++ b/app/src/test/java/to/bitkit/services/PaykitSdkServiceTest.kt @@ -88,6 +88,7 @@ import to.bitkit.models.PubkyAuthRequestError import to.bitkit.models.PubkyProfileData import to.bitkit.repositories.Endpoint import to.bitkit.repositories.MethodId +import to.bitkit.repositories.PaykitPaymentRequestError import to.bitkit.repositories.PubkyContactError import to.bitkit.services.PaykitSdkOperationLock.Priority import to.bitkit.test.forEachCase @@ -112,6 +113,73 @@ class PaykitSdkServiceTest { private val READ_TIMEOUT = 10.seconds } + @Test + fun `passive refresh yields at admission and after backup preparation without dirtying backup`() = runTest { + val operations = listOf Any?>( + { receivePrivateMessagesFromLinkedPeers(it) }, + { processPendingPrivateMessages(it) }, + { observeState(RING_PUBKY, it) }, + ) + for ((index, operation) in operations.withIndex()) { + for (queued in if (index == 2) listOf(true) else listOf(true, false)) { + val sdk = mock() + val release = CompletableDeferred() + var reached = false + var nativeCalls = 0 + whenever(sdk.contactRecords()).doSuspendableAnswer { + reached = true + release.await() + emptyList() + } + whenever(sdk.backupStateRevision()).doSuspendableAnswer { + if (!queued) { + reached = true + release.await() + } + "revision" + } + whenever(sdk.receivePrivateMessagesFromLinkedPeers()).thenAnswer { + nativeCalls++ + emptyList() + } + whenever(sdk.processPendingPrivateMessages()).thenAnswer { + nativeCalls++ + emptyList() + } + whenever(sdk.observeState()).thenAnswer { + nativeCalls++ + PaykitStateObservation( + RING_PUBKY, + 0uL, + "2026-10-19T00:00:00Z", + emptyList(), + emptyList(), + emptyList(), + emptyList(), + ) + } + val service = PaykitSdkService(mock(), mock(), mock(), settingsStore = mock()) { sdk } + val holder = if (queued) async { service.contactRecords() } else null + runCurrent() + val refresh = async { runSuspendCatching { service.operation(Priority.Background) } } + runCurrent() + assertTrue(reached) + service.setPaymentSubmissionActive(true) + release.complete(Unit) + holder?.await() + assertSame(PaykitPaymentRequestError.OperationInProgress, refresh.await().exceptionOrNull()) + assertEquals(0, nativeCalls) + assertEquals(0L, service.backupStateVersion.value) + // Recovery reads must complete without waiting for the enclosing payment to finish. + service.operation(Priority.Ordered) + assertEquals(1, nativeCalls) + service.setPaymentSubmissionActive(false) + service.operation(Priority.Background) + assertEquals(2, nativeCalls) + } + } + } + @Test fun `state observation validates identity and propagates unavailable session failures`() = runTest { val observation = PaykitStateObservation( diff --git a/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt b/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt index d5084e2e43..122a635b2f 100644 --- a/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt +++ b/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt @@ -13306,7 +13306,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { isProfileDeletionInProgress.update { false } sut.onHomeResumed() runCurrent() - verify(paykitPaymentRequestRepo).refresh(PaykitPaymentRequestRefreshMode.FULL, Priority.Ordered) + verify(paykitPaymentRequestRepo).refresh(PaykitPaymentRequestRefreshMode.FULL, Priority.Background) } @Test From 99c18fcf67f470dffa2c00bf4b57fefcbd79245a Mon Sep 17 00:00:00 2001 From: benk10 Date: Mon, 19 Oct 2026 21:49:56 -0600 Subject: [PATCH 07/13] fix: defer passive refresh during payment review --- .../repositories/PaykitPaymentRequestRepo.kt | 2 + .../to/bitkit/services/PaykitSdkService.kt | 10 +++- .../screens/wallets/usdt/PaykitUsdtReview.kt | 13 +++++ .../java/to/bitkit/ui/sheets/SendSheet.kt | 19 +++++++ .../java/to/bitkit/viewmodels/AppViewModel.kt | 2 + .../bitkit/services/PaykitSdkServiceTest.kt | 52 +++++++++++++++++++ changelog.d/next/1591.fixed.md | 2 +- journeys/payment-requests/review-refresh.xml | 24 +++++++++ 8 files changed, 122 insertions(+), 2 deletions(-) create mode 100644 journeys/payment-requests/review-refresh.xml diff --git a/app/src/main/java/to/bitkit/repositories/PaykitPaymentRequestRepo.kt b/app/src/main/java/to/bitkit/repositories/PaykitPaymentRequestRepo.kt index 20eb56fcb7..490df7c6b4 100644 --- a/app/src/main/java/to/bitkit/repositories/PaykitPaymentRequestRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/PaykitPaymentRequestRepo.kt @@ -367,6 +367,8 @@ class PaykitPaymentRequestRepo @Inject constructor( fun setPaymentSubmissionActive(active: Boolean) = paykitSdkService.setPaymentSubmissionActive(active) + fun beginPaymentReview(): () -> Unit = paykitSdkService.beginPaymentReview() + @Volatile private var activeIdentity: String? = null diff --git a/app/src/main/java/to/bitkit/services/PaykitSdkService.kt b/app/src/main/java/to/bitkit/services/PaykitSdkService.kt index ad721928c2..ed99500a5f 100644 --- a/app/src/main/java/to/bitkit/services/PaykitSdkService.kt +++ b/app/src/main/java/to/bitkit/services/PaykitSdkService.kt @@ -276,6 +276,7 @@ class PaykitSdkService @Inject constructor( private val _backupStateVersion = MutableStateFlow(0L) val backupStateVersion: StateFlow = _backupStateVersion.asStateFlow() private val _isPaymentSubmissionActive = MutableStateFlow(false) + private val paymentReviews = mutableSetOf() val isPaymentSubmissionActive: StateFlow = _isPaymentSubmissionActive.asStateFlow() private var sdkFactory: () -> PaykitSdk = { @@ -315,6 +316,12 @@ class PaykitSdkService @Inject constructor( _isPaymentSubmissionActive.update { active } } + fun beginPaymentReview(): () -> Unit { + val review = Any() + synchronized(paymentReviews) { paymentReviews.add(review) } + return { synchronized(paymentReviews) { paymentReviews.remove(review) } } + } + suspend fun initializeAndImportSession(secret: String): Result { var imported: Result? = null initialize { @@ -1523,7 +1530,8 @@ class PaykitSdkService @Inject constructor( } private fun checkRefreshAdmission(priority: Priority) { - if (priority == Priority.Background && isPaymentSubmissionActive.value) { + val reviewing = synchronized(paymentReviews) { paymentReviews.isNotEmpty() } + if (priority == Priority.Background && (isPaymentSubmissionActive.value || reviewing)) { throw PaykitPaymentRequestError.OperationInProgress } } diff --git a/app/src/main/java/to/bitkit/ui/screens/wallets/usdt/PaykitUsdtReview.kt b/app/src/main/java/to/bitkit/ui/screens/wallets/usdt/PaykitUsdtReview.kt index fc5f533ded..4b6e4904a6 100644 --- a/app/src/main/java/to/bitkit/ui/screens/wallets/usdt/PaykitUsdtReview.kt +++ b/app/src/main/java/to/bitkit/ui/screens/wallets/usdt/PaykitUsdtReview.kt @@ -22,6 +22,7 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.repeatOnLifecycle import com.synonym.bitkitcore.UsdtDestination import com.synonym.bitkitcore.UsdtTransferStatus +import kotlinx.coroutines.awaitCancellation import kotlinx.coroutines.delay import to.bitkit.R import to.bitkit.ui.scaffold.AppAlertDialog @@ -43,6 +44,18 @@ fun PaykitUsdtReview( val state by viewModel.state.collectAsStateWithLifecycle() val settings by viewModel.settings.collectAsStateWithLifecycle() val lifecycle = LocalLifecycleOwner.current.lifecycle + val reviewing = state.quote != null && !state.submitted + LaunchedEffect(reviewing, lifecycle, app) { + if (!reviewing) return@LaunchedEffect + lifecycle.repeatOnLifecycle(Lifecycle.State.STARTED) { + val finishReview = app.beginPaymentReview() + try { + awaitCancellation() + } finally { + finishReview() + } + } + } DisposableEffect(state.busy, state.authenticationRequired) { app.updatePaykitUsdtBusy(state.busy || state.authenticationRequired) onDispose { app.updatePaykitUsdtBusy(false) } diff --git a/app/src/main/java/to/bitkit/ui/sheets/SendSheet.kt b/app/src/main/java/to/bitkit/ui/sheets/SendSheet.kt index 132d87ae4b..8039cef676 100644 --- a/app/src/main/java/to/bitkit/ui/sheets/SendSheet.kt +++ b/app/src/main/java/to/bitkit/ui/sheets/SendSheet.kt @@ -30,9 +30,12 @@ import androidx.lifecycle.compose.LocalLifecycleOwner import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.repeatOnLifecycle import androidx.navigation.NavController +import androidx.navigation.NavDestination.Companion.hasRoute import androidx.navigation.compose.NavHost +import androidx.navigation.compose.currentBackStackEntryAsState import androidx.navigation.compose.rememberNavController import androidx.navigation.toRoute +import kotlinx.coroutines.awaitCancellation import kotlinx.coroutines.delay import kotlinx.coroutines.isActive import kotlinx.coroutines.launch @@ -180,6 +183,22 @@ fun SendSheet( .testTag("SendSheet"), ) { val navController = rememberNavController() + val entry by navController.currentBackStackEntryAsState() + val reviewing = entry?.destination?.let { + it.hasRoute() || it.hasRoute() || + it.hasRoute() + } == true + LaunchedEffect(reviewing, lifecycle, appViewModel) { + if (!reviewing) return@LaunchedEffect + lifecycle.repeatOnLifecycle(Lifecycle.State.STARTED) { + val finishReview = appViewModel.beginPaymentReview() + try { + awaitCancellation() + } finally { + finishReview() + } + } + } fun showHardwareSuccess(txId: String, amountSats: ULong, walletId: String, isShopPayment: Boolean) { appViewModel.onSendSuccess( details = NewTransactionSheetDetails( diff --git a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt index a063e8499c..655bc7cf91 100644 --- a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt @@ -7093,6 +7093,8 @@ class AppViewModel @Inject constructor( fun beginHardwarePaymentSubmission(): () -> Unit = beginPaymentSubmission() + fun beginPaymentReview(): () -> Unit = paykitPaymentRequestRepo.beginPaymentReview() + private fun beginPaymentSubmission(): () -> Unit { val submission = Any() synchronized(activePaymentSubmissions) { diff --git a/app/src/test/java/to/bitkit/services/PaykitSdkServiceTest.kt b/app/src/test/java/to/bitkit/services/PaykitSdkServiceTest.kt index df777ef411..2b3ee9c87f 100644 --- a/app/src/test/java/to/bitkit/services/PaykitSdkServiceTest.kt +++ b/app/src/test/java/to/bitkit/services/PaykitSdkServiceTest.kt @@ -180,6 +180,58 @@ class PaykitSdkServiceTest { } } + @Test + fun `payment review defers only passive refresh until every review ends`() = runTest { + val sdk = mock() + whenever(sdk.processPendingPrivateMessages()).thenReturn(emptyList()) + whenever(sdk.exportBackupString()).thenReturn("backup") + val report = mock() + whenever(sdk.processOutboundPrivateMessages(RING_PUBKY)).thenReturn(report) + val pending = PaykitException.RecoveryRequired("recovery_required", "Handshake pending") + whenever(sdk.prepareAndResolvePrivatePaymentRequest(RING_PUBKY, "request", null, 1u)).thenThrow(pending) + whenever(sdk.receivePrivateMessages(RING_PUBKY)).thenReturn( + PrivateStreamIntakeReport(null, emptyList(), emptyList()), + ) + val service = PaykitSdkService(mock(), mock(), mock(), settingsStore = mock()) { sdk } + val finishFirst = service.beginPaymentReview() + val finishSecond = service.beginPaymentReview() + val operations = listOf Any?>( + { receivePrivateMessagesFromLinkedPeers(Priority.Background) }, + { processPendingPrivateMessages(Priority.Background) }, + { observeState(RING_PUBKY) }, + ) + for (operation in operations) { + assertSame( + PaykitPaymentRequestError.OperationInProgress, + runSuspendCatching { service.operation() }.exceptionOrNull(), + ) + } + finishFirst() + finishFirst() + assertFalse(service.isPaymentSubmissionActive.value) + assertFailsWith { + service.processPendingPrivateMessages(Priority.Background) + } + assertSame( + pending, + assertFailsWith { + service.prepareAndResolvePrivatePaymentRequest(RING_PUBKY, "request", null) + }, + ) + service.receivePrivateMessages(RING_PUBKY, Priority.Background) + service.processPendingPrivateMessages(Priority.Ordered) + service.exportBackupState() + assertSame(report, service.processOutboundPrivateMessages(RING_PUBKY, Priority.Background)) + verify(sdk).processPendingPrivateMessages() + verify(sdk).exportBackupString() + verify(sdk).prepareAndResolvePrivatePaymentRequest(RING_PUBKY, "request", null, 1u) + verify(sdk).receivePrivateMessages(RING_PUBKY) + + finishSecond() + service.processPendingPrivateMessages(Priority.Background) + verify(sdk, times(2)).processPendingPrivateMessages() + } + @Test fun `state observation validates identity and propagates unavailable session failures`() = runTest { val observation = PaykitStateObservation( diff --git a/changelog.d/next/1591.fixed.md b/changelog.d/next/1591.fixed.md index b2e69d8ae2..4047bc6d88 100644 --- a/changelog.d/next/1591.fixed.md +++ b/changelog.d/next/1591.fixed.md @@ -1 +1 @@ -Reduce delays when delivering payment confirmations to Paykit recipients. +Reduce delays while authorizing Paykit payments and delivering payment confirmations. diff --git a/journeys/payment-requests/review-refresh.xml b/journeys/payment-requests/review-refresh.xml new file mode 100644 index 0000000000..b1333e95e7 --- /dev/null +++ b/journeys/payment-requests/review-refresh.xml @@ -0,0 +1,24 @@ + + + Verifies that a Bitcoin Payment Request remains usable while reviewing and authorizing it, and that closing or backgrounding review does not prevent later requests from arriving. Requires two linked disposable regtest wallets, an existing PIN with payment-PIN enabled and biometric authorization disabled, and at least 10,000 sats in Savings. Use only authorized regtest payments. Queue-admission and expiry fault checks are covered separately by repository tests; this journey verifies the visible flow without changing payment terms. + + + On the requester, send the payer an on-chain-only Bitcoin Payment Request for 1,000 sats + On the payer, verify the Payment Request confirmation screen (id "PaymentRequestConfirm") shows 1,000 sats and Savings is selected + Leave the confirmation open for 30 seconds + Verify the amount remains 1,000 sats and payment authorization has not started + Swipe to pay using the control with id "GRAB", complete any sending-warning confirmation, and wait on PIN entry for 30 seconds without authorizing + Cancel PIN entry, then close the send sheet without paying + Verify Activity contains no outgoing payment for this request + On the requester, send the payer a second on-chain-only Bitcoin Payment Request for 2,000 sats + On the payer, verify the second request becomes visible without restarting the app + Close the second request without paying, then reopen the first request from the pending-request list + Verify the confirmation still shows the original 1,000 sats + Background the app, reopen it, and verify the original request remains available for review + Open the original request if necessary, verify 1,000 sats and the fee, then swipe to pay and complete PIN authorization + Verify the success screen (id "SendSuccess") is visible + Close success and verify Activity contains exactly one outgoing payment for the original request + On the requester, verify the original request records the completed Bitcoin payment without another payment from the payer + Verify the second request remains unpaid + + From 2477559904e7d10211c4f62009683390e39a949d Mon Sep 17 00:00:00 2001 From: benk10 Date: Mon, 19 Oct 2026 22:30:59 -0600 Subject: [PATCH 08/13] fix: reduce Paykit authorization read contention --- .../repositories/PaykitPaymentRequestRepo.kt | 4 +- .../to/bitkit/services/PaykitSdkService.kt | 16 ++- .../PaykitPaymentRequestRepoTest.kt | 48 +++++++ .../bitkit/services/PaykitSdkServiceTest.kt | 120 ++++++++++++++++++ 4 files changed, 185 insertions(+), 3 deletions(-) diff --git a/app/src/main/java/to/bitkit/repositories/PaykitPaymentRequestRepo.kt b/app/src/main/java/to/bitkit/repositories/PaykitPaymentRequestRepo.kt index 490df7c6b4..a0276e9c35 100644 --- a/app/src/main/java/to/bitkit/repositories/PaykitPaymentRequestRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/PaykitPaymentRequestRepo.kt @@ -1075,7 +1075,9 @@ class PaykitPaymentRequestRepo @Inject constructor( } val identity = activeIdentity ?: throw PaykitPaymentRequestError.RequestUnavailable val generation = stateGeneration.get() - ensurePaymentAllowed(it, forExecution = false).getOrThrow() + if (it.isExpired(clock.now())) throw PaykitPaymentRequestError.RequestExpired + if (!isLocallyPayable(it)) throw PaykitPaymentRequestError.RequestUnavailable + // The SDK checks the current peer while atomically claiming and queueing acceptance. val alreadySaved = it.id in presentationStore.loadAcceptedOneTimeIds(identity) val acceptedIds = presentationStore.addAcceptedOneTimeId(identity, it.id) if (!isCurrentState(generation, identity)) throw PaykitPaymentRequestError.RequestUnavailable diff --git a/app/src/main/java/to/bitkit/services/PaykitSdkService.kt b/app/src/main/java/to/bitkit/services/PaykitSdkService.kt index ed99500a5f..f4e53ecdce 100644 --- a/app/src/main/java/to/bitkit/services/PaykitSdkService.kt +++ b/app/src/main/java/to/bitkit/services/PaykitSdkService.kt @@ -74,6 +74,7 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import kotlinx.coroutines.NonCancellable import kotlinx.coroutines.currentCoroutineContext +import kotlinx.coroutines.delay import kotlinx.coroutines.ensureActive import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow @@ -120,6 +121,7 @@ import javax.inject.Inject import javax.inject.Singleton import kotlin.coroutines.cancellation.CancellationException import kotlin.time.Duration +import kotlin.time.Duration.Companion.milliseconds import kotlin.time.Duration.Companion.minutes import kotlin.time.Duration.Companion.seconds import kotlin.time.Instant @@ -1075,7 +1077,7 @@ class PaykitSdkService @Inject constructor( internal suspend fun identityStatus(priority: Priority): IdentityStatus? { isSetup.await() return measuredOperation("identityStatus", priority) { - withPaykitKey { completeSdkCall { it.identityStatus() } } + withPaykitKey { handle -> retryContendedRead { handle.identityStatus() } } } } @@ -1215,7 +1217,7 @@ class PaykitSdkService @Inject constructor( internal suspend fun linkedPeers(priority: Priority): List { isSetup.await() return operationLock.withLock(priority) { - withPaykitKey { completeSdkCall { it.linkedPeers() } } + withPaykitKey { handle -> retryContendedRead { handle.linkedPeers() } } } } @@ -1581,6 +1583,13 @@ class PaykitSdkService @Inject constructor( return result.getOrThrow() } + private suspend fun retryContendedRead(operation: suspend () -> T): T = + runSuspendCatching { completeSdkCall(operation) }.getOrElse { + if (it !is PaykitException.SharedStateBusy) throw it + delay(SHARED_STATE_READ_RETRY_DELAY) + completeSdkCall(operation) + } + /** * Runs [block] on the SDK instance without [operationLock]. [block] may only call unauthenticated public * Pubky reads, never session, secret, state-blob or publishing APIs. Without an instance it builds one under @@ -1655,6 +1664,9 @@ class PaykitSdkService @Inject constructor( /** Maximum time identity maintenance may delay its caller. */ private val IDENTITY_REPUBLISH_WAIT_TIMEOUT = 5.seconds + /** Delay before the single retry of a contended shared state read. */ + private val SHARED_STATE_READ_RETRY_DELAY = 250.milliseconds + /** Maximum duration of a public payment-request capability lookup. */ private val PAYMENT_REQUEST_DISCOVERY_TIMEOUT = 5.seconds diff --git a/app/src/test/java/to/bitkit/repositories/PaykitPaymentRequestRepoTest.kt b/app/src/test/java/to/bitkit/repositories/PaykitPaymentRequestRepoTest.kt index d2746a53c9..40c3dbb0cc 100644 --- a/app/src/test/java/to/bitkit/repositories/PaykitPaymentRequestRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/PaykitPaymentRequestRepoTest.kt @@ -1362,6 +1362,7 @@ class PaykitPaymentRequestRepoTest : BaseUnitTest(StandardTestDispatcher()) { sut.accept(sut.pendingRequests.value.single()).getOrThrow() runCurrent() + verify(paykitSdkService, never()).linkedPeers() assertTrue(sut.pendingRequests.value.isEmpty()) assertEquals(PaymentRequestLifecycleState.ACCEPTED, sut.paymentRequestHistory.value.single().lifecycleState) verify(paykitSdkService, never()).processPendingPrivateMessages(any()) @@ -1382,6 +1383,53 @@ class PaykitPaymentRequestRepoTest : BaseUnitTest(StandardTestDispatcher()) { verify(paykitSdkService, times(1)).acceptPaymentRequest(COUNTERPARTY, PAYMENT_REQUEST_ID, 41uL) } + @Test + fun `proposed acceptance rejects current sdk peer policy without a separate preflight`() = test { + val record = paymentRequestRecord() + whenever(paykitSdkService.allPaymentRequests(anyOrNull())).thenReturn(listOf(record)) + sut.refresh().getOrThrow() + val request = sut.pendingRequests.value.single() + clearInvocations(paykitSdkService) + whenever(paykitSdkService.linkedPeers()).thenReturn( + listOf(linkedPeer(COUNTERPARTY, LinkedPeerState.BLOCKED)), + ) + val refusal = PaykitException.Policy("policy", "counterparty is blocked") + whenever(paykitSdkService.acceptPaymentRequest(any(), any(), any())).doSuspendableAnswer { throw refusal } + + assertSame(refusal, sut.accept(request).exceptionOrNull()) + + verify(paykitSdkService, never()).linkedPeers() + verify(paykitSdkService).acceptPaymentRequest( + COUNTERPARTY, + PAYMENT_REQUEST_ID, + requireNotNull(request.proposalStreamItemId), + ) + verify(presentationStore).removeAcceptedOneTimeIds(LOCAL_IDENTITY, setOf(request.id)) + assertEquals(PaymentRequestLifecycleState.PROPOSED, sut.paymentRequestHistory.value.single().lifecycleState) + assertTrue(sut.ensurePaymentAllowed(request).isFailure) + } + + @Test + fun `final authorization rejects a peer blocked after atomic acceptance`() = test { + val record = paymentRequestRecord() + whenever(paykitSdkService.allPaymentRequests(anyOrNull())).thenReturn(listOf(record)) + whenever(paykitSdkService.acceptPaymentRequest(any(), any(), any())) + .thenReturn(record.copy(state = PaymentRequestLifecycleState.ACCEPTED)) + sut.refresh().getOrThrow() + val request = sut.pendingRequests.value.single() + sut.setPaymentSubmissionActive(true) + sut.accept(request).getOrThrow() + clearInvocations(paykitSdkService) + whenever(paykitSdkService.linkedPeers()).thenReturn( + listOf(linkedPeer(COUNTERPARTY, LinkedPeerState.BLOCKED)), + ) + + assertEquals(PaykitPaymentRequestError.RequestUnavailable, sut.ensurePaymentAllowed(request).exceptionOrNull()) + + verify(paykitSdkService).linkedPeers() + verify(paykitSdkService, never()).acceptPaymentRequest(any(), any(), any()) + } + @Test fun `accept rejects a replacement proposal even when its terms match`() = test { val original = paymentRequestRecord().copy(proposalStreamItemId = 41uL) diff --git a/app/src/test/java/to/bitkit/services/PaykitSdkServiceTest.kt b/app/src/test/java/to/bitkit/services/PaykitSdkServiceTest.kt index 2b3ee9c87f..f9186ad454 100644 --- a/app/src/test/java/to/bitkit/services/PaykitSdkServiceTest.kt +++ b/app/src/test/java/to/bitkit/services/PaykitSdkServiceTest.kt @@ -828,6 +828,126 @@ class PaykitSdkServiceTest { } } + @Test + fun `identity and peer reads retry contention without releasing SDK ownership`() = runTest { + listOf(false, true).forEachCase({ if (it) "linked peers" else "identity" }) { peers -> + val results: List = if (peers) { + listOf(listOf(contactPeer(LinkedPeerState.BLOCKED)), emptyList()) + } else { + listOf( + IdentityStatus(RING_PUBKY, PubkyIdentityCapability.PRIVATE_LINK_CAPABLE), + IdentityStatus(RING_PUBKY, PubkyIdentityCapability.PUBLIC_ONLY), + IdentityStatus(null, PubkyIdentityCapability.SIGNED_OUT), + null, + ) + } + for (expected in results) { + val sdk = mock() + val events = mutableListOf() + val answer = { + events += "read" + if (events.size == 1) throw PaykitException.SharedStateBusy("shared_state_busy", "Locked") + expected + } + if (peers) { + whenever(sdk.linkedPeers()).thenAnswer { answer() } + } else { + whenever(sdk.identityStatus()).thenAnswer { answer() } + } + whenever(sdk.contactRecords()).thenAnswer { + events += "contacts" + emptyList() + } + val service = PaykitSdkService(mock(), mock(), mock(), settingsStore = mock()) { sdk } + val read = async { + if (peers) { + service.linkedPeers(Priority.Interactive) + } else { + service.identityStatus(Priority.Interactive) + } + } + runCurrent() + val contacts = async { service.contactRecords() } + runCurrent() + advanceTimeBy(249) + runCurrent() + assertEquals(listOf("read"), events) + assertFalse(read.isCompleted) + assertFalse(contacts.isCompleted) + + advanceTimeBy(1) + runCurrent() + assertEquals(expected, read.await()) + assertEquals(emptyList(), contacts.await()) + assertEquals(listOf("read", "read", "contacts"), events) + if (peers) verify(sdk, times(2)).linkedPeers() else verify(sdk, times(2)).identityStatus() + } + } + } + + @Test + fun `identity and peer reads retry only one shared state busy failure`() = runTest { + listOf(false, true).forEachCase({ if (it) "linked peers" else "identity" }) { peers -> + listOf( + PaykitException.SharedStateBusy("shared_state_busy", "Locked"), + PaykitException.ConcurrentUpdate("concurrent_update", "Changed"), + PaykitException.Identity("identity_error", "Signed out"), + PaykitException.Storage("storage_error", "Unavailable"), + ).forEachCase({ it.javaClass.simpleName }) { failure -> + val sdk = mock() + if (peers) { + whenever(sdk.linkedPeers()).thenThrow(failure) + } else { + whenever(sdk.identityStatus()).thenThrow(failure) + } + val service = PaykitSdkService(mock(), mock(), mock(), settingsStore = mock()) { sdk } + val started = currentTime + val result = async { + runSuspendCatching { if (peers) service.linkedPeers() else service.identityStatus() } + } + advanceUntilIdle() + val retries = if (failure is PaykitException.SharedStateBusy) 1 else 0 + assertSame(failure, result.await().exceptionOrNull()) + assertEquals(250L * retries, currentTime - started) + if (peers) { + verify(sdk, times(1 + retries)).linkedPeers() + } else { + verify(sdk, times(1 + retries)).identityStatus() + } + } + } + } + + @Test + fun `cancelling identity or peer backoff releases SDK ownership without retrying`() = runTest { + listOf(false, true).forEachCase({ if (it) "linked peers" else "identity" }) { peers -> + val sdk = mock() + val failure = PaykitException.SharedStateBusy("shared_state_busy", "Locked") + if (peers) { + whenever(sdk.linkedPeers()).thenThrow(failure) + } else { + whenever(sdk.identityStatus()).thenThrow(failure) + } + whenever(sdk.contactRecords()).thenReturn(emptyList()) + val service = PaykitSdkService(mock(), mock(), mock(), settingsStore = mock()) { sdk } + val read = async { + if (peers) service.linkedPeers(Priority.Interactive) else service.identityStatus(Priority.Interactive) + } + runCurrent() + val contacts = async { service.contactRecords() } + runCurrent() + assertFalse(contacts.isCompleted) + advanceTimeBy(100) + read.cancel() + runCurrent() + assertFailsWith { read.await() } + assertEquals(emptyList(), contacts.await()) + advanceTimeBy(250) + runCurrent() + if (peers) verify(sdk).linkedPeers() else verify(sdk).identityStatus() + } + } + @Test fun `foreground identity read cannot overtake queued signout`() = runTest { val sdk = mock() From 9055e1daecac948ab0abb48ea2db2873216f0e50 Mon Sep 17 00:00:00 2001 From: benk10 Date: Tue, 20 Oct 2026 02:50:23 -0600 Subject: [PATCH 09/13] fix: protect recovery review and prioritize payment proofs --- .../send/SendPendingObservationDeviceTest.kt | 1 + app/src/main/java/to/bitkit/ext/Lifecycle.kt | 16 ++ .../repositories/PaykitPaymentProofRepo.kt | 21 +- .../to/bitkit/services/PaykitSdkService.kt | 5 +- .../screens/wallets/send/SendPendingScreen.kt | 14 ++ .../java/to/bitkit/ui/sheets/SendSheet.kt | 22 +- .../test/java/to/bitkit/ext/LifecycleTest.kt | 59 +++++ .../PaykitPaymentProofRepoTest.kt | 217 +++++++++++++----- .../bitkit/services/PaykitSdkServiceTest.kt | 147 ++++++++---- .../wallets/send/SendPendingViewModelTest.kt | 30 +++ .../ui/sheets/SendSheetNavigationTest.kt | 29 +++ .../send/onchain-original-payment-retry.xml | 15 +- journeys/send/prepared-fee-recovery.md | 10 + 13 files changed, 462 insertions(+), 124 deletions(-) create mode 100644 app/src/main/java/to/bitkit/ext/Lifecycle.kt create mode 100644 app/src/test/java/to/bitkit/ext/LifecycleTest.kt 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 e6f4cb1f11..9fc40081b8 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 @@ -151,6 +151,7 @@ class SendPendingObservationDeviceTest { onRecovered = { txid, amount -> recovered = txid to amount }, savedStateHandle = SavedStateHandle(), onNavigateToPin = { error("Observation must not request spend authentication") }, + beginPaymentReview = { error("Observation must not hold payment review") }, ) } else { Text("Original payment recovered") diff --git a/app/src/main/java/to/bitkit/ext/Lifecycle.kt b/app/src/main/java/to/bitkit/ext/Lifecycle.kt new file mode 100644 index 0000000000..c2d9273407 --- /dev/null +++ b/app/src/main/java/to/bitkit/ext/Lifecycle.kt @@ -0,0 +1,16 @@ +package to.bitkit.ext + +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.repeatOnLifecycle +import kotlinx.coroutines.awaitCancellation + +internal suspend fun Lifecycle.withPaymentReview(beginReview: () -> (() -> Unit)) { + repeatOnLifecycle(Lifecycle.State.STARTED) { + val finishReview = beginReview() + try { + awaitCancellation() + } finally { + finishReview() + } + } +} diff --git a/app/src/main/java/to/bitkit/repositories/PaykitPaymentProofRepo.kt b/app/src/main/java/to/bitkit/repositories/PaykitPaymentProofRepo.kt index 8850d23aff..f516f4ae16 100644 --- a/app/src/main/java/to/bitkit/repositories/PaykitPaymentProofRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/PaykitPaymentProofRepo.kt @@ -1386,7 +1386,10 @@ class PaykitPaymentProofRepo @Inject constructor( operationMutex.withLock { runSuspendCatching { submitReady(proof) } } } - private suspend fun submitReady(proof: PendingPaykitPaymentProof): Boolean { + private suspend fun submitReady( + proof: PendingPaykitPaymentProof, + priority: PaykitSdkOperationLock.Priority = PaykitSdkOperationLock.Priority.Ordered, + ): Boolean { // Missing legacy app provenance must remain readable, never guessed for delivery. if (proof.paymentAppId.isBlank()) return false if (proof.kind == PaykitPaymentProofKind.Onchain && proof.onchainAcceptanceVerified != true) return false @@ -1414,6 +1417,7 @@ class PaykitPaymentProofRepo @Inject constructor( proofJson = proofJson, conversionQuoteId = proof.conversionQuoteId, billingPeriod = proof.billingPeriod, + priority = priority, ) Logger.info("Queued a Paykit payment proof for private delivery", context = TAG) } @@ -1499,7 +1503,20 @@ class PaykitPaymentProofRepo @Inject constructor( operationMutex.withLock { runSuspendCatching { for (proof in completedProofs) { - if (proof in loadProofs()) submitReady(proof) + if (proof in loadProofs()) { + val priority = if ( + proof.kind == PaykitPaymentProofKind.Lightning || + ( + proof.kind == PaykitPaymentProofKind.Onchain && + proof.onchainWalletId == WalletScope.default + ) + ) { + PaykitSdkOperationLock.Priority.Interactive + } else { + PaykitSdkOperationLock.Priority.Ordered + } + submitReady(proof, priority) + } } }.onFailure { Logger.warn("Failed to queue a Paykit payment proof", it, context = TAG) } } diff --git a/app/src/main/java/to/bitkit/services/PaykitSdkService.kt b/app/src/main/java/to/bitkit/services/PaykitSdkService.kt index f4e53ecdce..aecf32d4dc 100644 --- a/app/src/main/java/to/bitkit/services/PaykitSdkService.kt +++ b/app/src/main/java/to/bitkit/services/PaykitSdkService.kt @@ -1156,7 +1156,7 @@ class PaykitSdkService @Inject constructor( } } - suspend fun submitPaymentProof( + internal suspend fun submitPaymentProof( counterparty: String, paymentRequestId: String, paymentAppId: String, @@ -1164,9 +1164,10 @@ class PaykitSdkService @Inject constructor( proofJson: String, billingPeriod: PaykitBillingPeriod? = null, conversionQuoteId: String? = null, + priority: Priority = Priority.Ordered, ): PaymentRequestRecord { isSetup.await() - return measuredOperation("submitPaymentProof") { + return measuredOperation("submitPaymentProof", priority) { withStateRevisionTracking { handle -> completeSdkCall { handle.submitPaymentProof( 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 c38681fab8..418e3b706f 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 @@ -37,9 +37,11 @@ import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.compose.ui.window.DialogProperties import androidx.lifecycle.SavedStateHandle +import androidx.lifecycle.compose.LocalLifecycleOwner import androidx.lifecycle.compose.collectAsStateWithLifecycle import kotlinx.coroutines.flow.filterNotNull import to.bitkit.R +import to.bitkit.ext.withPaymentReview import to.bitkit.models.Toast import to.bitkit.models.WalletScope import to.bitkit.repositories.OnchainPreparedReceipt @@ -93,6 +95,7 @@ fun SendPendingScreen( onRecovered: (String, Long) -> Unit, savedStateHandle: SavedStateHandle, onNavigateToPin: () -> Unit, + beginPaymentReview: () -> (() -> Unit), onRecoveredTransfer: (OnchainSendAttempt) -> Unit = {}, ) { val uiState by viewModel.uiState.collectAsStateWithLifecycle() @@ -143,6 +146,8 @@ fun SendPendingScreen( savedStateHandle, onNavigateToPin, viewModel, + beginPaymentReview, + showAbandonConfirmation, { rate, approve -> viewModel.retryOriginal(rate) { attempt, fee -> retryOriginal(attempt, fee, approve) } }, ) { onRetry -> SendPendingContent( @@ -229,6 +234,8 @@ private fun RecoveryAuthorization( savedStateHandle: SavedStateHandle, onNavigateToPin: () -> Unit, viewModel: SendPendingViewModel, + beginPaymentReview: () -> (() -> Unit), + showAbandonConfirmation: Boolean, retryOriginal: (ULong, suspend (OnchainPreparedReceipt, List) -> Unit) -> Unit, content: @Composable (() -> Unit) -> Unit, ) { @@ -239,6 +246,13 @@ private fun RecoveryAuthorization( val pinForPayments by settings.isPinForPaymentsEnabled.collectAsStateWithLifecycle() val isBiometricEnabled by settings.isBiometricEnabled.collectAsStateWithLifecycle() val isBiometrySupported = rememberBiometricAuthSupported() + val lifecycle = LocalLifecycleOwner.current.lifecycle + val reviewing = (showFeeApproval && uiState.recoveryAttempt != null) || + showAbandonConfirmation || uiState.recoveryApproval != null + LaunchedEffect(reviewing, lifecycle, beginPaymentReview) { + if (!reviewing) return@LaunchedEffect + lifecycle.withPaymentReview(beginPaymentReview) + } LaunchedEffect(savedStateHandle) { savedStateHandle.getStateFlow(RECOVERY_PIN_CHECK_RESULT_KEY, null) .filterNotNull().collect { successful -> diff --git a/app/src/main/java/to/bitkit/ui/sheets/SendSheet.kt b/app/src/main/java/to/bitkit/ui/sheets/SendSheet.kt index 8039cef676..7289d40e09 100644 --- a/app/src/main/java/to/bitkit/ui/sheets/SendSheet.kt +++ b/app/src/main/java/to/bitkit/ui/sheets/SendSheet.kt @@ -30,12 +30,12 @@ import androidx.lifecycle.compose.LocalLifecycleOwner import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.repeatOnLifecycle import androidx.navigation.NavController +import androidx.navigation.NavDestination import androidx.navigation.NavDestination.Companion.hasRoute import androidx.navigation.compose.NavHost import androidx.navigation.compose.currentBackStackEntryAsState import androidx.navigation.compose.rememberNavController import androidx.navigation.toRoute -import kotlinx.coroutines.awaitCancellation import kotlinx.coroutines.delay import kotlinx.coroutines.isActive import kotlinx.coroutines.launch @@ -44,6 +44,7 @@ import to.bitkit.R import to.bitkit.ext.getSatsPerVByteFor import to.bitkit.ext.supportPaymentRequest import to.bitkit.ext.toSendFailureDetails +import to.bitkit.ext.withPaymentReview import to.bitkit.models.NewTransactionSheetDetails import to.bitkit.models.NewTransactionSheetDirection import to.bitkit.models.NewTransactionSheetType @@ -107,6 +108,10 @@ private const val HARDWARE_SEND_FALLBACK_SATS_PER_VBYTE = 3uL /** A peer reconnecting makes a channel usable again without any node event, so the overlay polls. */ private val CHANNELS_REFRESH_INTERVAL = 1.seconds +internal fun NavDestination.isPaymentReview(): Boolean = + hasRoute() || hasRoute() || + hasRoute() || hasRoute() + internal fun NavController.navigateToCoinSelection() { currentBackStackEntry?.savedStateHandle?.set(SEND_CONFIRM_RESET_RESULT_KEY, true) navigateTo(SendRoute.CoinSelection) @@ -184,20 +189,10 @@ fun SendSheet( ) { val navController = rememberNavController() val entry by navController.currentBackStackEntryAsState() - val reviewing = entry?.destination?.let { - it.hasRoute() || it.hasRoute() || - it.hasRoute() - } == true + val reviewing = entry?.destination?.isPaymentReview() == true LaunchedEffect(reviewing, lifecycle, appViewModel) { if (!reviewing) return@LaunchedEffect - lifecycle.repeatOnLifecycle(Lifecycle.State.STARTED) { - val finishReview = appViewModel.beginPaymentReview() - try { - awaitCancellation() - } finally { - finishReview() - } - } + lifecycle.withPaymentReview(appViewModel::beginPaymentReview) } fun showHardwareSuccess(txId: String, amountSats: ULong, walletId: String, isShopPayment: Boolean) { appViewModel.onSendSuccess( @@ -691,6 +686,7 @@ fun SendSheet( SendPendingScreen( savedStateHandle = it.savedStateHandle, onNavigateToPin = { navController.navigateTo(SendRoute.RecoveryPinCheck) }, + beginPaymentReview = appViewModel::beginPaymentReview, paymentHash = route.paymentHash, amount = route.amount, observeResolution = route.observeResolution, diff --git a/app/src/test/java/to/bitkit/ext/LifecycleTest.kt b/app/src/test/java/to/bitkit/ext/LifecycleTest.kt new file mode 100644 index 0000000000..6caac0a7da --- /dev/null +++ b/app/src/test/java/to/bitkit/ext/LifecycleTest.kt @@ -0,0 +1,59 @@ +package to.bitkit.ext + +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleOwner +import androidx.lifecycle.LifecycleRegistry +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.cancelAndJoin +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.runCurrent +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import to.bitkit.test.BaseUnitTest +import kotlin.test.assertEquals + +@OptIn(ExperimentalCoroutinesApi::class) +@Config(sdk = [34]) +@RunWith(RobolectricTestRunner::class) +class LifecycleTest : BaseUnitTest() { + @Test + fun `payment review releases on background and dismissal and resumes when started`() = test { + val owner = object : LifecycleOwner { + override val lifecycle = LifecycleRegistry.createUnsafe(this) + } + owner.lifecycle.currentState = Lifecycle.State.CREATED + var activeReviews = 0 + var startedReviews = 0 + val review = launch { + owner.lifecycle.withPaymentReview { + activeReviews++ + startedReviews++ + val finishReview: () -> Unit = { activeReviews-- } + finishReview + } + } + runCurrent() + assertEquals(0, activeReviews) + + owner.lifecycle.currentState = Lifecycle.State.STARTED + runCurrent() + assertEquals(1, activeReviews) + owner.lifecycle.currentState = Lifecycle.State.RESUMED + runCurrent() + assertEquals(1, startedReviews) + + owner.lifecycle.currentState = Lifecycle.State.CREATED + runCurrent() + assertEquals(0, activeReviews) + owner.lifecycle.currentState = Lifecycle.State.STARTED + runCurrent() + assertEquals(1, activeReviews) + assertEquals(2, startedReviews) + + review.cancelAndJoin() + assertEquals(0, activeReviews) + owner.lifecycle.currentState = Lifecycle.State.DESTROYED + } +} diff --git a/app/src/test/java/to/bitkit/repositories/PaykitPaymentProofRepoTest.kt b/app/src/test/java/to/bitkit/repositories/PaykitPaymentProofRepoTest.kt index 36762704e7..b0d592551b 100644 --- a/app/src/test/java/to/bitkit/repositories/PaykitPaymentProofRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/PaykitPaymentProofRepoTest.kt @@ -183,7 +183,7 @@ class PaykitPaymentProofRepoTest : BaseUnitTest(StandardTestDispatcher()) { val request = paymentRequest(MethodId.P2wpkh.rawValue) val record = paymentRequestRecord() whenever(paykitSdkService.paymentRequests()).thenReturn(listOf(record)) - whenever(paykitSdkService.submitPaymentProof(any(), any(), any(), any(), any(), isNull(), isNull())) + whenever(paykitSdkService.submitPaymentProof(any(), any(), any(), any(), any(), isNull(), isNull(), any())) .doSuspendableAnswer { assertEquals(txid, PaykitPaymentProofStore(diskKeychain).load().single().proofData) delay(5.seconds) @@ -206,7 +206,18 @@ class PaykitPaymentProofRepoTest : BaseUnitTest(StandardTestDispatcher()) { assertEquals(0L, testScheduler.currentTime - started) verify(lightningRepo).completeAcceptedShopFollowup(request.id, txid) runCurrent() - verify(paykitSdkService).submitPaymentProof(any(), any(), any(), any(), any(), isNull(), isNull()) + verify( + paykitSdkService + ).submitPaymentProof( + any(), + any(), + any(), + any(), + any(), + isNull(), + isNull(), + eq(PaykitSdkOperationLock.Priority.Interactive) + ) assertTrue(file.exists()) advanceUntilIdle() assertEquals(5.seconds.inWholeMilliseconds, testScheduler.currentTime - started) @@ -250,7 +261,7 @@ class PaykitPaymentProofRepoTest : BaseUnitTest(StandardTestDispatcher()) { whenever(paykitSdkService.paymentRequests()).thenReturn(listOf(record)) val submissionStarted = CompletableDeferred() val releaseSubmission = CompletableDeferred() - whenever(paykitSdkService.submitPaymentProof(any(), any(), any(), any(), any(), isNull(), isNull())) + whenever(paykitSdkService.submitPaymentProof(any(), any(), any(), any(), any(), isNull(), isNull(), any())) .doSuspendableAnswer { submissionStarted.complete(Unit) releaseSubmission.await() @@ -274,6 +285,9 @@ class PaykitPaymentProofRepoTest : BaseUnitTest(StandardTestDispatcher()) { repo.associateLightningPayment(request, PAYMENT_HASH, MethodId.Bolt11.rawValue, "bitkit").getOrThrow() repo.completeLightningPayment(PAYMENT_HASH, PREIMAGE) submissionStarted.await() + val reconciliation = launch { repo.reconcile() } + runCurrent() + assertFalse(reconciliation.isCompleted) try { val snapshot = withTimeout(1_000) { repo.backupSnapshot() } assertEquals(PREIMAGE, snapshot.single().restored().proofData) @@ -286,8 +300,21 @@ class PaykitPaymentProofRepoTest : BaseUnitTest(StandardTestDispatcher()) { } finally { releaseDelivery.complete(Unit) } + reconciliation.join() advanceUntilIdle() - verify(paykitSdkService, times(1)).submitPaymentProof(any(), any(), any(), any(), any(), isNull(), isNull()) + verify( + paykitSdkService, + times(1) + ).submitPaymentProof( + any(), + any(), + any(), + any(), + any(), + isNull(), + isNull(), + eq(PaykitSdkOperationLock.Priority.Interactive) + ) } @Test @@ -300,7 +327,7 @@ class PaykitPaymentProofRepoTest : BaseUnitTest(StandardTestDispatcher()) { repo.prepare(request, MethodId.P2wpkh.rawValue, "bitkit", PaykitPaymentProofKind.Onchain).getOrThrow() repo.markOnchainPaymentStarted(request, ONCHAIN_ADDRESS, request.amount.atomic).getOrThrow() whenever(store.save(any())).doSuspendableAnswer { throw AppError("disk unavailable") } - whenever(paykitSdkService.submitPaymentProof(any(), any(), any(), any(), any(), isNull(), isNull())) + whenever(paykitSdkService.submitPaymentProof(any(), any(), any(), any(), any(), isNull(), isNull(), any())) .doSuspendableAnswer { delay(5.seconds) throw AppError("delivery unavailable") @@ -350,14 +377,20 @@ class PaykitPaymentProofRepoTest : BaseUnitTest(StandardTestDispatcher()) { whenever(paykitSdkService.identityStatus()).thenReturn(status) paymentProofRepo().reconcile() assertEquals(txid, storedProofs.single().proofData) - verify(paykitSdkService, never()).submitPaymentProof(any(), any(), any(), any(), any(), isNull(), isNull()) + verify( + paykitSdkService, + never() + ).submitPaymentProof(any(), any(), any(), any(), any(), isNull(), isNull(), any()) } storedProofs = emptyList() whenever(paykitSdkService.identityStatus()) .thenReturn(IdentityStatus(LOCAL_IDENTITY, PubkyIdentityCapability.PRIVATE_LINK_CAPABLE)) repo.reconcile() assertTrue(storedProofs.isEmpty()) - verify(paykitSdkService, never()).submitPaymentProof(any(), any(), any(), any(), any(), isNull(), isNull()) + verify( + paykitSdkService, + never() + ).submitPaymentProof(any(), any(), any(), any(), any(), isNull(), isNull(), any()) } @Test @@ -365,7 +398,10 @@ class PaykitPaymentProofRepoTest : BaseUnitTest(StandardTestDispatcher()) { storedProofs = listOf(readyLightningProof(PAYMENT_REQUEST_ID).copy(paymentAppId = "")) whenever(paykitSdkService.paymentRequests()).thenReturn(listOf(paymentRequestRecord())) paymentProofRepo().reconcile() - verify(paykitSdkService, never()).submitPaymentProof(any(), any(), any(), any(), any(), isNull(), isNull()) + verify( + paykitSdkService, + never() + ).submitPaymentProof(any(), any(), any(), any(), any(), isNull(), isNull(), any()) assertEquals("", storedProofs.single().paymentAppId) assertTrue(storedProofs.single().paymentStarted) } @@ -573,7 +609,7 @@ class PaykitPaymentProofRepoTest : BaseUnitTest(StandardTestDispatcher()) { val record = paymentRequestRecord() val request = paymentRequest(MethodId.Bolt11.rawValue) whenever(paykitSdkService.paymentRequests()).thenReturn(listOf(record)) - whenever(paykitSdkService.submitPaymentProof(any(), any(), any(), any(), any(), isNull(), isNull())) + whenever(paykitSdkService.submitPaymentProof(any(), any(), any(), any(), any(), isNull(), isNull(), any())) .thenThrow(IllegalStateException("temporary failure")) .thenReturn(record) val firstRepo = paymentProofRepo() @@ -597,6 +633,7 @@ class PaykitPaymentProofRepoTest : BaseUnitTest(StandardTestDispatcher()) { proofJson = proofCaptor.capture(), billingPeriod = isNull(), conversionQuoteId = isNull(), + priority = any(), ) assertEquals(MethodId.Bolt11.rawValue, endpointCaptor.lastValue) assertEquals( @@ -622,7 +659,7 @@ class PaykitPaymentProofRepoTest : BaseUnitTest(StandardTestDispatcher()) { ) } whenever(paykitSdkService.paymentRequests()).thenReturn(listOf(record)) - whenever(paykitSdkService.submitPaymentProof(any(), any(), any(), any(), any(), isNull(), isNull())) + whenever(paykitSdkService.submitPaymentProof(any(), any(), any(), any(), any(), isNull(), isNull(), any())) .thenReturn(record) val lightning = readyLightningProof(PAYMENT_REQUEST_ID) val onchain = lightning.copy( @@ -653,6 +690,7 @@ class PaykitPaymentProofRepoTest : BaseUnitTest(StandardTestDispatcher()) { proofJson = any(), billingPeriod = isNull(), conversionQuoteId = isNull(), + priority = any(), ) } } @@ -670,7 +708,7 @@ class PaykitPaymentProofRepoTest : BaseUnitTest(StandardTestDispatcher()) { paymentRequestRecord(paymentRequestId = secondPaymentRequestId), ), ) - whenever(paykitSdkService.submitPaymentProof(any(), any(), any(), any(), any(), isNull(), isNull())) + whenever(paykitSdkService.submitPaymentProof(any(), any(), any(), any(), any(), isNull(), isNull(), any())) .thenThrow(IllegalStateException("temporary failure")) .thenReturn(paymentRequestRecord(paymentRequestId = secondPaymentRequestId)) @@ -685,6 +723,7 @@ class PaykitPaymentProofRepoTest : BaseUnitTest(StandardTestDispatcher()) { proofJson = any(), billingPeriod = isNull(), conversionQuoteId = isNull(), + priority = any(), ) assertEquals(listOf(PAYMENT_REQUEST_ID, secondPaymentRequestId), paymentRequestIdCaptor.allValues) assertEquals(listOf(PAYMENT_REQUEST_ID), storedProofs.map { it.requestId.paymentRequestId }) @@ -706,7 +745,7 @@ class PaykitPaymentProofRepoTest : BaseUnitTest(StandardTestDispatcher()) { whenever(lightningRepo.getPayments()).thenReturn(Result.success(listOf(payment))) whenever(paykitSdkService.paymentRequests()).thenReturn(listOf(record)) whenever( - paykitSdkService.submitPaymentProof(any(), any(), any(), any(), any(), isNull(), isNull()) + paykitSdkService.submitPaymentProof(any(), any(), any(), any(), any(), isNull(), isNull(), any()) ).thenReturn(record) val firstRepo = paymentProofRepo() @@ -726,6 +765,7 @@ class PaykitPaymentProofRepoTest : BaseUnitTest(StandardTestDispatcher()) { proofJson = proofCaptor.capture(), billingPeriod = isNull(), conversionQuoteId = isNull(), + priority = eq(PaykitSdkOperationLock.Priority.Ordered), ) assertEquals( """{"data":"$PREIMAGE","type":"${PaykitPaymentProofKind.Lightning.type}"}""", @@ -740,7 +780,7 @@ class PaykitPaymentProofRepoTest : BaseUnitTest(StandardTestDispatcher()) { val request = paymentRequest(MethodId.Bolt11.rawValue) whenever(paykitSdkService.paymentRequests()).thenReturn(listOf(record)) whenever( - paykitSdkService.submitPaymentProof(any(), any(), any(), any(), any(), isNull(), isNull()) + paykitSdkService.submitPaymentProof(any(), any(), any(), any(), any(), isNull(), isNull(), any()) ).thenReturn(record) val repo = paymentProofRepo() @@ -748,7 +788,7 @@ class PaykitPaymentProofRepoTest : BaseUnitTest(StandardTestDispatcher()) { repo.completeLightningPayment(PAYMENT_HASH, PREIMAGE) repo.reconcile() - verify(paykitSdkService).submitPaymentProof(any(), any(), any(), any(), any(), isNull(), isNull()) + verify(paykitSdkService).submitPaymentProof(any(), any(), any(), any(), any(), isNull(), isNull(), any()) assertTrue(storedProofs.isEmpty()) } @@ -762,7 +802,10 @@ class PaykitPaymentProofRepoTest : BaseUnitTest(StandardTestDispatcher()) { repo.completeLightningPayment(PAYMENT_HASH, "01".repeat(32)) assertNull(storedProofs.single().proofData) - verify(paykitSdkService, never()).submitPaymentProof(any(), any(), any(), any(), any(), isNull(), isNull()) + verify( + paykitSdkService, + never() + ).submitPaymentProof(any(), any(), any(), any(), any(), isNull(), isNull(), any()) } @Test @@ -812,7 +855,10 @@ class PaykitPaymentProofRepoTest : BaseUnitTest(StandardTestDispatcher()) { LOCAL_IDENTITY, ) verify(paykitSdkService, never()).processPendingPrivateMessages() - verify(paykitSdkService, never()).submitPaymentProof(any(), any(), any(), any(), any(), isNull(), isNull()) + verify( + paykitSdkService, + never() + ).submitPaymentProof(any(), any(), any(), any(), any(), isNull(), isNull(), any()) } @Test @@ -836,7 +882,10 @@ class PaykitPaymentProofRepoTest : BaseUnitTest(StandardTestDispatcher()) { assertEquals(PaykitPaymentRequestError.OperationInProgress, result.exceptionOrNull()) assertTrue(storedProofs.isEmpty()) - verify(paykitSdkService, never()).submitPaymentProof(any(), any(), any(), any(), any(), isNull(), isNull()) + verify( + paykitSdkService, + never() + ).submitPaymentProof(any(), any(), any(), any(), any(), isNull(), isNull(), any()) } @Test @@ -866,7 +915,10 @@ class PaykitPaymentProofRepoTest : BaseUnitTest(StandardTestDispatcher()) { repo.failLightningPayment(PAYMENT_HASH) assertTrue(storedProofs.isEmpty()) - verify(paykitSdkService, never()).submitPaymentProof(any(), any(), any(), any(), any(), isNull(), isNull()) + verify( + paykitSdkService, + never() + ).submitPaymentProof(any(), any(), any(), any(), any(), isNull(), isNull(), any()) } @Test @@ -881,7 +933,7 @@ class PaykitPaymentProofRepoTest : BaseUnitTest(StandardTestDispatcher()) { whenever(paykitSdkService.paymentRequests()).thenReturn(listOf(record)) whenever(lightningRepo.getPayments()).thenReturn(Result.success(emptyList())) whenever( - paykitSdkService.submitPaymentProof(any(), any(), any(), any(), any(), isNull(), isNull()) + paykitSdkService.submitPaymentProof(any(), any(), any(), any(), any(), isNull(), isNull(), any()) ).thenReturn(record) for (error in errors) { val request = paymentRequest(MethodId.Bolt11.rawValue) @@ -912,7 +964,7 @@ class PaykitPaymentProofRepoTest : BaseUnitTest(StandardTestDispatcher()) { verify( paykitSdkService, times(errors.size) - ).submitPaymentProof(any(), any(), any(), any(), any(), isNull(), isNull()) + ).submitPaymentProof(any(), any(), any(), any(), any(), isNull(), isNull(), any()) } @Test @@ -946,7 +998,7 @@ class PaykitPaymentProofRepoTest : BaseUnitTest(StandardTestDispatcher()) { val record = paymentRequestRecord() whenever(paykitSdkService.paymentRequests()).thenReturn(listOf(record)) whenever( - paykitSdkService.submitPaymentProof(any(), any(), any(), any(), any(), isNull(), isNull()) + paykitSdkService.submitPaymentProof(any(), any(), any(), any(), any(), isNull(), isNull(), any()) ).thenReturn(record) val repo = paymentProofRepo() @@ -972,6 +1024,7 @@ class PaykitPaymentProofRepoTest : BaseUnitTest(StandardTestDispatcher()) { proofCaptor.capture(), isNull(), conversionQuoteId = isNull(), + priority = any(), ) assertEquals(MethodId.P2wpkh.rawValue, endpointCaptor.firstValue) assertEquals( @@ -1092,7 +1145,7 @@ class PaykitPaymentProofRepoTest : BaseUnitTest(StandardTestDispatcher()) { assertNull(storedProofs.single().paymentIdentifier) assertNull(storedProofs.single().proofData) assertTrue(storedProofs.single().paymentStarted) - verify(paykitSdkService, never()).submitPaymentProof(any(), any(), any(), any(), any(), any(), isNull()) + verify(paykitSdkService, never()).submitPaymentProof(any(), any(), any(), any(), any(), any(), isNull(), any()) } @Test @@ -1103,14 +1156,14 @@ class PaykitPaymentProofRepoTest : BaseUnitTest(StandardTestDispatcher()) { val record = paymentRequestRecord() whenever(paykitSdkService.paymentRequests()).thenReturn(listOf(record)) whenever( - paykitSdkService.submitPaymentProof(any(), any(), any(), any(), any(), isNull(), isNull()) + paykitSdkService.submitPaymentProof(any(), any(), any(), any(), any(), isNull(), isNull(), any()) ).thenReturn(record) val repo = paymentProofRepo() repo.completeOnchainPayment(request, txid, endpoint, "bitkit", OnchainSendOutcome.Accepted(txid)) repo.reconcile() - verify(paykitSdkService).submitPaymentProof(any(), any(), any(), eq(endpoint), any(), isNull(), isNull()) + verify(paykitSdkService).submitPaymentProof(any(), any(), any(), eq(endpoint), any(), isNull(), isNull(), any()) assertTrue(storedProofs.isEmpty()) } @@ -1124,7 +1177,7 @@ class PaykitPaymentProofRepoTest : BaseUnitTest(StandardTestDispatcher()) { val record = paymentRequestRecord() whenever(paykitSdkService.paymentRequests()).thenReturn(listOf(record)) whenever( - paykitSdkService.submitPaymentProof(any(), any(), any(), any(), any(), any(), isNull()) + paykitSdkService.submitPaymentProof(any(), any(), any(), any(), any(), any(), isNull(), any()) ).thenReturn(record) val repo = paymentProofRepo() @@ -1142,6 +1195,7 @@ class PaykitPaymentProofRepoTest : BaseUnitTest(StandardTestDispatcher()) { proofJson = any(), billingPeriod = periodCaptor.capture(), conversionQuoteId = isNull(), + priority = any(), ) assertEquals(period, periodCaptor.firstValue) } @@ -1168,7 +1222,7 @@ class PaykitPaymentProofRepoTest : BaseUnitTest(StandardTestDispatcher()) { val request = paymentRequest(MethodId.Bolt11.rawValue, billingPeriod = currentPeriod) whenever(paykitSdkService.paymentRequests()).thenReturn(listOf(record)) whenever( - paykitSdkService.submitPaymentProof(any(), any(), any(), any(), any(), any(), isNull()) + paykitSdkService.submitPaymentProof(any(), any(), any(), any(), any(), any(), isNull(), any()) ).thenReturn(record) val repo = paymentProofRepo() @@ -1177,7 +1231,7 @@ class PaykitPaymentProofRepoTest : BaseUnitTest(StandardTestDispatcher()) { repo.completeLightningPayment(PAYMENT_HASH, PREIMAGE) repo.reconcile() - verify(paykitSdkService).submitPaymentProof(any(), any(), any(), any(), any(), any(), isNull()) + verify(paykitSdkService).submitPaymentProof(any(), any(), any(), any(), any(), any(), isNull(), any()) } @Test @@ -1219,7 +1273,7 @@ class PaykitPaymentProofRepoTest : BaseUnitTest(StandardTestDispatcher()) { val record = paymentRequestRecord() whenever(paykitSdkService.paymentRequests()).thenReturn(listOf(record)) whenever( - paykitSdkService.submitPaymentProof(any(), any(), any(), any(), any(), isNull(), isNull()) + paykitSdkService.submitPaymentProof(any(), any(), any(), any(), any(), isNull(), isNull(), any()) ).thenReturn(record) val repo = paymentProofRepo() @@ -1234,7 +1288,18 @@ class PaykitPaymentProofRepoTest : BaseUnitTest(StandardTestDispatcher()) { OnchainSendOutcome.Accepted(txid) ) - verify(paykitSdkService).submitPaymentProof(any(), any(), any(), any(), any(), isNull(), isNull()) + verify( + paykitSdkService + ).submitPaymentProof( + any(), + any(), + any(), + any(), + any(), + isNull(), + isNull(), + eq(PaykitSdkOperationLock.Priority.Ordered) + ) assertTrue(storedProofs.isEmpty()) } @@ -1244,7 +1309,7 @@ class PaykitPaymentProofRepoTest : BaseUnitTest(StandardTestDispatcher()) { val request = paymentRequest(MethodId.P2wpkh.rawValue) val record = paymentRequestRecord() whenever(paykitSdkService.paymentRequests()).thenReturn(listOf(record)) - whenever(paykitSdkService.submitPaymentProof(any(), any(), any(), any(), any(), isNull(), isNull())) + whenever(paykitSdkService.submitPaymentProof(any(), any(), any(), any(), any(), isNull(), isNull(), any())) .thenThrow(IllegalStateException("transient submission failure")) val repo = paymentProofRepo() @@ -1260,7 +1325,7 @@ class PaykitPaymentProofRepoTest : BaseUnitTest(StandardTestDispatcher()) { ) assertEquals(txid, storedProofs.single().proofData) - verify(paykitSdkService).submitPaymentProof(any(), any(), any(), any(), any(), isNull(), isNull()) + verify(paykitSdkService).submitPaymentProof(any(), any(), any(), any(), any(), isNull(), isNull(), any()) } @Test @@ -1270,7 +1335,7 @@ class PaykitPaymentProofRepoTest : BaseUnitTest(StandardTestDispatcher()) { val record = paymentRequestRecord() whenever(paykitSdkService.paymentRequests()).thenReturn(listOf(record)) whenever( - paykitSdkService.submitPaymentProof(any(), any(), any(), any(), any(), isNull(), isNull()) + paykitSdkService.submitPaymentProof(any(), any(), any(), any(), any(), isNull(), isNull(), any()) ).thenReturn(record) val repo = paymentProofRepo() @@ -1286,7 +1351,7 @@ class PaykitPaymentProofRepoTest : BaseUnitTest(StandardTestDispatcher()) { ) repo.reconcile() - verify(paykitSdkService).submitPaymentProof(any(), any(), any(), any(), any(), isNull(), isNull()) + verify(paykitSdkService).submitPaymentProof(any(), any(), any(), any(), any(), isNull(), isNull(), any()) assertEquals(txid, storedProofs.single().proofData) } @@ -1298,7 +1363,7 @@ class PaykitPaymentProofRepoTest : BaseUnitTest(StandardTestDispatcher()) { val record = paymentRequestRecord() whenever(paykitSdkService.paymentRequests()).thenReturn(listOf(record)) whenever( - paykitSdkService.submitPaymentProof(any(), any(), any(), any(), any(), isNull(), isNull()) + paykitSdkService.submitPaymentProof(any(), any(), any(), any(), any(), isNull(), isNull(), any()) ).thenReturn(record) val repo = paymentProofRepo() @@ -1318,6 +1383,7 @@ class PaykitPaymentProofRepoTest : BaseUnitTest(StandardTestDispatcher()) { proofJson = proofCaptor.capture(), billingPeriod = isNull(), conversionQuoteId = isNull(), + priority = any(), ) assertEquals(endpoint, endpointCaptor.firstValue) assertEquals( @@ -1334,7 +1400,7 @@ class PaykitPaymentProofRepoTest : BaseUnitTest(StandardTestDispatcher()) { val request = paymentRequest(MethodId.P2wpkh.rawValue) whenever(paykitSdkService.paymentRequests()).thenReturn(listOf(record)) whenever( - paykitSdkService.submitPaymentProof(any(), any(), any(), any(), any(), isNull(), isNull()) + paykitSdkService.submitPaymentProof(any(), any(), any(), any(), any(), isNull(), isNull(), any()) ).thenReturn(record) val repo = paymentProofRepo() @@ -1353,6 +1419,7 @@ class PaykitPaymentProofRepoTest : BaseUnitTest(StandardTestDispatcher()) { proofJson = proofCaptor.capture(), billingPeriod = isNull(), conversionQuoteId = isNull(), + priority = any(), ) assertTrue(proofCaptor.firstValue.contains(txid)) } @@ -1375,7 +1442,7 @@ class PaykitPaymentProofRepoTest : BaseUnitTest(StandardTestDispatcher()) { ) ) assertEquals(listOf(original), storedProofs) - verify(paykitSdkService, never()).submitPaymentProof(any(), any(), any(), any(), any(), any(), isNull()) + verify(paykitSdkService, never()).submitPaymentProof(any(), any(), any(), any(), any(), any(), isNull(), any()) } @Test @@ -1432,7 +1499,7 @@ class PaykitPaymentProofRepoTest : BaseUnitTest(StandardTestDispatcher()) { ), ) whenever(paykitSdkService.paymentRequests()).thenReturn(listOf(paymentRequestRecord())) - whenever(paykitSdkService.submitPaymentProof(any(), any(), any(), any(), any(), isNull(), isNull())) + whenever(paykitSdkService.submitPaymentProof(any(), any(), any(), any(), any(), isNull(), isNull(), any())) .thenThrow(IllegalStateException("delivery temporarily unavailable")) assertTrue( @@ -1484,7 +1551,7 @@ class PaykitPaymentProofRepoTest : BaseUnitTest(StandardTestDispatcher()) { ).thenReturn(IdentityStatus(LOCAL_IDENTITY, PubkyIdentityCapability.PRIVATE_LINK_CAPABLE)) whenever(lightningRepo.currentOnchainSendAttempt()).thenReturn(attempt.copy(payerIdentity = null)) assertTrue(repo.authorizeOnchainRecovery(request, attempt.copy(payerIdentity = null)).isFailure) - verify(paykitSdkService, never()).submitPaymentProof(any(), any(), any(), any(), any(), any(), isNull()) + verify(paykitSdkService, never()).submitPaymentProof(any(), any(), any(), any(), any(), any(), isNull(), any()) } @Test @@ -1565,7 +1632,7 @@ class PaykitPaymentProofRepoTest : BaseUnitTest(StandardTestDispatcher()) { assertNull(storedProofs.single().proofData) assertEquals("ef".repeat(32), storedProofs.single().paymentIdentifier) - verify(paykitSdkService, never()).submitPaymentProof(any(), any(), any(), any(), any(), any(), isNull()) + verify(paykitSdkService, never()).submitPaymentProof(any(), any(), any(), any(), any(), any(), isNull(), any()) verify(lightningRepo, never()).finishAcceptedShopActivity(any(), any()) } @@ -1580,7 +1647,7 @@ class PaykitPaymentProofRepoTest : BaseUnitTest(StandardTestDispatcher()) { paymentRequestRecord(paymentRequestId = secondPaymentRequestId), ), ) - whenever(paykitSdkService.submitPaymentProof(any(), any(), any(), any(), any(), isNull(), isNull())) + whenever(paykitSdkService.submitPaymentProof(any(), any(), any(), any(), any(), isNull(), isNull(), any())) .thenReturn(paymentRequestRecord()) val repo = paymentProofRepo() repo.prepare(firstRequest, MethodId.P2wpkh.rawValue, "bitkit", PaykitPaymentProofKind.Onchain).getOrThrow() @@ -1611,7 +1678,7 @@ class PaykitPaymentProofRepoTest : BaseUnitTest(StandardTestDispatcher()) { assertTrue(storedProofs.single().onchainMatchingTransactionIdsBeforeAttempt.isEmpty()) assertNull(storedProofs.single().proofData) - verify(paykitSdkService, never()).submitPaymentProof(any(), any(), any(), any(), any(), any(), isNull()) + verify(paykitSdkService, never()).submitPaymentProof(any(), any(), any(), any(), any(), any(), isNull(), any()) } @Test @@ -1663,7 +1730,7 @@ class PaykitPaymentProofRepoTest : BaseUnitTest(StandardTestDispatcher()) { assertTrue(storedProofs.single().paymentStarted) assertNull(storedProofs.single().proofData) verify(lightningRepo, never()).completeAcceptedShopFollowup(any(), any()) - verify(paykitSdkService, never()).submitPaymentProof(any(), any(), any(), any(), any(), any(), isNull()) + verify(paykitSdkService, never()).submitPaymentProof(any(), any(), any(), any(), any(), any(), isNull(), any()) whenever( paykitSdkService.identityStatus() @@ -1672,7 +1739,7 @@ class PaykitPaymentProofRepoTest : BaseUnitTest(StandardTestDispatcher()) { assertTrue(storedProofs.single().paymentStarted) assertNull(storedProofs.single().proofData) verify(lightningRepo, never()).completeAcceptedShopFollowup(any(), any()) - verify(paykitSdkService, never()).submitPaymentProof(any(), any(), any(), any(), any(), any(), isNull()) + verify(paykitSdkService, never()).submitPaymentProof(any(), any(), any(), any(), any(), any(), isNull(), any()) whenever( paykitSdkService.identityStatus() ).thenReturn(IdentityStatus(LOCAL_IDENTITY, PubkyIdentityCapability.PRIVATE_LINK_CAPABLE)) @@ -1690,6 +1757,7 @@ class PaykitPaymentProofRepoTest : BaseUnitTest(StandardTestDispatcher()) { any(), isNull(), isNull(), + any(), ) } @@ -1699,7 +1767,7 @@ class PaykitPaymentProofRepoTest : BaseUnitTest(StandardTestDispatcher()) { val request = paymentRequest(MethodId.P2wpkh.rawValue) val record = paymentRequestRecord() whenever(paykitSdkService.paymentRequests()).thenReturn(listOf(record)) - whenever(paykitSdkService.submitPaymentProof(any(), any(), any(), any(), any(), isNull(), isNull())) + whenever(paykitSdkService.submitPaymentProof(any(), any(), any(), any(), any(), isNull(), isNull(), any())) .thenThrow(IllegalStateException("delivery unavailable")) .thenReturn(record) val repo = paymentProofRepo() @@ -1732,6 +1800,7 @@ class PaykitPaymentProofRepoTest : BaseUnitTest(StandardTestDispatcher()) { proofCaptor.capture(), isNull(), isNull(), + any(), ) assertTrue(proofCaptor.allValues.all { it.contains(txid) }) verify(lightningRepo, never()).completeAcceptedShopFollowup(newerRequest.id, "cd".repeat(32)) @@ -1755,7 +1824,7 @@ class PaykitPaymentProofRepoTest : BaseUnitTest(StandardTestDispatcher()) { paymentProofRepo().reconcile() assertEquals(1, storedProofs.size) - verify(paykitSdkService, never()).submitPaymentProof(any(), any(), any(), any(), any(), any(), isNull()) + verify(paykitSdkService, never()).submitPaymentProof(any(), any(), any(), any(), any(), any(), isNull(), any()) verify(lightningRepo, never()).completeAcceptedShopFollowup(any(), any()) } @@ -1769,7 +1838,7 @@ class PaykitPaymentProofRepoTest : BaseUnitTest(StandardTestDispatcher()) { assertFalse(repo.completeOnchainPayment(request, "ab".repeat(32), MethodId.P2wpkh.rawValue, "bitkit")) assertNull(storedProofs.single().proofData) - verify(paykitSdkService, never()).submitPaymentProof(any(), any(), any(), any(), any(), any(), isNull()) + verify(paykitSdkService, never()).submitPaymentProof(any(), any(), any(), any(), any(), any(), isNull(), any()) } @Test @@ -1792,7 +1861,7 @@ class PaykitPaymentProofRepoTest : BaseUnitTest(StandardTestDispatcher()) { acceptedAttempt(request, txid).copy(evidence = OnchainSendEvidence.Observed), ) whenever(paykitSdkService.paymentRequests()).thenReturn(listOf(paymentRequestRecord())) - whenever(paykitSdkService.submitPaymentProof(any(), any(), any(), any(), any(), isNull(), isNull())) + whenever(paykitSdkService.submitPaymentProof(any(), any(), any(), any(), any(), isNull(), isNull(), any())) .thenThrow(IllegalStateException("delivery unavailable")) paymentProofRepo().reconcile() @@ -1818,7 +1887,7 @@ class PaykitPaymentProofRepoTest : BaseUnitTest(StandardTestDispatcher()) { ) assertFalse(storedProofs.single().onchainAcceptanceVerified) assertNull(storedProofs.single().proofData) - verify(paykitSdkService, never()).submitPaymentProof(any(), any(), any(), any(), any(), any(), isNull()) + verify(paykitSdkService, never()).submitPaymentProof(any(), any(), any(), any(), any(), any(), isNull(), any()) } @Test @@ -1839,7 +1908,7 @@ class PaykitPaymentProofRepoTest : BaseUnitTest(StandardTestDispatcher()) { paymentProofRepo().reconcile() assertEquals(1, storedProofs.size) - verify(paykitSdkService, never()).submitPaymentProof(any(), any(), any(), any(), any(), any(), isNull()) + verify(paykitSdkService, never()).submitPaymentProof(any(), any(), any(), any(), any(), any(), isNull(), any()) verify(lightningRepo, never()).completeAcceptedShopFollowup(any(), any()) } @@ -2371,7 +2440,7 @@ class PaykitPaymentProofRepoTest : BaseUnitTest(StandardTestDispatcher()) { ).thenReturn(Result.success(true)) whenever(paykitSdkService.paymentRequests()).thenReturn(listOf(paymentRequestRecord())) whenever( - paykitSdkService.submitPaymentProof(any(), any(), any(), any(), any(), isNull(), isNull()) + paykitSdkService.submitPaymentProof(any(), any(), any(), any(), any(), isNull(), isNull(), any()) ).thenReturn(paymentRequestRecord()) paymentProofRepo().reconcile() verify(paykitSdkService).submitPaymentProof( @@ -2381,7 +2450,8 @@ class PaykitPaymentProofRepoTest : BaseUnitTest(StandardTestDispatcher()) { eq(MethodId.P2wpkh.rawValue), eq("""{"data":"$txid","type":"bitcoin-onchain-txid"}"""), isNull(), - isNull() + isNull(), + any() ) assertTrue(storedProofs.isEmpty()) verify(hwWalletRepo, never()).broadcastFunding(any(), org.mockito.kotlin.anyOrNull()) @@ -2409,13 +2479,16 @@ class PaykitPaymentProofRepoTest : BaseUnitTest(StandardTestDispatcher()) { ) repo.failOnchainPayment(request) assertEquals(1, storedProofs.size) - verify(paykitSdkService, never()).submitPaymentProof(any(), any(), any(), any(), any(), isNull(), isNull()) + verify( + paykitSdkService, + never() + ).submitPaymentProof(any(), any(), any(), any(), any(), isNull(), isNull(), any()) whenever( hwWalletRepo.observeExactTransaction(walletId, txid, ONCHAIN_ADDRESS, request.amount.atomic) ).thenReturn(Result.success(true)) whenever(paykitSdkService.paymentRequests()).thenReturn(listOf(paymentRequestRecord())) - whenever(paykitSdkService.submitPaymentProof(any(), any(), any(), any(), any(), isNull(), isNull())) + whenever(paykitSdkService.submitPaymentProof(any(), any(), any(), any(), any(), isNull(), isNull(), any())) .thenReturn(paymentRequestRecord()) whenever(lightningRepo.currentOnchainSendAttempt()).thenReturn(acceptedAttempt(request, "cd".repeat(32))) paymentProofRepo().reconcile() @@ -2427,7 +2500,8 @@ class PaykitPaymentProofRepoTest : BaseUnitTest(StandardTestDispatcher()) { eq(MethodId.P2wpkh.rawValue), eq("""{"data":"$txid","type":"bitcoin-onchain-txid"}"""), isNull(), - isNull() + isNull(), + any() ) assertTrue(storedProofs.isEmpty()) verify(hwWalletRepo, never()).broadcastFunding(any(), org.mockito.kotlin.anyOrNull()) @@ -2451,10 +2525,13 @@ class PaykitPaymentProofRepoTest : BaseUnitTest(StandardTestDispatcher()) { .thenReturn(Result.success(false)) paymentProofRepo().reconcile() assertEquals(txid, storedProofs.single().proofData) - verify(paykitSdkService, never()).submitPaymentProof(any(), any(), any(), any(), any(), isNull(), isNull()) + verify( + paykitSdkService, + never() + ).submitPaymentProof(any(), any(), any(), any(), any(), isNull(), isNull(), any()) whenever(hwWalletRepo.observeExactTransaction(walletId, txid, ONCHAIN_ADDRESS, request.amount.atomic)) .thenReturn(Result.success(true)) - whenever(paykitSdkService.submitPaymentProof(any(), any(), any(), any(), any(), isNull(), isNull())) + whenever(paykitSdkService.submitPaymentProof(any(), any(), any(), any(), any(), isNull(), isNull(), any())) .thenReturn(paymentRequestRecord()) paymentProofRepo().reconcile() assertTrue(storedProofs.isEmpty()) @@ -2482,7 +2559,7 @@ class PaykitPaymentProofRepoTest : BaseUnitTest(StandardTestDispatcher()) { whenever(hwWalletRepo.observeExactTransaction(walletId, txid, ONCHAIN_ADDRESS, request.amount.atomic)) .thenReturn(Result.success(false)) whenever(paykitSdkService.paymentRequests()).thenReturn(listOf(paymentRequestRecord())) - whenever(paykitSdkService.submitPaymentProof(any(), any(), any(), any(), any(), isNull(), isNull())) + whenever(paykitSdkService.submitPaymentProof(any(), any(), any(), any(), any(), isNull(), isNull(), any())) .thenReturn(paymentRequestRecord()) repo.reconcile() assertTrue(storedProofs.isEmpty()) @@ -2495,6 +2572,7 @@ class PaykitPaymentProofRepoTest : BaseUnitTest(StandardTestDispatcher()) { eq("""{"data":"$txid","type":"bitcoin-onchain-txid"}"""), isNull(), isNull(), + eq(PaykitSdkOperationLock.Priority.Ordered), ) verify(hwWalletRepo, never()).broadcastFunding(any(), org.mockito.kotlin.anyOrNull()) } @@ -2521,7 +2599,10 @@ class PaykitPaymentProofRepoTest : BaseUnitTest(StandardTestDispatcher()) { assertEquals(completed, storedProofs.single()) verify(hwWalletRepo, times(2)).observeExactTransaction(walletId, txid, ONCHAIN_ADDRESS, request.amount.atomic) - verify(paykitSdkService, never()).submitPaymentProof(any(), any(), any(), any(), any(), isNull(), isNull()) + verify( + paykitSdkService, + never() + ).submitPaymentProof(any(), any(), any(), any(), any(), isNull(), isNull(), any()) verify(hwWalletRepo, never()).broadcastFunding(any(), org.mockito.kotlin.anyOrNull()) } @@ -2545,7 +2626,10 @@ class PaykitPaymentProofRepoTest : BaseUnitTest(StandardTestDispatcher()) { assertFalse(storedProofs.single().onchainAcceptanceVerified) verify(hwWalletRepo, times(2)).observeExactTransaction(walletId, txid, ONCHAIN_ADDRESS, request.amount.atomic) verify(hwWalletRepo, never()).broadcastFunding(any(), org.mockito.kotlin.anyOrNull()) - verify(paykitSdkService, never()).submitPaymentProof(any(), any(), any(), any(), any(), isNull(), isNull()) + verify( + paykitSdkService, + never() + ).submitPaymentProof(any(), any(), any(), any(), any(), isNull(), isNull(), any()) } @Test @@ -2574,7 +2658,10 @@ class PaykitPaymentProofRepoTest : BaseUnitTest(StandardTestDispatcher()) { assertTrue(repo.completeHardwareOnchainPayment(request.id, walletId, txid, LOCAL_IDENTITY)) assertTrue(storedProofs.first { it.identity == LOCAL_IDENTITY }.onchainAcceptanceVerified) assertEquals(other, storedProofs.first { it.identity == COUNTERPARTY }) - verify(paykitSdkService, never()).submitPaymentProof(any(), any(), any(), any(), any(), isNull(), isNull()) + verify( + paykitSdkService, + never() + ).submitPaymentProof(any(), any(), any(), any(), any(), isNull(), isNull(), any()) } @Test @@ -2676,7 +2763,10 @@ class PaykitPaymentProofRepoTest : BaseUnitTest(StandardTestDispatcher()) { .exceptionOrNull() ) verify(hwWalletRepo, times(1)).broadcastFunding(signed) - verify(paykitSdkService, never()).submitPaymentProof(any(), any(), any(), any(), any(), isNull(), isNull()) + verify( + paykitSdkService, + never() + ).submitPaymentProof(any(), any(), any(), any(), any(), isNull(), isNull(), any()) } @Test @@ -3049,7 +3139,10 @@ class PaykitPaymentProofRepoTest : BaseUnitTest(StandardTestDispatcher()) { assertNull(reopened.retainedHardwareOnchainRequest(request)) assertEquals(listOf(proof), storedProofs) verify(hwWalletRepo, never()).broadcastFunding(any(), org.mockito.kotlin.anyOrNull()) - verify(paykitSdkService, never()).submitPaymentProof(any(), any(), any(), any(), any(), isNull(), isNull()) + verify( + paykitSdkService, + never() + ).submitPaymentProof(any(), any(), any(), any(), any(), isNull(), isNull(), any()) } private fun paymentProofRepo(store: PaykitPaymentProofStore = this.store) = PaykitPaymentProofRepo( diff --git a/app/src/test/java/to/bitkit/services/PaykitSdkServiceTest.kt b/app/src/test/java/to/bitkit/services/PaykitSdkServiceTest.kt index f9186ad454..dde6fdada0 100644 --- a/app/src/test/java/to/bitkit/services/PaykitSdkServiceTest.kt +++ b/app/src/test/java/to/bitkit/services/PaykitSdkServiceTest.kt @@ -23,6 +23,7 @@ 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.PrivatePaymentListDeliveryReport import com.synonym.paykit.PrivatePaymentListReservationUpdateInput import com.synonym.paykit.PrivatePaymentListSyncChange @@ -987,57 +988,123 @@ class PaykitSdkServiceTest { @Test fun `payment mutations overtake background work without crossing signout barriers`() = runTest { - val payments = listOf Any?>( - { claimPaymentRequestForExecution(RING_PUBKY, "request") }, - { acceptPaymentRequest(RING_PUBKY, "request", 1uL) }, - ) - for ((index, payment) in payments.withIndex()) { - for (withBarrier in listOf(false, true)) { + mockConstruction(PrivateJsonObject::class.java).use { + val payments = listOf Any?>( + { claimPaymentRequestForExecution(RING_PUBKY, "request") }, + { acceptPaymentRequest(RING_PUBKY, "request", 1uL) }, + { + submitPaymentProof( + RING_PUBKY, + "request", + "bitkit", + "endpoint", + "{}", + priority = Priority.Interactive + ) + }, + { submitPaymentProof(RING_PUBKY, "request", "bitkit", "endpoint", "{}") }, + ) + for ((index, payment) in payments.withIndex()) { + for (withBarrier in listOf(false, true)) { + val sdk = mock() + val release = CompletableDeferred() + val events = mutableListOf() + whenever { sdk.contactRecords() }.doSuspendableAnswer { + release.await() + emptyList() + } + whenever { sdk.pendingOutboundPrivateCounterparties() }.thenAnswer { + events += "background" + emptyList() + } + whenever { sdk.signOut() }.thenAnswer { + events += "signout" + IdentityStatus(null, PubkyIdentityCapability.SIGNED_OUT) + } + whenever { sdk.claimPaymentRequestForExecution(RING_PUBKY, "request") }.thenAnswer { + events += "payment" + mock() + } + whenever { sdk.claimAndAcceptPaymentRequest(RING_PUBKY, "request", 1uL) }.thenAnswer { + events += "payment" + mock() + } + whenever { sdk.submitPaymentProof(any(), any(), any()) }.thenAnswer { + events += "payment" + mock() + } + val service = PaykitSdkService(mock(), mock(), mock(), settingsStore = mock()) { sdk } + val active = async { service.contactRecords() } + runCurrent() + val background = async { service.pendingOutboundPrivateCounterparties(Priority.Background) } + val barrier = if (withBarrier) async { service.signOut() } else null + val foreground = async { service.payment() } + runCurrent() + assertTrue(events.isEmpty()) + + release.complete(Unit) + active.await() + background.await() + barrier?.await() + foreground.await() + + assertEquals( + when { + withBarrier -> listOf("background", "signout", "payment") + index == 3 -> listOf("background", "payment") + else -> listOf("payment", "background") + }, + events, + ) + verify(sdk, times(if (index == 0) 1 else 0)).claimPaymentRequestForExecution(RING_PUBKY, "request") + verify( + sdk, + times(if (index == 1) 1 else 0) + ).claimAndAcceptPaymentRequest(RING_PUBKY, "request", 1uL) + verify(sdk, times(if (index >= 2) 1 else 0)).submitPaymentProof(any(), any(), any()) + verify(sdk, never()).acceptPaymentRequest(any(), any(), any()) + } + } + } + } + + @Test + fun `proof enqueue cancellation preserves SDK ownership until the native call finishes`() = runTest { + mockConstruction(PrivateJsonObject::class.java).use { + for (cancelBeforeAdmission in listOf(true, false)) { val sdk = mock() val release = CompletableDeferred() - val events = mutableListOf() whenever { sdk.contactRecords() }.doSuspendableAnswer { - release.await() + if (cancelBeforeAdmission) release.await() emptyList() } - whenever { sdk.pendingOutboundPrivateCounterparties() }.thenAnswer { - events += "background" - emptyList() - } - whenever { sdk.signOut() }.thenAnswer { - events += "signout" - IdentityStatus(null, PubkyIdentityCapability.SIGNED_OUT) - } - whenever { sdk.claimPaymentRequestForExecution(RING_PUBKY, "request") }.thenAnswer { - events += "payment" - mock() - } - whenever { sdk.claimAndAcceptPaymentRequest(RING_PUBKY, "request", 1uL) }.thenAnswer { - events += "payment" + whenever { sdk.submitPaymentProof(any(), any(), any()) }.doSuspendableAnswer { + release.await() mock() } val service = PaykitSdkService(mock(), mock(), mock(), settingsStore = mock()) { sdk } - val active = async { service.contactRecords() } + val active = if (cancelBeforeAdmission) async { service.contactRecords() } else null runCurrent() - val background = async { service.pendingOutboundPrivateCounterparties(Priority.Background) } - val barrier = if (withBarrier) async { service.signOut() } else null - val foreground = async { service.payment() } + val proof = async { + service.submitPaymentProof( + RING_PUBKY, + "request", + "bitkit", + "endpoint", + "{}", + priority = Priority.Interactive + ) + } runCurrent() - assertTrue(events.isEmpty()) - + proof.cancel() + val next = async { service.contactRecords() } + runCurrent() + assertFalse(next.isCompleted) + verify(sdk, times(if (cancelBeforeAdmission) 0 else 1)).submitPaymentProof(any(), any(), any()) release.complete(Unit) - active.await() - background.await() - barrier?.await() - foreground.await() - - assertEquals( - if (withBarrier) listOf("background", "signout", "payment") else listOf("payment", "background"), - events, - ) - verify(sdk, times(if (index == 0) 1 else 0)).claimPaymentRequestForExecution(RING_PUBKY, "request") - verify(sdk, times(if (index == 1) 1 else 0)).claimAndAcceptPaymentRequest(RING_PUBKY, "request", 1uL) - verify(sdk, never()).acceptPaymentRequest(any(), any(), any()) + active?.await() + assertFailsWith { proof.await() } + assertEquals(emptyList(), next.await()) } } } 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 cf3174f5fc..7a4f7a6876 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 @@ -8,7 +8,9 @@ import com.synonym.bitkitcore.PaymentType import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.async +import kotlinx.coroutines.cancelAndJoin import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.launch import kotlinx.coroutines.test.advanceTimeBy import kotlinx.coroutines.test.advanceUntilIdle import kotlinx.coroutines.test.runCurrent @@ -38,6 +40,7 @@ import to.bitkit.viewmodels.SanityWarning import kotlin.test.assertEquals import kotlin.test.assertIs import kotlin.test.assertNull +import kotlin.test.assertTrue import kotlin.time.Duration.Companion.seconds @OptIn(ExperimentalCoroutinesApi::class) @@ -104,6 +107,33 @@ class SendPendingViewModelTest : BaseUnitTest() { assertNull(sut.uiState.value.recoveryApproval) } + @Test + fun `declined or cancelled recovery approval returns to idle pending`() = test { + val receipt = OnchainPreparedReceipt( + "ab".repeat(32), + emptyList(), + "original", + 1_000uL, + feeRateSatsPerVByte = 10uL, + miningFeeSats = 600uL + ) + for (cancelCaller in listOf(false, true)) { + val authorization = launch { + sut.approveRecoveryFee(receipt, emptyList(), requiresPin = true, useBiometrics = true) + } + runCurrent() + assertEquals(RecoveryApproval.Fee(receipt), sut.uiState.value.recoveryApproval) + if (cancelCaller) { + authorization.cancelAndJoin() + } else { + sut.answerRecoveryApproval(false) + runCurrent() + } + assertTrue(authorization.isCancelled) + assertNull(sut.uiState.value.recoveryApproval) + } + } + @Test fun `pending original retry is explicit serialized and retains original amount`() = test { val txid = "ab".repeat(32) diff --git a/app/src/test/java/to/bitkit/ui/sheets/SendSheetNavigationTest.kt b/app/src/test/java/to/bitkit/ui/sheets/SendSheetNavigationTest.kt index 3bbc8a8181..df2c6367ba 100644 --- a/app/src/test/java/to/bitkit/ui/sheets/SendSheetNavigationTest.kt +++ b/app/src/test/java/to/bitkit/ui/sheets/SendSheetNavigationTest.kt @@ -12,12 +12,41 @@ import org.junit.runner.RunWith import org.robolectric.RobolectricTestRunner import org.robolectric.annotation.Config import to.bitkit.ui.navigateTo +import kotlin.test.assertFalse import kotlin.test.assertNull import kotlin.test.assertTrue @Config(sdk = [34]) @RunWith(RobolectricTestRunner::class) class SendSheetNavigationTest { + @Test + fun `recovery pin holds review only until returning to pending`() { + val pending = SendRoute.Pending(paymentHash = "payment", amount = 1000, walletId = "wallet0") + val navController = NavHostController(ApplicationProvider.getApplicationContext()).apply { + navigatorProvider.addNavigator(ComposeNavigator()) + graph = createGraph(startDestination = pending) { + composable {} + composable {} + composable {} + composable {} + composable {} + } + } + val reviewRoutes = listOf( + SendRoute.RecoveryPinCheck, + SendRoute.Confirm, + SendRoute.PinCheck, + SendRoute.HardwareSign, + ) + for (route in reviewRoutes) { + assertFalse(requireNotNull(navController.currentDestination).isPaymentReview()) + navController.navigateTo(route) + assertTrue(requireNotNull(navController.currentDestination).isPaymentReview()) + assertTrue(navController.popBackStack()) + assertFalse(requireNotNull(navController.currentDestination).isPaymentReview()) + } + } + @Test fun `pending clears earlier send screens after prepared amount review`() { for (start in listOf(SendRoute.Confirm, SendRoute.ContactSelect, SendRoute.Amount)) { diff --git a/journeys/send/onchain-original-payment-retry.xml b/journeys/send/onchain-original-payment-retry.xml index 13902a8797..7d57695426 100644 --- a/journeys/send/onchain-original-payment-retry.xml +++ b/journeys/send/onchain-original-payment-retry.xml @@ -1,12 +1,14 @@ Verifies explicit fee approval and normal spend authentication for retrying one original software - on-chain payment. Precondition: an onboarded funded dev wallet, PIN enabled, a synced node, and a - retained unresolved prepared-send receipt with its exact original recipient, amount, inputs and + on-chain payment. Precondition: an onboarded funded dev wallet, payment PIN enabled, biometrics + disabled, a synced node, and a retained unresolved prepared-send receipt with its exact original + recipient, amount, inputs and candidate transaction IDs. Creating a controlled refusal or response-loss fixture is not provided by the Capabilities table and remains in Manual Tests. A seeded Pending fixture must be labelled - as state injection, never as proof of a native fault. Manual Tests must verify the exact backend - winning transaction retains the complete original input set and recipient amount, and any original + as state injection, never as proof of a native fault. Keep the original outcome unresolved during + the cancellation steps. Manual Tests must verify the exact backend winning transaction retains the + complete original input set and recipient amount, and any original Shop request or Blocktank order completes once without a new request or order. Local activity alone cannot prove these checks. See prepared-fee-recovery.md in this directory for the four shared manual fee-recovery cases and platform adaptations. Record native fault coverage separately. @@ -16,9 +18,12 @@ Verify Pending describes the unresolved outcome and offers Retry original payment Tap Retry original payment Verify the dialog shows the original recipient and amount and prefills the original fee rate + Leave the fee dialog open for 30 seconds, then tap Cancel; verify the original Pending payment remains retained and tap Retry original payment again Enter an invalid fee rate of 1000 sat/vB and verify valid positive fee feedback appears and Continue is disabled Enter a valid authorized fee rate that the funded fixture can support and tap Continue - Verify the exact prepared mining fee in sats is shown beside the original recipient amount; tap Authorize retry, accept any normal fee-over-half or fee-over-10-USD warnings, and verify the normal payment PIN screen appears before dispatch + Verify the exact prepared mining fee in sats is shown beside the original recipient amount; leave this approval open for 30 seconds, then tap Cancel and verify the original Pending payment remains retained + Tap Retry original payment again, choose the fixture-supported fee, tap Continue, approve the exact prepared fee and any normal fee warnings, and verify the normal payment PIN screen appears before dispatch + Leave the payment PIN screen open for 30 seconds and verify it still awaits authentication Go Back without entering the PIN and verify the original Pending payment remains retained Tap Retry original payment again, choose the fixture-supported fee, tap Continue, approve the exact prepared fee and any normal fee warnings Enter the fixture's normal payment PIN without recording entered digits diff --git a/journeys/send/prepared-fee-recovery.md b/journeys/send/prepared-fee-recovery.md index d4dcbcbfc8..e5a691a7e1 100644 --- a/journeys/send/prepared-fee-recovery.md +++ b/journeys/send/prepared-fee-recovery.md @@ -9,6 +9,16 @@ These checks need a disposable funded regtest wallet and controlled native trans If the winning fee is not already known, an attempt without a retained candidate fee must still wait for verified transaction-history data. The private candidate-fee map is not exported in the shared backup: reset or a new installation uses the existing history fallback, not an imported fee. A retained fee is bookkeeping evidence, never payment-acceptance evidence. +## Recovery review and authentication + +Use the retained original payment from the same controlled fixture. Keep transaction history controlled so an original confirmation cannot silently replace the approval being checked. + +1. Run the shared retry journey with payment PIN enabled and biometrics disabled. Check cancellation at the fee entry, exact prepared fee and PIN stages. Cancellation must not authorize a replacement or discard the original Pending payment. +2. Repeat the fee and PIN checks while backgrounding the app, returning and unlocking normally. If the review closes, reopen the retained original Pending payment; do not start a new send. Verify the original recipient and amount before any approval. Separately close the review and reopen it without backgrounding. +3. On a disposable device with enrolled biometrics, repeat retry authentication and cancel the system prompt. For an eligible expired, unbroadcast request, also cancel both the abandon confirmation and its release-fee approval, then exercise biometric cancellation. Verify cancellation neither authorizes a replacement nor releases the original inputs. Report these checks as blocked when biometric or controlled abandon fixtures are unavailable. + +With no active payment and the SDK initially idle, verify that passive refresh is deferred during each visible fee, abandon or payment-authentication review. A biometric overlay that leaves the app visible must retain this protection. Backgrounding, dismissing or cancelling the review releases its hold; an idle Pending screen must still allow passive refresh and recovery observation. Use the focused admission tests or a sanitized SDK operation trace for these checks: waiting on a screen alone cannot establish SDK admission or a latency improvement. Work already inside the SDK may finish. + ## Platform adaptations Android retains candidate fees for original sends and replacements in `candidateMiningFees`. iOS already retains the original prepared fee; `candidateFeeSats` covers successors/replacements. Run the same four cases on both platforms. Use each platform's normal identity-selection flow; iOS Max recovery keeps the original rate without an editable field. The common UI actions are in [onchain-original-payment-retry.xml](onchain-original-payment-retry.xml). From 7c7a51a6d06705f25170b0872cf84548340d1d4f Mon Sep 17 00:00:00 2001 From: benk10 Date: Tue, 20 Oct 2026 07:44:45 -0600 Subject: [PATCH 10/13] fix: retain deferred contact refresh and payment priority --- .../java/to/bitkit/repositories/PubkyRepo.kt | 12 ++- .../contacts/ContactDetailViewModel.kt | 3 +- .../java/to/bitkit/viewmodels/AppViewModel.kt | 11 +++ .../to/bitkit/repositories/PubkyRepoTest.kt | 92 +++++++++++++++++++ .../contacts/ContactDetailViewModelTest.kt | 59 ++++++++---- .../viewmodels/AppViewModelSendFlowTest.kt | 76 +++++++++++++++ journeys/send/prepared-fee-recovery.md | 2 + 7 files changed, 232 insertions(+), 23 deletions(-) diff --git a/app/src/main/java/to/bitkit/repositories/PubkyRepo.kt b/app/src/main/java/to/bitkit/repositories/PubkyRepo.kt index 72ffd14344..70f2406610 100644 --- a/app/src/main/java/to/bitkit/repositories/PubkyRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/PubkyRepo.kt @@ -101,7 +101,7 @@ sealed class PubkyContactError(message: String) : AppError(message) { */ class PubkySignIn internal constructor(val publicKey: String, internal val generation: Long) -private fun Throwable.hasCause(error: PubkyContactError): Boolean = +private fun Throwable.hasCause(error: AppError): Boolean = generateSequence(this) { it.cause }.any { it == error } private fun Throwable.isPaykitReadTimeout(): Boolean = @@ -151,6 +151,9 @@ class PubkyRepo @Inject constructor( /** Shortest time between two contact list updates of a background profile refresh. */ internal val CONTACT_REFRESH_BATCH_WINDOW = 300.milliseconds + /** Delay before retrying a contact observation deferred by payment review or submission. */ + private val CONTACT_LOAD_RETRY_INTERVAL = 500.milliseconds + /** Maximum automatic restore attempts during one foreground recovery window. */ private const val DEFERRED_RESTORE_ATTEMPTS = 8 @@ -1240,8 +1243,11 @@ class PubkyRepo @Inject constructor( loaded = true shouldMarkLoadCompleted = true }.onFailure { - shouldMarkLoadCompleted = isCurrent(signIn) - if (it != PaykitPaymentRequestError.OperationInProgress) { + if (it.hasCause(PaykitPaymentRequestError.OperationInProgress)) { + reload = isCurrent(signIn) + if (reload) delay(CONTACT_LOAD_RETRY_INTERVAL) + } else { + shouldMarkLoadCompleted = isCurrent(signIn) Logger.error("Failed to load contacts", it, context = TAG) } } diff --git a/app/src/main/java/to/bitkit/ui/screens/contacts/ContactDetailViewModel.kt b/app/src/main/java/to/bitkit/ui/screens/contacts/ContactDetailViewModel.kt index fcba7b0277..d0a520144c 100644 --- a/app/src/main/java/to/bitkit/ui/screens/contacts/ContactDetailViewModel.kt +++ b/app/src/main/java/to/bitkit/ui/screens/contacts/ContactDetailViewModel.kt @@ -14,7 +14,6 @@ import kotlinx.coroutines.Deferred import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.Job import kotlinx.coroutines.async -import kotlinx.coroutines.cancelAndJoin import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow @@ -198,7 +197,7 @@ class ContactDetailViewModel @Inject constructor( private suspend fun awaitPaymentRequestTarget(): PaykitPaymentRequestTarget? { val refresh = refreshPaymentRequestTarget() val target = withTimeoutOrNull(PAYMENT_REQUEST_TARGET_WAIT) { refresh.await() } - if (refresh.isActive) refresh.cancelAndJoin() + if (refresh.isActive) refresh.cancel() return target } diff --git a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt index 655bc7cf91..01b4fe59e0 100644 --- a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt @@ -1152,6 +1152,17 @@ class AppViewModel @Inject constructor( ) { return Result.failure(PaykitPaymentRequestError.RequestUnavailable) } + if (onlyWhileActive && reconcileProofs) { + val signIn = pubkyRepo.currentSignIn() + ?: return Result.failure(PaykitPaymentRequestError.RequestUnavailable) + paykitPaymentRequestRepo.isPaymentSubmissionActive.first { !it } + currentCoroutineContext().ensureActive() + if (!pubkyRepo.isCurrent(signIn) || !isPaykitEnabled.value || !walletRepo.walletExists() || + isPaymentRequestPollingStopped.value || privatePaykitRepo.isProfileDeletionInProgress.value + ) { + return Result.failure(PaykitPaymentRequestError.RequestUnavailable) + } + } if (reconcileProofs) paykitPaymentProofRepo.reconcile() if (privatePaykitRepo.isProfileDeletionInProgress.value || onlyWhileActive && isPaymentRequestPollingStopped.value diff --git a/app/src/test/java/to/bitkit/repositories/PubkyRepoTest.kt b/app/src/test/java/to/bitkit/repositories/PubkyRepoTest.kt index 3f134239c0..8b6748ef12 100644 --- a/app/src/test/java/to/bitkit/repositories/PubkyRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/PubkyRepoTest.kt @@ -9,6 +9,7 @@ import com.synonym.paykit.ContactUpdate import com.synonym.paykit.PaykitException import com.synonym.paykit.PaykitProfile import com.synonym.paykit.PaykitSdk +import com.synonym.paykit.PaykitStateObservation import com.synonym.paykit.ProfileResolution import com.synonym.paykit.ProfileSource import com.synonym.paykit.PubkyAuthCompanionClaim @@ -3478,6 +3479,97 @@ class PubkyRepoTest : BaseUnitTest() { assertFalse(sut.isLoadingContacts.value) } + @Test + fun `contact refresh retries wrapped SDK review deferral without completing or holding the SDK lock`() = test { + authenticateForTesting(publicKey = VALID_SELF_KEY) + val saved = createContactRecord(VALID_CONTACT_KEY_A, profile = createPaykitProfile("Saved")) + whenever(pubkyService.observedContactRecords(VALID_SELF_KEY)).thenReturn(listOf(saved)) + sut.loadContacts() + val version = sut.contactsLoadVersion.value + val completionVersion = sut.contactsLoadCompletionVersion.value + val sdk = mock() + val observation = PaykitStateObservation( + publicKey = VALID_SELF_KEY, + keyGeneration = 0uL, + observedAt = "2026-10-20T00:00:00Z", + contacts = listOf(saved), + linkedPeers = emptyList(), + paymentRequests = emptyList(), + receipts = emptyList(), + ) + whenever(sdk.observeState()).thenReturn(observation) + whenever(sdk.contactRecords()).thenReturn(listOf(saved)) + val paykit = PaykitSdkService(mock(), mock(), mock(), settingsStore = mock()) { sdk } + val service = PubkyService(paykit) + val deferred = CompletableDeferred() + whenever(pubkyService.observedContactRecords(VALID_SELF_KEY)).doSuspendableAnswer { + runSuspendCatching { service.observedContactRecords(VALID_SELF_KEY) } + .onFailure { deferred.complete(it) } + .getOrThrow() + } + val endReview = paykit.beginPaymentReview() + val refresh = async { sut.refreshContacts() } + try { + assertSame(PaykitPaymentRequestError.OperationInProgress, deferred.await().cause) + runCurrent() + + assertFalse(refresh.isCompleted) + assertEquals(version, sut.contactsLoadVersion.value) + assertEquals(completionVersion, sut.contactsLoadCompletionVersion.value) + assertEquals(listOf(VALID_CONTACT_KEY_A), sut.contacts.value.map { it.publicKey }) + verify(sdk, never()).observeState() + assertEquals(listOf(saved), service.contactRecords()) + + endReview() + advanceTimeBy(500.milliseconds) + runCurrent() + + assertTrue(refresh.await()) + assertEquals(version + 1, sut.contactsLoadVersion.value) + assertEquals(completionVersion + 1, sut.contactsLoadCompletionVersion.value) + assertFalse(sut.isLoadingContacts.value) + verify(sdk).observeState() + } finally { + endReview() + refresh.cancelAndJoin() + } + } + + @Test + fun `deferred contact load stops after cancellation or sign out`() = test { + listOf("cancel", "sign out").forEachCase({ it }) { action -> + resetForCase() + authenticateForTesting(publicKey = VALID_SELF_KEY) + val version = sut.contactsLoadVersion.value + val completionVersion = sut.contactsLoadCompletionVersion.value + whenever(pubkyService.observedContactRecords(VALID_SELF_KEY)).doSuspendableAnswer { + throw PaykitPaymentRequestError.OperationInProgress + } + clearInvocations(pubkyService) + val load = launch { sut.loadContacts() } + try { + runCurrent() + assertFalse(load.isCompleted) + assertEquals(version, sut.contactsLoadVersion.value) + assertEquals(completionVersion, sut.contactsLoadCompletionVersion.value) + + when (action) { + "cancel" -> load.cancel() + "sign out" -> sut.signOut().getOrThrow() + } + advanceUntilIdle() + load.join() + + verify(pubkyService).observedContactRecords(VALID_SELF_KEY) + assertFalse(sut.isLoadingContacts.value) + assertEquals(if (action == "cancel") version else 0L, sut.contactsLoadVersion.value) + assertEquals(if (action == "cancel") completionVersion else 0L, sut.contactsLoadCompletionVersion.value) + } finally { + load.cancelAndJoin() + } + } + } + @Test fun `refreshContacts coalesces overlapping reads and refreshes warm contacts`() = test { authenticateForTesting(publicKey = VALID_SELF_KEY) diff --git a/app/src/test/java/to/bitkit/ui/screens/contacts/ContactDetailViewModelTest.kt b/app/src/test/java/to/bitkit/ui/screens/contacts/ContactDetailViewModelTest.kt index 9cc61274d9..f28e483952 100644 --- a/app/src/test/java/to/bitkit/ui/screens/contacts/ContactDetailViewModelTest.kt +++ b/app/src/test/java/to/bitkit/ui/screens/contacts/ContactDetailViewModelTest.kt @@ -6,9 +6,16 @@ import app.cash.turbine.test import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.Job +import kotlinx.coroutines.NonCancellable import kotlinx.coroutines.awaitCancellation +import kotlinx.coroutines.currentCoroutineContext +import kotlinx.coroutines.ensureActive import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.job +import kotlinx.coroutines.test.advanceTimeBy import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.withContext import org.junit.Test import org.mockito.kotlin.any import org.mockito.kotlin.anyOrNull @@ -582,30 +589,46 @@ class ContactDetailViewModelTest : BaseUnitTest() { } @Test - fun `pay tap cancels a stalled eligibility check before paying`() = test { - var isCheckCancelled = false - var wasCheckCancelledBeforePayment = false + fun `pay tap times out eligibility without waiting for a native call`() = test { + val checkStarted = CompletableDeferred() + val finishNativeCall = CompletableDeferred() whenever(pubkyRepo.contacts).thenReturn(MutableStateFlow(listOf(createContact()))) whenever(paykitPaymentRequestRepo.refreshEligibleTarget(TEST_PUBLIC_KEY)).doSuspendableAnswer { - try { - awaitCancellation() - } finally { - isCheckCancelled = true - } - } - whenever(privatePaykitRepo.beginSavedContactPayment(TEST_PUBLIC_KEY)).doSuspendableAnswer { - wasCheckCancelledBeforePayment = isCheckCancelled - Result.success(openedPayment) + checkStarted.complete(currentCoroutineContext().job) + withContext(NonCancellable) { finishNativeCall.await() } + currentCoroutineContext().ensureActive() + Result.success(targetCheck(target)) } + whenever(privatePaykitRepo.beginSavedContactPayment(TEST_PUBLIC_KEY)) + .thenReturn(Result.success(openedPayment)) val sut = createSut() + val check = checkStarted.await() - sut.effects.test { - sut.onClickPay() - advanceUntilIdle() + try { + sut.effects.test { + sut.onClickPay() + advanceTimeBy(2.seconds) - assertIs(awaitItem()) - assertTrue(wasCheckCancelledBeforePayment) - assertFalse(sut.uiState.value.showRequestOrPaySheet) + verify(privatePaykitRepo, never()).beginSavedContactPayment(any()) + assertTrue(sut.uiState.value.isPayLoading) + runCurrent() + + assertTrue(check.isCancelled) + assertFalse(check.isCompleted) + verify(privatePaykitRepo).beginSavedContactPayment(TEST_PUBLIC_KEY) + assertIs(awaitItem()) + assertFalse(sut.uiState.value.showRequestOrPaySheet) + + finishNativeCall.complete(Unit) + runCurrent() + + assertTrue(check.isCompleted) + assertFalse(sut.uiState.value.showRequestOrPaySheet) + expectNoEvents() + } + } finally { + finishNativeCall.complete(Unit) + runCurrent() } } diff --git a/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt b/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt index 122a635b2f..d6b766f53f 100644 --- a/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt +++ b/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt @@ -529,6 +529,82 @@ class AppViewModelSendFlowTest : BaseUnitTest() { } } + @Test + fun `automatic proof reconciliation waits for payment completion without losing the refresh`() = test { + enablePaykitUi() + pubkyPublicKey.value = testPublicKey + whenever(paykitPaymentRequestRepo.refresh(any())).thenReturn(Result.success(Unit)) + runCurrent() + clearInvocations(paykitPaymentRequestRepo, paykitPaymentProofRepo) + paymentSubmissionActive.value = true + + sut.startPaykitPaymentRequestPolling() + try { + advanceTimeBy(30.seconds.inWholeMilliseconds) + runCurrent() + verify(paykitPaymentProofRepo, never()).reconcile() + verify(paykitPaymentRequestRepo, never()).refresh(any()) + + paymentSubmissionActive.value = false + runCurrent() + verify(paykitPaymentProofRepo).reconcile() + verify(paykitPaymentRequestRepo).refresh(PaykitPaymentRequestRefreshMode.FULL) + } finally { + sut.stopPaykitPaymentRequestPolling() + } + } + + @Test + fun `stopping polling cancels deferred reconciliation and a later start recovers it`() = test { + enablePaykitUi() + pubkyPublicKey.value = testPublicKey + whenever(paykitPaymentRequestRepo.refresh(any())).thenReturn(Result.success(Unit)) + runCurrent() + clearInvocations(paykitPaymentRequestRepo, paykitPaymentProofRepo) + paymentSubmissionActive.value = true + sut.startPaykitPaymentRequestPolling() + runCurrent() + + sut.stopPaykitPaymentRequestPolling() + paymentSubmissionActive.value = false + runCurrent() + verify(paykitPaymentProofRepo, never()).reconcile() + verify(paykitPaymentRequestRepo, never()).refresh(any()) + + sut.startPaykitPaymentRequestPolling() + try { + runCurrent() + verify(paykitPaymentProofRepo).reconcile() + verify(paykitPaymentRequestRepo).refresh(PaykitPaymentRequestRefreshMode.FULL) + } finally { + sut.stopPaykitPaymentRequestPolling() + } + } + + @Test + fun `deferred reconciliation rejects a replaced session for the same identity`() = test { + enablePaykitUi() + pubkyPublicKey.value = testPublicKey + whenever(paykitPaymentRequestRepo.refresh(any())).thenReturn(Result.success(Unit)) + runCurrent() + var signIn = PubkySignIn(testPublicKey, 0L) + whenever(pubkyRepo.currentSignIn()).thenAnswer { signIn } + whenever(pubkyRepo.isCurrent(any())).thenAnswer { it.getArgument(0) == signIn } + clearInvocations(paykitPaymentRequestRepo, paykitPaymentProofRepo) + paymentSubmissionActive.value = true + sut.startPaykitPaymentRequestPolling() + try { + runCurrent() + signIn = PubkySignIn(testPublicKey, 1L) + paymentSubmissionActive.value = false + runCurrent() + verify(paykitPaymentProofRepo, never()).reconcile() + verify(paykitPaymentRequestRepo, never()).refresh(any()) + } finally { + sut.stopPaykitPaymentRequestPolling() + } + } + @Test fun `current toast is hidden only when dev mode and disable all toasts are both on`() = test { val toast = Toast(Toast.ToastType.SUCCESS, "Toast", null, true, Toast.VISIBILITY_TIME_DEFAULT, null) diff --git a/journeys/send/prepared-fee-recovery.md b/journeys/send/prepared-fee-recovery.md index e5a691a7e1..cfecb66790 100644 --- a/journeys/send/prepared-fee-recovery.md +++ b/journeys/send/prepared-fee-recovery.md @@ -19,6 +19,8 @@ Use the retained original payment from the same controlled fixture. Keep transac With no active payment and the SDK initially idle, verify that passive refresh is deferred during each visible fee, abandon or payment-authentication review. A biometric overlay that leaves the app visible must retain this protection. Backgrounding, dismissing or cancelling the review releases its hold; an idle Pending screen must still allow passive refresh and recovery observation. Use the focused admission tests or a sanitized SDK operation trace for these checks: waiting on a screen alone cannot establish SDK admission or a latency improvement. Work already inside the SDK may finish. +For an automatic full refresh that starts during a software payment, verify proof reconciliation waits until the payment finishes and then resumes. Cancelling polling must cancel the wait; restarting polling must still recover retained work. A wait from a replaced sign-in session must not reconcile for the replacement session. This entry check does not preempt a reconciliation pass admitted before payment started, and explicit payment recovery keeps its existing authority checks. + ## Platform adaptations Android retains candidate fees for original sends and replacements in `candidateMiningFees`. iOS already retains the original prepared fee; `candidateFeeSats` covers successors/replacements. Run the same four cases on both platforms. Use each platform's normal identity-selection flow; iOS Max recovery keeps the original rate without an editable field. The common UI actions are in [onchain-original-payment-retry.xml](onchain-original-payment-retry.xml). From 546873f229c888694d83e0a5bc268cd729b82d7d Mon Sep 17 00:00:00 2001 From: benk10 Date: Tue, 20 Oct 2026 09:39:55 -0600 Subject: [PATCH 11/13] fix: cancel recovery pin and skip needless identity reads --- app/build.gradle.kts | 1 + .../repositories/PaykitPaymentProofRepo.kt | 3 + .../wallets/send/SendPinCheckScreen.kt | 3 + .../PaykitPaymentProofRepoTest.kt | 55 ++++++- .../wallets/send/SendPendingViewModelTest.kt | 131 ++++++++++++++-- .../wallets/send/SendPinCheckScreenTest.kt | 145 ++++++++++++++++++ .../send/onchain-original-payment-retry.xml | 2 +- journeys/send/prepared-fee-recovery.md | 1 + 8 files changed, 327 insertions(+), 14 deletions(-) create mode 100644 app/src/test/java/to/bitkit/ui/screens/wallets/send/SendPinCheckScreenTest.kt diff --git a/app/build.gradle.kts b/app/build.gradle.kts index d030e04ad5..78420f412a 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -589,6 +589,7 @@ dependencies { debugImplementation(libs.compose.ui.tooling) debugImplementation(libs.compose.ui.test.manifest) androidTestImplementation(libs.compose.ui.test.junit4) + testImplementation(libs.compose.ui.test.junit4) implementation(libs.accompanist.pager.indicators) implementation(libs.accompanist.permissions) implementation(libs.constraintlayout.compose) diff --git a/app/src/main/java/to/bitkit/repositories/PaykitPaymentProofRepo.kt b/app/src/main/java/to/bitkit/repositories/PaykitPaymentProofRepo.kt index f516f4ae16..3b6dc5fbc7 100644 --- a/app/src/main/java/to/bitkit/repositories/PaykitPaymentProofRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/PaykitPaymentProofRepo.kt @@ -856,6 +856,9 @@ class PaykitPaymentProofRepo @Inject constructor( suspend fun retainedHardwareOnchainRequest(request: PaykitPaymentRequest): PendingPaykitPaymentProof? = withContext(ioDispatcher) { operationMutex.withLock { + if (loadProofs().none { it.requestId == request.id && it.hardwareSignedTransaction != null }) { + return@withLock null + } val identity = currentIdentity() ?: return@withLock null loadProofs().singleOrNull { it.isReopenableHardwareReceipt(identity) && it.matchesHardwareRetryRequest(request) diff --git a/app/src/main/java/to/bitkit/ui/screens/wallets/send/SendPinCheckScreen.kt b/app/src/main/java/to/bitkit/ui/screens/wallets/send/SendPinCheckScreen.kt index 40303cef7d..35a9096750 100644 --- a/app/src/main/java/to/bitkit/ui/screens/wallets/send/SendPinCheckScreen.kt +++ b/app/src/main/java/to/bitkit/ui/screens/wallets/send/SendPinCheckScreen.kt @@ -1,5 +1,6 @@ package to.bitkit.ui.screens.wallets.send +import androidx.activity.compose.BackHandler import androidx.compose.animation.AnimatedVisibility import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Spacer @@ -48,6 +49,8 @@ fun SendPinCheckScreen( val attemptsRemaining by app.pinAttemptsRemaining.collectAsStateWithLifecycle() var pin by remember { mutableStateOf("") } + BackHandler(onBack = onBack) + LaunchedEffect(pin) { if (pin.length == Env.PIN_LENGTH) { if (app.validatePin(pin)) { diff --git a/app/src/test/java/to/bitkit/repositories/PaykitPaymentProofRepoTest.kt b/app/src/test/java/to/bitkit/repositories/PaykitPaymentProofRepoTest.kt index b0d592551b..15b7378db8 100644 --- a/app/src/test/java/to/bitkit/repositories/PaykitPaymentProofRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/PaykitPaymentProofRepoTest.kt @@ -3082,6 +3082,40 @@ class PaykitPaymentProofRepoTest : BaseUnitTest(StandardTestDispatcher()) { runCurrent() } + @Test + fun `hardware retry lookup skips identity when no signed receipt belongs to the request`() = test { + val request = paymentRequest(MethodId.P2wpkh.rawValue) + val softwareProof = PendingPaykitPaymentProof( + identity = LOCAL_IDENTITY, + requestId = request.id, + paymentEndpointIdentifier = MethodId.P2wpkh.rawValue, + paymentAppId = "bitkit", + kind = PaykitPaymentProofKind.Onchain, + paymentStarted = true, + onchainWalletId = WalletScope.default, + ) + val unrelated = softwareProof.copy( + requestId = request.id.copy(paymentRequestId = "other-request"), + hardwareSignedTransaction = "retained", + ) + val otherPeriod = unrelated.copy( + requestId = request.id.copy(billingPeriodStartsAt = "2030-01-01T00:00:00Z"), + ) + whenever(paykitSdkService.identityStatus()) + .doSuspendableAnswer { throw PaykitException.SharedStateBusy("shared_state_busy", "Locked") } + val repo = paymentProofRepo() + + for (proofs in listOf(emptyList(), listOf(softwareProof), listOf(unrelated, otherPeriod, softwareProof))) { + storedProofs = proofs + assertNull(repo.retainedHardwareOnchainRequest(request)) + assertEquals(proofs, storedProofs) + } + shouldFailNextLoad = true + assertFailsWith { repo.retainedHardwareOnchainRequest(request) } + verify(paykitSdkService, never()).identityStatus() + verify(store, never()).save(any()) + } + @Test fun `retained refused hardware reopen preserves exact receipt and blocks new preparation`() = test { val method = MethodId.P2wpkh.rawValue @@ -3134,10 +3168,23 @@ class PaykitPaymentProofRepoTest : BaseUnitTest(StandardTestDispatcher()) { assertNull(reopened.retainedHardwareOnchainRequest(request)) assertTrue(reopened.hasRetainedHardwareOnchainPayment(proof.onchainWalletId)) storedProofs = listOf(proof) - whenever(paykitSdkService.identityStatus()) - .thenReturn(IdentityStatus(COUNTERPARTY, PubkyIdentityCapability.PRIVATE_LINK_CAPABLE)) - assertNull(reopened.retainedHardwareOnchainRequest(request)) - assertEquals(listOf(proof), storedProofs) + for (removeReceipt in listOf(false, true)) { + val identityRead = CompletableDeferred() + whenever(paykitSdkService.identityStatus()).doSuspendableAnswer { identityRead.await() } + val lookup = async { reopened.retainedHardwareOnchainRequest(request) } + runCurrent() + assertFalse(lookup.isCompleted) + if (removeReceipt) storedProofs = emptyList() + identityRead.complete( + IdentityStatus( + if (removeReceipt) LOCAL_IDENTITY else COUNTERPARTY, + PubkyIdentityCapability.PRIVATE_LINK_CAPABLE, + ), + ) + assertNull(lookup.await()) + assertEquals(if (removeReceipt) emptyList() else listOf(proof), storedProofs) + storedProofs = listOf(proof) + } verify(hwWalletRepo, never()).broadcastFunding(any(), org.mockito.kotlin.anyOrNull()) verify( paykitSdkService, 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 7a4f7a6876..e4f63a19e7 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 @@ -18,6 +18,7 @@ import org.junit.Before import org.junit.Test import org.mockito.kotlin.any import org.mockito.kotlin.doReturn +import org.mockito.kotlin.doSuspendableAnswer import org.mockito.kotlin.eq import org.mockito.kotlin.mock import org.mockito.kotlin.verify @@ -117,21 +118,133 @@ class SendPendingViewModelTest : BaseUnitTest() { feeRateSatsPerVByte = 10uL, miningFeeSats = 600uL ) - for (cancelCaller in listOf(false, true)) { - val authorization = launch { - sut.approveRecoveryFee(receipt, emptyList(), requiresPin = true, useBiometrics = true) + for (usePin in listOf(false, true)) { + for (cancelCaller in listOf(false, true)) { + val authorization = launch { + sut.approveRecoveryFee(receipt, emptyList(), requiresPin = true, useBiometrics = true) + } + runCurrent() + assertEquals(RecoveryApproval.Fee(receipt), sut.uiState.value.recoveryApproval) + if (usePin) { + sut.answerRecoveryApproval(true) + runCurrent() + sut.useRecoveryPin() + assertTrue(sut.openRecoveryPin()) + } + if (cancelCaller) { + authorization.cancelAndJoin() + } else { + if (usePin) sut.answerRecoveryPin(false) else sut.answerRecoveryApproval(false) + runCurrent() + } + assertTrue(authorization.isCancelled) + assertNull(sut.uiState.value.recoveryApproval) } + } + } + + @Test + fun `declined recovery pin preserves the original and allows one authorized retry`() = test { + val original = pendingOriginal() + val receipt = OnchainPreparedReceipt( + requireNotNull(original.txid), + emptyList(), + original.address, + original.amountSats, + feeRateSatsPerVByte = 2uL, + miningFeeSats = 600uL, + ) + whenever(lightningRepo.currentOnchainSendAttempt()).thenReturn(original) + sut.initOnchain(original.txid, amount) + runCurrent() + var preparations = 0 + var sends = 0 + val retry: suspend (OnchainSendAttempt, ULong) -> Result = { attempt, _ -> + assertEquals(original, attempt) + preparations++ + sut.approveRecoveryFee(receipt, emptyList(), requiresPin = true, useBiometrics = true) + sends++ + Result.success(OnchainSendOutcome.Unknown(requireNotNull(original.txid))) + } + + for (approved in listOf(false, true)) { + sut.retryOriginal(2uL, retry) + sut.retryOriginal(2uL, retry) runCurrent() assertEquals(RecoveryApproval.Fee(receipt), sut.uiState.value.recoveryApproval) - if (cancelCaller) { - authorization.cancelAndJoin() - } else { - sut.answerRecoveryApproval(false) - runCurrent() + sut.answerRecoveryApproval(true) + runCurrent() + assertEquals(RecoveryApproval.Biometrics, sut.uiState.value.recoveryApproval) + sut.useRecoveryPin() + assertTrue(sut.openRecoveryPin()) + sut.answerRecoveryPin(approved) + runCurrent() + sut.answerRecoveryPin(true) + runCurrent() + + assertNull(sut.uiState.value.recoveryApproval) + assertEquals(false, sut.uiState.value.isRecovering) + assertEquals(original, sut.uiState.value.recoveryAttempt) + assertEquals(if (approved) original.txid else null, sut.uiState.value.currentTxid) + assertNull(sut.uiState.value.recoveredTxid) + assertEquals(if (approved) 1 else 0, sends) + } + assertEquals(2, preparations) + } + + @Test + fun `declined recovery pin preserves expired payment until a later authorized release`() = test { + val original = pendingOriginal().copy( + requestId = PaykitPaymentRequestId("request", "payee"), + orderId = "order", + payerIdentity = "payer", + ) + val receipt = OnchainPreparedReceipt( + "cd".repeat(32), + emptyList(), + "release-address", + original.amountSats, + feeRateSatsPerVByte = 2uL, + miningFeeSats = 600uL, + ) + whenever(lightningRepo.currentOnchainSendAttempt()).thenReturn(original) + whenever(paykitPaymentProofRepo.onchainRequestHasEnded(original)).thenReturn(true) + var preparations = 0 + var releases = 0 + whenever(paykitPaymentProofRepo.abandonUnbroadcastOnchainPayment(any(), any(), any())) + .doSuspendableAnswer { + assertEquals(original, it.getArgument(0)) + preparations++ + it.getArgument Unit>(2)(receipt) + releases++ + Result.success(Unit) } - assertTrue(authorization.isCancelled) + sut.initOnchain(original.txid, amount) + runCurrent() + + for (approved in listOf(false, true)) { + sut.abandonExpired(requiresPin = true, useBiometrics = true) + sut.abandonExpired(requiresPin = true, useBiometrics = true) + runCurrent() + assertEquals(RecoveryApproval.Release(receipt), sut.uiState.value.recoveryApproval) + sut.answerRecoveryApproval(true) + runCurrent() + assertEquals(RecoveryApproval.Biometrics, sut.uiState.value.recoveryApproval) + sut.useRecoveryPin() + assertTrue(sut.openRecoveryPin()) + sut.answerRecoveryPin(approved) + runCurrent() + sut.answerRecoveryPin(true) + runCurrent() + assertNull(sut.uiState.value.recoveryApproval) + assertEquals(false, sut.uiState.value.isAbandoning) + assertEquals(approved, sut.uiState.value.abandoned) + assertEquals(if (approved) null else original, sut.uiState.value.recoveryAttempt) + assertEquals(if (approved) null else original, sut.uiState.value.abandonableAttempt) + assertEquals(if (approved) 1 else 0, releases) } + assertEquals(2, preparations) } @Test diff --git a/app/src/test/java/to/bitkit/ui/screens/wallets/send/SendPinCheckScreenTest.kt b/app/src/test/java/to/bitkit/ui/screens/wallets/send/SendPinCheckScreenTest.kt new file mode 100644 index 0000000000..42e875b101 --- /dev/null +++ b/app/src/test/java/to/bitkit/ui/screens/wallets/send/SendPinCheckScreenTest.kt @@ -0,0 +1,145 @@ +package to.bitkit.ui.screens.wallets.send + +import android.app.Application +import androidx.activity.ComponentActivity +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.ui.test.junit4.v2.createAndroidComposeRule +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.performClick +import androidx.lifecycle.Lifecycle +import androidx.navigation.NavDestination.Companion.hasRoute +import androidx.navigation.NavHostController +import androidx.navigation.compose.NavHost +import androidx.navigation.compose.composable +import androidx.navigation.compose.rememberNavController +import kotlinx.coroutines.flow.MutableStateFlow +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.kotlin.any +import org.mockito.kotlin.mock +import org.mockito.kotlin.never +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import org.robolectric.shadows.ShadowSystemClock +import to.bitkit.env.Env +import to.bitkit.ui.LocalAppViewModel +import to.bitkit.ui.navigateTo +import to.bitkit.ui.sheets.SendRoute +import to.bitkit.ui.theme.AppThemeSurface +import to.bitkit.viewmodels.AppViewModel +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue +import kotlin.time.Duration.Companion.seconds +import kotlin.time.toJavaDuration + +@Config(application = Application::class, sdk = [34]) +@RunWith(RobolectricTestRunner::class) +class SendPinCheckScreenTest { + @get:Rule + val composeRule = createAndroidComposeRule() + + @Test + fun `system and header back decline recovery and ordinary pin checks once`() { + val app: AppViewModel = mock() + whenever(app.pinAttemptsRemaining).thenReturn(MutableStateFlow(Env.PIN_ATTEMPTS)) + lateinit var navController: NavHostController + var cancellations = 0 + var successes = 0 + val pending = SendRoute.Pending(paymentHash = "payment", amount = 1000, walletId = "wallet0") + composeRule.setContent { + AppThemeSurface { + CompositionLocalProvider(LocalAppViewModel provides app) { + navController = rememberNavController() + NavHost(navController, startDestination = pending) { + composable {} + composable { + SendPinCheckScreen( + onBack = { + cancellations++ + navController.previousBackStackEntry?.savedStateHandle + ?.set(RECOVERY_PIN_CHECK_RESULT_KEY, false) + navController.popBackStack() + }, + onSuccess = { + successes++ + navController.previousBackStackEntry?.savedStateHandle + ?.set(RECOVERY_PIN_CHECK_RESULT_KEY, true) + navController.popBackStack() + }, + ) + } + composable { + SendPinCheckScreen( + onBack = { + cancellations++ + navController.previousBackStackEntry?.savedStateHandle + ?.set(PIN_CHECK_RESULT_KEY, false) + navController.popBackStack() + }, + onSuccess = { + successes++ + navController.previousBackStackEntry?.savedStateHandle + ?.set(PIN_CHECK_RESULT_KEY, true) + navController.popBackStack() + }, + ) + } + } + } + } + } + + var expectedCancellations = 0 + for ((route, resultKey) in listOf( + SendRoute.RecoveryPinCheck to RECOVERY_PIN_CHECK_RESULT_KEY, + SendRoute.PinCheck to PIN_CHECK_RESULT_KEY, + )) { + for (systemBack in listOf(true, false)) { + composeRule.runOnIdle { navController.navigateTo(route) } + composeRule.onNodeWithTag("NavigationBack").assertExists() + composeRule.waitUntil { + navController.currentBackStackEntry?.lifecycle?.currentState == Lifecycle.State.RESUMED + } + if (systemBack) { + composeRule.runOnIdle { composeRule.activity.onBackPressedDispatcher.onBackPressed() } + } else { + ShadowSystemClock.advanceBy(1.seconds.toJavaDuration()) + composeRule.onNodeWithTag("NavigationBack").performClick() + } + expectedCancellations++ + composeRule.runOnIdle { + assertEquals( + expectedCancellations, + cancellations, + "Expected one cancellation for $route with systemBack=$systemBack", + ) + assertTrue( + navController.currentDestination?.hasRoute() == true, + "Expected Pending after $route with systemBack=$systemBack", + ) + val result = navController.currentBackStackEntry?.savedStateHandle?.remove(resultKey) + assertFalse(requireNotNull(result)) + assertEquals(0, successes) + } + } + } + verify(app, never()).validatePin(any()) + + whenever(app.validatePin("1".repeat(Env.PIN_LENGTH))).thenReturn(true) + composeRule.runOnIdle { navController.navigateTo(SendRoute.RecoveryPinCheck) } + repeat(Env.PIN_LENGTH) { composeRule.onNodeWithTag("N1").performClick() } + composeRule.runOnIdle { + assertTrue(navController.currentDestination?.hasRoute() == true) + val result = navController.currentBackStackEntry?.savedStateHandle + ?.get(RECOVERY_PIN_CHECK_RESULT_KEY) + assertEquals(true, result) + assertEquals(expectedCancellations, cancellations) + assertEquals(1, successes) + } + verify(app).validatePin("1".repeat(Env.PIN_LENGTH)) + } +} diff --git a/journeys/send/onchain-original-payment-retry.xml b/journeys/send/onchain-original-payment-retry.xml index 7d57695426..e5026c3e20 100644 --- a/journeys/send/onchain-original-payment-retry.xml +++ b/journeys/send/onchain-original-payment-retry.xml @@ -24,7 +24,7 @@ Verify the exact prepared mining fee in sats is shown beside the original recipient amount; leave this approval open for 30 seconds, then tap Cancel and verify the original Pending payment remains retained Tap Retry original payment again, choose the fixture-supported fee, tap Continue, approve the exact prepared fee and any normal fee warnings, and verify the normal payment PIN screen appears before dispatch Leave the payment PIN screen open for 30 seconds and verify it still awaits authentication - Go Back without entering the PIN and verify the original Pending payment remains retained + Use Android system Back or iOS header Back without entering the PIN; verify the original Pending payment remains retained and Retry original payment is enabled again Tap Retry original payment again, choose the fixture-supported fee, tap Continue, approve the exact prepared fee and any normal fee warnings Enter the fixture's normal payment PIN without recording entered digits Wait for Bitcoin Sent only after exact positive evidence, or verify Pending remains with a readable error if fee headroom is insufficient diff --git a/journeys/send/prepared-fee-recovery.md b/journeys/send/prepared-fee-recovery.md index cfecb66790..4807317a4a 100644 --- a/journeys/send/prepared-fee-recovery.md +++ b/journeys/send/prepared-fee-recovery.md @@ -16,6 +16,7 @@ Use the retained original payment from the same controlled fixture. Keep transac 1. Run the shared retry journey with payment PIN enabled and biometrics disabled. Check cancellation at the fee entry, exact prepared fee and PIN stages. Cancellation must not authorize a replacement or discard the original Pending payment. 2. Repeat the fee and PIN checks while backgrounding the app, returning and unlocking normally. If the review closes, reopen the retained original Pending payment; do not start a new send. Verify the original recipient and amount before any approval. Separately close the review and reopen it without backgrounding. 3. On a disposable device with enrolled biometrics, repeat retry authentication and cancel the system prompt. For an eligible expired, unbroadcast request, also cancel both the abandon confirmation and its release-fee approval, then exercise biometric cancellation. Verify cancellation neither authorizes a replacement nor releases the original inputs. Report these checks as blocked when biometric or controlled abandon fixtures are unavailable. +4. On Android, choose Use PIN code from recovery biometric authentication for both retry and an eligible expired-payment cancellation. Leave PIN with system Back, then repeat with header Back. On iOS, disable biometrics for this PIN check and use header Back; cancelling or choosing fallback in its biometric prompt cancels recovery. Verify Pending restores its recovery actions, passive refresh can resume, and no replacement or release is authorized. Start the same recovery action again and verify it requires fresh fee approval and authentication, with no duplicate dispatch. With no active payment and the SDK initially idle, verify that passive refresh is deferred during each visible fee, abandon or payment-authentication review. A biometric overlay that leaves the app visible must retain this protection. Backgrounding, dismissing or cancelling the review releases its hold; an idle Pending screen must still allow passive refresh and recovery observation. Use the focused admission tests or a sanitized SDK operation trace for these checks: waiting on a screen alone cannot establish SDK admission or a latency improvement. Work already inside the SDK may finish. From 5ced84770b60c3718597afb06c030d6a7c6a1965 Mon Sep 17 00:00:00 2001 From: benk10 Date: Tue, 20 Oct 2026 11:26:52 -0600 Subject: [PATCH 12/13] fix: use fresh Paykit reads for payment checks Adopt published Paykit 0.1.1-rc1 and Core 0.8.0-rc10. Combine adjacent identity and request reads for proof preparation and submission, and read drain scheduling state in one SDK operation. Preserve acquisition-only retry, cancellation, identity and exact-proof guards, with actual service and scheduling regression coverage. --- .../repositories/PaykitPaymentProofRepo.kt | 35 ++-- .../bitkit/repositories/PrivatePaykitRepo.kt | 17 +- .../to/bitkit/services/PaykitSdkService.kt | 21 +++ .../PaykitPaymentProofRepoTest.kt | 18 +++ .../repositories/PrivatePaykitRepoTest.kt | 76 +++++++-- .../bitkit/services/PaykitSdkServiceTest.kt | 153 ++++++++++++++++++ .../services/PaykitSdkServiceWipeTest.kt | 72 +++++---- gradle/libs.versions.toml | 4 +- 8 files changed, 333 insertions(+), 63 deletions(-) diff --git a/app/src/main/java/to/bitkit/repositories/PaykitPaymentProofRepo.kt b/app/src/main/java/to/bitkit/repositories/PaykitPaymentProofRepo.kt index 3b6dc5fbc7..64bf08fb87 100644 --- a/app/src/main/java/to/bitkit/repositories/PaykitPaymentProofRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/PaykitPaymentProofRepo.kt @@ -192,12 +192,28 @@ class PaykitPaymentProofRepo @Inject constructor( val onchainAttempt = lightningRepo.currentOnchainSendAttempt() if (onchainAttempt?.requestId == request.id) throw PaykitPaymentRequestError.OperationInProgress operationMutex.withLock { - val proof = pendingProof( - request, - paymentEndpointIdentifier, - paymentAppId, - kind - ).copy(conversionQuoteId = paymentTerms?.quoteId) + if ( + paymentAppId.isBlank() || + paymentEndpointIdentifier !in request.acceptedPaymentEndpointIdentifiers || + !endpointSupports(paymentEndpointIdentifier, kind) + ) { + throw PaykitPaymentRequestError.RequestUnavailable + } + val read = paykitSdkService.readPaymentRequests(request.counterparty) + val identityStatus = read.identityStatus + val identity = identityStatus?.publicKey?.let(PubkyPublicKeyFormat::normalized) + if (identityStatus?.capability != PubkyIdentityCapability.PRIVATE_LINK_CAPABLE || identity == null) { + throw PaykitPaymentRequestError.RequestUnavailable + } + val proof = PendingPaykitPaymentProof( + identity = identity, + requestId = request.id, + paymentEndpointIdentifier = paymentEndpointIdentifier, + paymentAppId = paymentAppId, + kind = kind, + billingPeriod = request.billingPeriod, + conversionQuoteId = paymentTerms?.quoteId, + ) val currentProofs = loadProofs() if (currentProofs.any { it.isStartedFor(proof.identity, request.id) || @@ -210,7 +226,7 @@ class PaykitPaymentProofRepo @Inject constructor( ) { throw PaykitPaymentRequestError.OperationInProgress } - val alreadyPaid = paykitSdkService.paymentRequests().any { record -> + val alreadyPaid = read.paymentRequests.any { record -> record.paymentRequestId == request.paymentRequestId && PubkyPublicKeyFormat.matches(record.counterparty, request.counterparty) && record.paymentProofs.any { it.billingPeriod.matches(request.billingPeriod) } @@ -1397,7 +1413,8 @@ class PaykitPaymentProofRepo @Inject constructor( if (proof.paymentAppId.isBlank()) return false if (proof.kind == PaykitPaymentProofKind.Onchain && proof.onchainAcceptanceVerified != true) return false val proofData = proof.proofData ?: return false - val identityStatus = paykitSdkService.identityStatus() + val read = paykitSdkService.readPaymentRequests(proof.requestId.counterparty, expectedIdentity = proof.identity) + val identityStatus = read.identityStatus if ( identityStatus?.capability != PubkyIdentityCapability.PRIVATE_LINK_CAPABLE || !PubkyPublicKeyFormat.matches(identityStatus.publicKey, proof.identity) @@ -1405,7 +1422,7 @@ class PaykitPaymentProofRepo @Inject constructor( return false } - val record = paykitSdkService.paymentRequests().firstOrNull { + val record = read.paymentRequests.firstOrNull { it.paymentRequestId == proof.requestId.paymentRequestId && PubkyPublicKeyFormat.matches(it.counterparty, proof.requestId.counterparty) } ?: return false diff --git a/app/src/main/java/to/bitkit/repositories/PrivatePaykitRepo.kt b/app/src/main/java/to/bitkit/repositories/PrivatePaykitRepo.kt index 3670e42bba..fb5f028b60 100644 --- a/app/src/main/java/to/bitkit/repositories/PrivatePaykitRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/PrivatePaykitRepo.kt @@ -1645,26 +1645,21 @@ class PrivatePaykitRepo @Inject constructor( private suspend fun readPrivateMessageDrainState(priority: suspend () -> Priority): PrivateMessageDrainState? { val generation = preparationGeneration - val linkedPeers = runSuspendCatching { paykitSdkService.linkedPeers(priority()) } + val snapshot = runSuspendCatching { paykitSdkService.privateMessageSchedulingSnapshot(priority()) } .getOrElse { - Logger.warn("Failed to inspect private Paykit link state", it, context = TAG) + Logger.warn("Failed to inspect private Paykit scheduling state", it, context = TAG) return null } + priority() + if (generation != preparationGeneration) return null + val linkedPeers = snapshot.linkedPeers .mapNotNull { peer -> normalizedPublicKey(peer.counterparty)?.let { publicKey -> publicKey to peer.state } } .toMap() - val pendingPriority = priority() - if (generation != preparationGeneration) return null - val pendingOutbound = runSuspendCatching { - paykitSdkService.pendingOutboundPrivateCounterparties(pendingPriority) - } - .getOrElse { - Logger.warn("Failed to inspect pending private Paykit messages", it, context = TAG) - return null - } + val pendingOutbound = snapshot.pendingOutboundCounterparties .mapNotNull(::normalizedPublicKey) .toSet() diff --git a/app/src/main/java/to/bitkit/services/PaykitSdkService.kt b/app/src/main/java/to/bitkit/services/PaykitSdkService.kt index aecf32d4dc..5e5f68503f 100644 --- a/app/src/main/java/to/bitkit/services/PaykitSdkService.kt +++ b/app/src/main/java/to/bitkit/services/PaykitSdkService.kt @@ -29,12 +29,14 @@ import com.synonym.paykit.PaymentRequestAmount import com.synonym.paykit.PaymentRequestFilter import com.synonym.paykit.PaymentRequestLifecycleState import com.synonym.paykit.PaymentRequestLocalRole +import com.synonym.paykit.PaymentRequestRead import com.synonym.paykit.PaymentRequestRecord import com.synonym.paykit.PaymentRequestRecurrence import com.synonym.paykit.PaymentRequestTerms import com.synonym.paykit.PaymentTarget import com.synonym.paykit.PrivateContactPaymentResolution import com.synonym.paykit.PrivateJsonObject +import com.synonym.paykit.PrivateMessageSchedulingSnapshot import com.synonym.paykit.PrivatePaymentEndpointCandidate import com.synonym.paykit.PrivatePaymentEndpointReservationCancellation import com.synonym.paykit.PrivatePaymentEndpointSelectionRequest @@ -1014,6 +1016,16 @@ class PaykitSdkService @Inject constructor( suspend fun paymentRequests(): List = allPaymentRequests().filter(::isBitkitPaymentRequest) + suspend fun readPaymentRequests(counterparty: String, expectedIdentity: String? = null): PaymentRequestRead { + isSetup.await() + return measuredOperation("readPaymentRequests") { + withPaykitKey { handle -> + val read = retryContendedRead { handle.readPaymentRequests(counterparty, expectedIdentity) } + read.copy(paymentRequests = read.paymentRequests.filter(::isBitkitPaymentRequest)) + } + } + } + /** Refreshes display projections only; payment authority remains with the action APIs. */ internal suspend fun observeState( expectedIdentity: String, @@ -1222,6 +1234,15 @@ class PaykitSdkService @Inject constructor( } } + internal suspend fun privateMessageSchedulingSnapshot( + priority: Priority = Priority.Ordered, + ): PrivateMessageSchedulingSnapshot { + isSetup.await() + return operationLock.withLock(priority) { + withPaykitKey { handle -> retryContendedRead { handle.privateMessageSchedulingSnapshot() } } + } + } + suspend fun pendingOutboundPrivateCounterparties(): List = pendingOutboundPrivateCounterparties(Priority.Ordered) diff --git a/app/src/test/java/to/bitkit/repositories/PaykitPaymentProofRepoTest.kt b/app/src/test/java/to/bitkit/repositories/PaykitPaymentProofRepoTest.kt index 15b7378db8..f853c796f7 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.PaymentReference import com.synonym.paykit.PaymentRequestAmount import com.synonym.paykit.PaymentRequestLifecycleState import com.synonym.paykit.PaymentRequestLocalRole +import com.synonym.paykit.PaymentRequestRead import com.synonym.paykit.PaymentRequestRecord import com.synonym.paykit.PaymentRequestRecurrence import com.synonym.paykit.PaymentRequestTerms @@ -69,6 +70,7 @@ import to.bitkit.models.PaykitAmount import to.bitkit.models.PaykitAsset import to.bitkit.models.PaykitPaymentStateBackup import to.bitkit.models.PaykitRequestPricing +import to.bitkit.models.PubkyPublicKeyFormat import to.bitkit.models.WalletScope import to.bitkit.services.CoreService import to.bitkit.services.PaykitSdkOperationLock @@ -137,6 +139,22 @@ class PaykitPaymentProofRepoTest : BaseUnitTest(StandardTestDispatcher()) { ).thenReturn(IdentityStatus(LOCAL_IDENTITY, PubkyIdentityCapability.PRIVATE_LINK_CAPABLE)) whenever(paykitSdkService.processPendingPrivateMessages()).thenReturn(emptyList()) whenever(paykitSdkService.paymentRequests()).thenReturn(emptyList()) + doSuspendableAnswer { invocation -> + val counterparty = invocation.getArgument(0) + val expectedIdentity = invocation.getArgument(1) + val status = paykitSdkService.identityStatus() + val records = if ( + status?.capability == PubkyIdentityCapability.PRIVATE_LINK_CAPABLE && + (expectedIdentity == null || PubkyPublicKeyFormat.matches(status.publicKey, expectedIdentity)) + ) { + paykitSdkService.paymentRequests().filter { + PubkyPublicKeyFormat.matches(it.counterparty, counterparty) + } + } else { + emptyList() + } + PaymentRequestRead(status, records) + }.whenever(paykitSdkService).readPaymentRequests(any(), org.mockito.kotlin.anyOrNull()) whenever(store.load()).thenAnswer { if (shouldFailNextLoad) { shouldFailNextLoad = false diff --git a/app/src/test/java/to/bitkit/repositories/PrivatePaykitRepoTest.kt b/app/src/test/java/to/bitkit/repositories/PrivatePaykitRepoTest.kt index cc8fb4a296..5e0aa48fea 100644 --- a/app/src/test/java/to/bitkit/repositories/PrivatePaykitRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/PrivatePaykitRepoTest.kt @@ -14,6 +14,7 @@ import com.synonym.paykit.LinkedPeerState import com.synonym.paykit.OutboundPrivateSendReport import com.synonym.paykit.PaykitException import com.synonym.paykit.PaymentRequestLifecycleState +import com.synonym.paykit.PrivateMessageSchedulingSnapshot import com.synonym.paykit.PrivatePaymentListDeliveryFailure import com.synonym.paykit.PrivatePaymentListDeliveryReport import com.synonym.paykit.PrivatePaymentListReservationUpdateInput @@ -165,6 +166,13 @@ class PrivatePaykitRepoTest : BaseUnitTest(StandardTestDispatcher()) { whenever(paykitSdkService.pendingOutboundPrivateCounterparties(any())).doSuspendableAnswer { paykitSdkService.pendingOutboundPrivateCounterparties() } + doSuspendableAnswer { + val priority = it.getArgument(0) + PrivateMessageSchedulingSnapshot( + paykitSdkService.linkedPeers(priority), + paykitSdkService.pendingOutboundPrivateCounterparties(priority), + ) + }.whenever(paykitSdkService).privateMessageSchedulingSnapshot(any()) whenever(paykitSdkService.processOutboundPrivateMessages(any(), any())).doSuspendableAnswer { paykitSdkService.processOutboundPrivateMessages(it.getArgument(0)) } @@ -1028,6 +1036,9 @@ class PrivatePaykitRepoTest : BaseUnitTest(StandardTestDispatcher()) { val pending = mutableSetOf() whenever(paykitSdkService.linkedPeers()).thenAnswer { states.map { linkedPeer(it.key, it.value) } } whenever(paykitSdkService.pendingOutboundPrivateCounterparties()).thenAnswer { pending.toList() } + doSuspendableAnswer { + PrivateMessageSchedulingSnapshot(states.map { linkedPeer(it.key, it.value) }, pending.toList()) + }.whenever(paykitSdkService).privateMessageSchedulingSnapshot(any()) sut.prepareSavedContacts(keys).getOrThrow() clearInvocations(paykitSdkService) whenever(paykitSdkService.ensureLinkWithPeer(CONTACT_KEY)).thenAnswer { @@ -1043,8 +1054,9 @@ class PrivatePaykitRepoTest : BaseUnitTest(StandardTestDispatcher()) { try { advanceTimeBy(1_000) runCurrent() - verify(paykitSdkService, times(6)).linkedPeers(Priority.Background) - verify(paykitSdkService, times(4)).pendingOutboundPrivateCounterparties(Priority.Background) + verify(paykitSdkService, times(2)).privateMessageSchedulingSnapshot(Priority.Background) + verify(paykitSdkService, times(4)).linkedPeers(Priority.Background) + verify(paykitSdkService, times(2)).pendingOutboundPrivateCounterparties(Priority.Background) verify(paykitSdkService).processOutboundPrivateMessages(CONTACT_KEY, Priority.Background) verify(paykitSdkService).receivePrivateMessages(CONTACT_KEY, Priority.Background) verify(paykitSdkService, never()).receivePrivateMessages(OTHER_CONTACT_KEY, Priority.Background) @@ -1066,6 +1078,36 @@ class PrivatePaykitRepoTest : BaseUnitTest(StandardTestDispatcher()) { } } + @Test + fun `failed scheduling snapshot retains retry intent until a fresh read succeeds`() = test { + settingsData.value = SettingsData(sharesPrivatePaykitEndpoints = false) + whenever(paykitSdkService.linkedPeers()).thenReturn(listOf(linkedPeer(CONTACT_KEY, LinkedPeerState.LINKING))) + sut.prepareSavedContacts(listOf(CONTACT_KEY)).getOrThrow() + whenever(paykitSdkService.linkedPeers()).thenReturn(listOf(linkedPeer(CONTACT_KEY, LinkedPeerState.LINKED))) + doSuspendableAnswer { + throw IllegalStateException("Scheduling unavailable") + }.whenever(paykitSdkService).privateMessageSchedulingSnapshot(any()) + try { + advanceTimeBy(1_000) + runCurrent() + clearInvocations(paykitSdkService) + advanceTimeBy(3_000) + runCurrent() + verify(paykitSdkService, atLeast(1)).privateMessageSchedulingSnapshot(Priority.Background) + doSuspendableAnswer { + PrivateMessageSchedulingSnapshot(listOf(linkedPeer(CONTACT_KEY, LinkedPeerState.LINKED)), emptyList()) + }.whenever(paykitSdkService).privateMessageSchedulingSnapshot(any()) + advanceTimeBy(8_000) + runCurrent() + clearInvocations(paykitSdkService) + advanceTimeBy(120_000) + runCurrent() + verifyNoInteractions(paykitSdkService) + } finally { + sut.closeAndClear() + } + } + @Test fun `shared scheduling reads retain each due peers foreground priority`() = test { settingsData.value = SettingsData(sharesPrivatePaykitEndpoints = false) @@ -1094,20 +1136,27 @@ class PrivatePaykitRepoTest : BaseUnitTest(StandardTestDispatcher()) { val keys = listOf(CONTACT_KEY, OTHER_CONTACT_KEY) for (invalidation in listOf("background", "identity", "deletion")) { settingsData.value = SettingsData(sharesPrivatePaykitEndpoints = false) + doSuspendableAnswer { + val priority = it.getArgument(0) + PrivateMessageSchedulingSnapshot( + paykitSdkService.linkedPeers(priority), + paykitSdkService.pendingOutboundPrivateCounterparties(priority), + ) + }.whenever(paykitSdkService).privateMessageSchedulingSnapshot(any()) whenever(paykitSdkService.linkedPeers(any())).doSuspendableAnswer { paykitSdkService.linkedPeers() } whenever(paykitSdkService.linkedPeers()).thenReturn(keys.map { linkedPeer(it, LinkedPeerState.LINKING) }) sut.prepareSavedContacts(keys).getOrThrow() val completion = CompletableDeferred() - whenever(paykitSdkService.linkedPeers(Priority.Background)).doSuspendableAnswer { + doSuspendableAnswer { withContext(NonCancellable) { completion.await() } - keys.map { linkedPeer(it, LinkedPeerState.LINKING) } - } + PrivateMessageSchedulingSnapshot(keys.map { linkedPeer(it, LinkedPeerState.LINKING) }, emptyList()) + }.whenever(paykitSdkService).privateMessageSchedulingSnapshot(Priority.Background) clearInvocations(paykitSdkService) try { advanceTimeBy(1_000) runCurrent() - verify(paykitSdkService).linkedPeers(Priority.Background) + verify(paykitSdkService).privateMessageSchedulingSnapshot(Priority.Background) when (invalidation) { "background" -> sut.setContactPreparationActive(false) "identity" -> whenever(paykitSdkService.identityStatus()).thenReturn( @@ -1146,10 +1195,10 @@ class PrivatePaykitRepoTest : BaseUnitTest(StandardTestDispatcher()) { sut.prepareSavedContacts(keys).getOrThrow() whenever(paykitSdkService.clearPrivatePaymentLists(listOf(CONTACT_KEY))).thenReturn(privateListDeliveryReport()) val completion = CompletableDeferred() - whenever(paykitSdkService.linkedPeers(Priority.Background)).doSuspendableAnswer { + doSuspendableAnswer { completion.await() - keys.map { linkedPeer(it, LinkedPeerState.LINKING) } - } + PrivateMessageSchedulingSnapshot(keys.map { linkedPeer(it, LinkedPeerState.LINKING) }, emptyList()) + }.whenever(paykitSdkService).privateMessageSchedulingSnapshot(Priority.Background) try { advanceTimeBy(1_000) runCurrent() @@ -1178,16 +1227,17 @@ class PrivatePaykitRepoTest : BaseUnitTest(StandardTestDispatcher()) { states[OTHER_CONTACT_KEY] = LinkedPeerState.LINKED val completion = CompletableDeferred() var reads = 0 - whenever(paykitSdkService.pendingOutboundPrivateCounterparties(Priority.Background)).doSuspendableAnswer { + doSuspendableAnswer { + val snapshot = PrivateMessageSchedulingSnapshot(states.map { linkedPeer(it.key, it.value) }, emptyList()) if (++reads == 1) completion.await() - emptyList() - } + snapshot + }.whenever(paykitSdkService).privateMessageSchedulingSnapshot(Priority.Background) clearInvocations(paykitSdkService) try { advanceTimeBy(500) runCurrent() - verify(paykitSdkService).linkedPeers(Priority.Background) + verify(paykitSdkService).privateMessageSchedulingSnapshot(Priority.Background) states[OTHER_CONTACT_KEY] = LinkedPeerState.LINKING advanceTimeBy(500) runCurrent() diff --git a/app/src/test/java/to/bitkit/services/PaykitSdkServiceTest.kt b/app/src/test/java/to/bitkit/services/PaykitSdkServiceTest.kt index dde6fdada0..f4056f8672 100644 --- a/app/src/test/java/to/bitkit/services/PaykitSdkServiceTest.kt +++ b/app/src/test/java/to/bitkit/services/PaykitSdkServiceTest.kt @@ -20,10 +20,12 @@ import com.synonym.paykit.PaykitStateObservation import com.synonym.paykit.PaymentPayload import com.synonym.paykit.PaymentRequestLifecycleState import com.synonym.paykit.PaymentRequestLocalRole +import com.synonym.paykit.PaymentRequestRead import com.synonym.paykit.PaymentRequestRecord import com.synonym.paykit.PaymentRequestRecurrence import com.synonym.paykit.PaymentRequestTerms import com.synonym.paykit.PrivateJsonObject +import com.synonym.paykit.PrivateMessageSchedulingSnapshot import com.synonym.paykit.PrivatePaymentListDeliveryReport import com.synonym.paykit.PrivatePaymentListReservationUpdateInput import com.synonym.paykit.PrivatePaymentListSyncChange @@ -949,6 +951,157 @@ class PaykitSdkServiceTest { } } + @Test + fun `payment request read retains its runtime through contention and filters records`() = runTest { + val sdk = mock() + val freshSdk = mock() + val counterparty = "a3mduedw686dysw8ndr5c1dyry5h8k6i8hzbbmx9gf9k43zq4s9o" + val bitkit = mock { + on { localRole }.thenReturn(PaymentRequestLocalRole.PAYEE) + on { proposalAppId }.thenReturn("bitkit") + } + val server = mock { + on { localRole }.thenReturn(PaymentRequestLocalRole.PAYEE) + on { proposalAppId }.thenReturn("marketplace") + } + val status = IdentityStatus(RING_PUBKY, PubkyIdentityCapability.PRIVATE_LINK_CAPABLE) + val events = mutableListOf() + doSuspendableAnswer { + events += "read" + if (events.size == 1) throw PaykitException.SharedStateBusy("shared_state_busy", "Locked") + PaymentRequestRead(status, listOf(server, bitkit)) + }.whenever(sdk).readPaymentRequests(counterparty, RING_PUBKY) + val freshStatus = IdentityStatus(null, PubkyIdentityCapability.SIGNED_OUT) + whenever(freshSdk.readPaymentRequests(counterparty, null)).thenReturn( + PaymentRequestRead(freshStatus, emptyList()) + ) + var handlesCreated = 0 + val service = PaykitSdkService(mock(), mock(), mock(), settingsStore = mock()) { + if (handlesCreated++ == 0) sdk else freshSdk + } + val read = async { service.readPaymentRequests(counterparty, RING_PUBKY) } + runCurrent() + val reset = async { + service.clearState() + events += "reset" + } + runCurrent() + advanceTimeBy(249) + runCurrent() + assertEquals(listOf("read"), events) + assertFalse(read.isCompleted) + assertFalse(reset.isCompleted) + advanceTimeBy(1) + runCurrent() + + val result = read.await() + reset.await() + assertEquals(status, result.identityStatus) + assertEquals(listOf(bitkit), result.paymentRequests) + assertEquals(listOf("read", "read", "reset"), events) + assertEquals(1, handlesCreated) + val freshResult = service.readPaymentRequests(counterparty) + assertEquals(freshStatus, freshResult.identityStatus) + assertTrue(freshResult.paymentRequests.isEmpty()) + assertEquals(2, handlesCreated) + verify(sdk, times(2)).readPaymentRequests(counterparty, RING_PUBKY) + verify(freshSdk).readPaymentRequests(counterparty, null) + verify(sdk, never()).identityStatus() + verify(sdk, never()).listPaymentRequests(any()) + } + + @Test + fun `payment request read retries only one acquisition failure`() = runTest { + val busy = PaykitException.SharedStateBusy("shared_state_busy", "Locked") + val cases = listOf( + listOf(busy, busy), + listOf(busy, PaykitException.Identity("identity_error", "Session changed")), + listOf(PaykitException.Identity("identity_error", "Session changed")), + listOf(PaykitException.Storage("storage_error", "State unavailable")), + listOf(PaykitException.Transport("transport_error", "Uncertain write")), + listOf(PaykitException.ConcurrentUpdate("concurrent_update", "Revision changed")), + listOf(CancellationException("Cancelled read")), + ) + cases.forEachCase({ errors -> errors.joinToString { it.javaClass.simpleName } }) { errors -> + val sdk = mock() + var calls = 0 + doSuspendableAnswer { + val failure = errors[minOf(calls, errors.lastIndex)] + calls++ + throw failure + }.whenever(sdk).readPaymentRequests(RING_PUBKY, null) + val service = PaykitSdkService(mock(), mock(), mock(), settingsStore = mock()) { sdk } + val started = currentTime + val failure = assertFailsWith { service.readPaymentRequests(RING_PUBKY) } + if (errors.last() is CancellationException) { + assertIs(failure) + assertEquals(errors.last().message, failure.message) + } else { + assertSame(errors.last(), failure) + } + assertEquals(errors.size, calls) + assertEquals(if (errors.size == 2) 250L else 0L, currentTime - started) + } + } + + @Test + fun `cancelling payment request backoff releases SDK ownership without retrying`() = runTest { + val sdk = mock() + doSuspendableAnswer { + throw PaykitException.SharedStateBusy("shared_state_busy", "Locked") + }.whenever(sdk).readPaymentRequests(RING_PUBKY, null) + whenever(sdk.contactRecords()).thenReturn(emptyList()) + val service = PaykitSdkService(mock(), mock(), mock(), settingsStore = mock()) { sdk } + val read = async { service.readPaymentRequests(RING_PUBKY) } + runCurrent() + val contacts = async { service.contactRecords() } + runCurrent() + assertFalse(contacts.isCompleted) + advanceTimeBy(100) + read.cancel() + runCurrent() + assertFailsWith { read.await() } + assertEquals(emptyList(), contacts.await()) + advanceTimeBy(250) + runCurrent() + verify(sdk).readPaymentRequests(RING_PUBKY, null) + } + + @Test + fun `scheduling snapshot keeps SDK admission and retries acquisition once`() = runTest { + val sdk = mock() + val release = CompletableDeferred() + whenever(sdk.contactRecords()).doSuspendableAnswer { + release.await() + emptyList() + } + val expected = PrivateMessageSchedulingSnapshot(emptyList(), listOf(RING_PUBKY)) + var reads = 0 + doSuspendableAnswer { + if (++reads == 1) throw PaykitException.SharedStateBusy("shared_state_busy", "Locked") + expected + }.whenever(sdk).privateMessageSchedulingSnapshot() + val service = PaykitSdkService(mock(), mock(), mock(), settingsStore = mock()) { sdk } + val holder = async { service.contactRecords() } + runCurrent() + val snapshot = async { service.privateMessageSchedulingSnapshot(Priority.Background) } + runCurrent() + assertEquals(0, reads) + release.complete(Unit) + runCurrent() + advanceTimeBy(249) + runCurrent() + assertEquals(1, reads) + assertFalse(snapshot.isCompleted) + advanceTimeBy(1) + runCurrent() + holder.await() + assertSame(expected, snapshot.await()) + assertEquals(2, reads) + verify(sdk, never()).linkedPeers() + verify(sdk, never()).pendingOutboundPrivateCounterparties() + } + @Test fun `foreground identity read cannot overtake queued signout`() = runTest { val sdk = mock() diff --git a/app/src/test/java/to/bitkit/services/PaykitSdkServiceWipeTest.kt b/app/src/test/java/to/bitkit/services/PaykitSdkServiceWipeTest.kt index b47e5c3958..2d55fd108c 100644 --- a/app/src/test/java/to/bitkit/services/PaykitSdkServiceWipeTest.kt +++ b/app/src/test/java/to/bitkit/services/PaykitSdkServiceWipeTest.kt @@ -3,6 +3,7 @@ package to.bitkit.services import com.synonym.paykit.IdentityStatus import com.synonym.paykit.PaykitException import com.synonym.paykit.PaykitSdk +import com.synonym.paykit.PaymentRequestRead import com.synonym.paykit.PubkyIdentityCapability import com.synonym.paykit.PubkySessionAccess import com.synonym.paykit.PubkySessionBootstrap @@ -47,35 +48,50 @@ class PaykitSdkServiceWipeTest { @Test @OptIn(ExperimentalCoroutinesApi::class) - fun `wipe drains a cancelled active identity read and rejects queued work`() = runTest { - val sdk = mock() - val releaseRead = CompletableDeferred() - val events = mutableListOf() - whenever { sdk.identityStatus() }.doSuspendableAnswer { - releaseRead.await() - events += "read finished" - IdentityStatus(RING_PUBKY, PubkyIdentityCapability.PRIVATE_LINK_CAPABLE) - } - whenever { sdk.contactRecords() }.thenReturn(emptyList()) - val service = PaykitSdkService(mock(), mock(), mock(), settingsStore = mock()) { sdk } - val read = async { service.identityStatus() } - runCurrent() - val queued = async { assertFailsWith { service.contactRecords() } } - runCurrent() - read.cancel() - val wipe = async { service.withWalletWipe { events += "wipe" } } - runCurrent() - assertFalse(read.isCompleted) - assertFalse(wipe.isCompleted) - assertEquals(emptyList(), events) - releaseRead.complete(Unit) + fun `wipe drains a cancelled active read and rejects queued work`() = runTest { + for (requests in listOf(false, true)) { + val sdk = mock() + val releaseRead = CompletableDeferred() + val events = mutableListOf() + whenever { sdk.identityStatus() }.doSuspendableAnswer { + releaseRead.await() + events += "read finished" + IdentityStatus(RING_PUBKY, PubkyIdentityCapability.PRIVATE_LINK_CAPABLE) + } + whenever { sdk.readPaymentRequests(RING_PUBKY, null) }.doSuspendableAnswer { + releaseRead.await() + events += "read finished" + PaymentRequestRead( + IdentityStatus(RING_PUBKY, PubkyIdentityCapability.PRIVATE_LINK_CAPABLE), + emptyList(), + ) + } + whenever { sdk.contactRecords() }.thenReturn(emptyList()) + val service = PaykitSdkService(mock(), mock(), mock(), settingsStore = mock()) { sdk } + val read = async { if (requests) service.readPaymentRequests(RING_PUBKY) else service.identityStatus() } + runCurrent() + val queued = async { + assertFailsWith { + if (requests) service.readPaymentRequests(RING_PUBKY) else service.contactRecords() + } + } + runCurrent() + read.cancel() + val wipe = async { service.withWalletWipe { events += "wipe" } } + runCurrent() + assertFalse(read.isCompleted) + assertFalse(wipe.isCompleted) + assertEquals(emptyList(), events) + releaseRead.complete(Unit) - assertFailsWith { read.await() } - assertEquals("wallet_wipe_in_progress", queued.await().code) - wipe.await() - assertEquals(listOf("read finished", "wipe"), events) - verify(sdk, never()).contactRecords() - assertEquals(emptyList(), service.contactRecords()) + assertFailsWith { read.await() } + assertEquals("wallet_wipe_in_progress", queued.await().code) + wipe.await() + assertEquals(listOf("read finished", "wipe"), events) + verify(sdk, never()).contactRecords() + verify(sdk, times(if (requests) 1 else 0)).readPaymentRequests(RING_PUBKY, null) + assertEquals(emptyList(), service.contactRecords()) + } } @Test diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 155126fe68..faade1aed3 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -21,8 +21,8 @@ activity-compose = { module = "androidx.activity:activity-compose", version = "1 appcompat = { module = "androidx.appcompat:appcompat", version = "1.7.1" } barcode-scanning = { module = "com.google.mlkit:barcode-scanning", version = "17.3.0" } biometric = { module = "androidx.biometric:biometric", version = "1.4.0-alpha05" } -bitkit-core = { module = "com.synonym:bitkit-core-android", version = "0.8.0-rc9" } -paykit = { module = "com.synonym:paykit-android", version = "0.1.0" } +bitkit-core = { module = "com.synonym:bitkit-core-android", version = "0.8.0-rc10" } +paykit = { module = "com.synonym:paykit-android", version = "0.1.1-rc1" } bouncycastle-pkix-jdk = { module = "org.bouncycastle:bcpkix-jdk18on", version = "1.83" } bouncycastle-provider-jdk = { module = "org.bouncycastle:bcprov-jdk18on", version = "1.83" } camera-camera2 = { module = "androidx.camera:camera-camera2", version.ref = "camera" } From 766f4a89caaf34df441addc3986e516275b6ad7a Mon Sep 17 00:00:00 2001 From: benk10 Date: Tue, 20 Oct 2026 13:11:34 -0600 Subject: [PATCH 13/13] fix: retain accepted payments when proof completion fails --- .../java/to/bitkit/viewmodels/AppViewModel.kt | 22 ++++---- .../PaykitPaymentProofRepoTest.kt | 47 +++++++++++++++++ .../viewmodels/AppViewModelSendFlowTest.kt | 50 +++++++++++++++++++ 3 files changed, 110 insertions(+), 9 deletions(-) diff --git a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt index 01b4fe59e0..d4ffe470ba 100644 --- a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt @@ -6120,15 +6120,19 @@ class AppViewModel @Inject constructor( if (preparation == null) return val quoteId = paykitPaymentTerms?.quoteId viewModelScope.launch { - paykitPaymentProofRepo.completeOnchainPayment( - request = paymentRequest, - txid = txId, - paymentEndpointIdentifier = preparation.endpointIdentifier, - paymentAppId = preparation.appId, - acceptedOutcome = acceptedOutcome, - conversionQuoteId = quoteId, - ) - if (paymentRequest.billingPeriod != null) refreshIncomingPaykitPaymentRequests() + runSuspendCatching { + paykitPaymentProofRepo.completeOnchainPayment( + request = paymentRequest, + txid = txId, + paymentEndpointIdentifier = preparation.endpointIdentifier, + paymentAppId = preparation.appId, + acceptedOutcome = acceptedOutcome, + conversionQuoteId = quoteId, + ) + if (paymentRequest.billingPeriod != null) refreshIncomingPaykitPaymentRequests() + }.onFailure { + Logger.warn("Deferred on-chain Paykit proof completion for reconciliation", context = TAG) + } } } diff --git a/app/src/test/java/to/bitkit/repositories/PaykitPaymentProofRepoTest.kt b/app/src/test/java/to/bitkit/repositories/PaykitPaymentProofRepoTest.kt index f853c796f7..2335992ee8 100644 --- a/app/src/test/java/to/bitkit/repositories/PaykitPaymentProofRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/PaykitPaymentProofRepoTest.kt @@ -1724,6 +1724,53 @@ class PaykitPaymentProofRepoTest : BaseUnitTest(StandardTestDispatcher()) { assertTrue(storedProofs.isEmpty()) } + @Test + fun `accepted onchain payment survives identity contention and reconciles once after restart`() = test { + val txid = "ab".repeat(32) + val request = paymentRequest(MethodId.P2wpkh.rawValue) + val repo = paymentProofRepo() + repo.prepare(request, MethodId.P2wpkh.rawValue, "bitkit", PaykitPaymentProofKind.Onchain).getOrThrow() + repo.markOnchainPaymentStarted(request, ONCHAIN_ADDRESS, request.amount.atomic).getOrThrow() + whenever(lightningRepo.currentOnchainSendAttempt()).thenReturn(acceptedAttempt(request, txid)) + whenever(paykitSdkService.paymentRequests()).thenReturn(listOf(paymentRequestRecord())) + val originalProof = storedProofs.single() + whenever(paykitSdkService.identityStatus()).doSuspendableAnswer { + throw PaykitException.SharedStateBusy("shared_state_busy", "Locked") + } + + assertFailsWith { + repo.completeOnchainPayment( + request, + txid, + MethodId.P2wpkh.rawValue, + "bitkit", + OnchainSendOutcome.Accepted(txid), + ) + } + assertEquals(originalProof, storedProofs.single()) + verify(lightningRepo, never()).completeAcceptedShopFollowup(any(), any()) + verify(paykitSdkService, never()).submitPaymentProof(any(), any(), any(), any(), any(), any(), isNull(), any()) + + doReturn(IdentityStatus(LOCAL_IDENTITY, PubkyIdentityCapability.PRIVATE_LINK_CAPABLE)) + .whenever(paykitSdkService).identityStatus() + val restored = paymentProofRepo() + restored.reconcile() + restored.reconcile() + + assertTrue(storedProofs.isEmpty()) + verify(lightningRepo).completeAcceptedShopFollowup(request.id, txid) + verify(paykitSdkService).submitPaymentProof( + eq(request.counterparty), + eq(request.paymentRequestId), + eq("bitkit"), + eq(MethodId.P2wpkh.rawValue), + any(), + isNull(), + isNull(), + any(), + ) + } + @Test fun `accepted Shop activity failure retains original proof for reconciliation before delivery`() = test { val txid = "ab".repeat(32) diff --git a/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt b/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt index d6b766f53f..f4c20b9361 100644 --- a/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt +++ b/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt @@ -25,6 +25,7 @@ import com.synonym.bitkitcore.UsdtPaymentRequest import com.synonym.bitkitcore.UsdtQuote import com.synonym.bitkitcore.ValidationResult import com.synonym.paykit.ConversionRate +import com.synonym.paykit.PaykitException import com.synonym.paykit.PaymentConversion import com.synonym.paykit.PaymentConversionQuoteRecord import com.synonym.paykit.PaymentDeadline @@ -9447,6 +9448,55 @@ class AppViewModelSendFlowTest : BaseUnitTest() { verify(privatePaykitRepo, never()).releasePrivatePaymentList(any(), any()) } + @Test + fun `accepted onchain payment survives a contended background proof completion`() = test { + val address = "bcrt1qpaymentrequest" + val request = paymentRequest() + val privateContext = privatePaymentContext(7uL) + balanceState.value = BalanceState(maxSendOnchainSats = 100_000u) + whenever(paykitPaymentRequestRepo.accept(request)).thenReturn(Result.success(Unit)) + whenever(privatePaykitRepo.consumePrivatePaymentList(testPublicKey, privateContext)) + .thenReturn(Result.success(Unit)) + whenever( + paykitPaymentProofRepo.completeOnchainPayment( + request, + "txid", + MethodId.P2wpkh.rawValue, + "bitkit", + OnchainSendOutcome.Accepted("txid"), + ) + ).doSuspendableAnswer { throw PaykitException.SharedStateBusy("shared_state_busy", "Locked") } + stubSuccessfulOnchainSend(address, request.amount.atomic) + setActiveContactPaymentContext(testPublicKey, privateContext, request) + setSendState( + SendUiState( + address = address, + amount = request.amount.atomic, + payMethod = SendMethod.ONCHAIN, + speed = TransactionSpeed.Medium, + isPaymentRequest = true, + ), + ) + + sut.sendEffect.test { + confirmCurrentPayment() + assertEquals(SendEffect.PaymentSuccess, awaitItem()) + expectNoEvents() + } + + assertEquals("txid", sut.successSendUiState.value.paymentHashOrTxId) + assertEquals(request.amount.atomic.toLong(), sut.successSendUiState.value.sats) + verify(paykitPaymentProofRepo, never()).failOnchainPayment(request) + verify(paykitPaymentProofRepo, never()).cancelPreparation(request) + verify(privatePaykitRepo, never()).releasePrivatePaymentList(any(), any()) + verify(lightningRepo).sendOnChain( + any(), any(), anyOrNull(), anyOrNull(), anyOrNull(), any(), anyOrNull(), any(), any(), any(), any(), + anyOrNull(), anyOrNull(), anyOrNull(), anyOrNull(), + paymentDeadlineAt = anyOrNull(), + contactPublicKey = anyOrNull(), + ) + } + @Test fun `proof preparation failure blocks incoming onchain payment`() = test { val address = "bcrt1qpaymentrequest"