diff --git a/app/src/main/java/to/bitkit/repositories/PaykitPaymentProofRepo.kt b/app/src/main/java/to/bitkit/repositories/PaykitPaymentProofRepo.kt index 2d6983e556..1766aeee88 100644 --- a/app/src/main/java/to/bitkit/repositories/PaykitPaymentProofRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/PaykitPaymentProofRepo.kt @@ -123,6 +123,9 @@ class PaykitPaymentProofRepo @Inject constructor( private val operationMutex = Mutex() + // Process-local activity handoffs only; restored proofs must reconstruct their activity. + private val observedHardwareProofs = mutableSetOf() + fun paymentRequestStateChanges(identity: Flow): Flow = combine(identity, store.backupStateVersion) { publicKey, _ -> runSuspendCatching { @@ -159,7 +162,10 @@ class PaykitPaymentProofRepo @Inject constructor( suspend fun restoreBackup(proofs: List) = withContext(ioDispatcher) { val restored = proofs.map { it.restored() } - operationMutex.withLock { persist(restored) } + operationMutex.withLock { + observedHardwareProofs.clear() + persist(restored) + } } private val _onchainPaymentResolutions = MutableStateFlow>(emptyList()) val onchainPaymentResolutions = _onchainPaymentResolutions.asStateFlow() @@ -320,7 +326,7 @@ class PaykitPaymentProofRepo @Inject constructor( it.kind == PaykitPaymentProofKind.Lightning && it.paymentIdentifier.equals(paymentHash, ignoreCase = true) } - persistAndSubmit(completedProofs, proofs) + persistAndSubmit(completedProofs, proofs, awaitDelivery = false) }.onFailure { Logger.warn("Failed to complete a Paykit Lightning payment proof", it, context = TAG) } } } @@ -477,7 +483,7 @@ class PaykitPaymentProofRepo @Inject constructor( } if (!hasPositiveEvidence(proof)) return@runSuspendCatching false if (index >= 0) proofs[index] = proof else proofs += proof - val retained = persistAndSubmit(listOf(proof), proofs) + val retained = persistAndSubmit(listOf(proof), proofs, awaitDelivery = false) if (retained) publishOnchainResolution(proof, txid) retained } @@ -769,7 +775,7 @@ class PaykitPaymentProofRepo @Inject constructor( val pending = original.copy(paymentIdentifier = txid.lowercase()) proofs[index] = pending persist(proofs) - reconcileHardwareOnchainProof(pending) + reconcileHardwareOnchainProof(pending, awaitDelivery = false) }.onFailure { Logger.warn("Failed to retain or observe the original hardware Shop payment", it, context = TAG) } .getOrDefault(false) } @@ -917,7 +923,7 @@ class PaykitPaymentProofRepo @Inject constructor( runSuspendCatching { finishAlreadyQueuedOnchainProof(attempt) } .onFailure { Logger.warn("Failed to check queued Shop proof", it, context = TAG) } } - if (!store.hasPendingProofs()) return@withContext + if (!hasPendingProofs()) return@withContext // Snapshot immutable proofs, finish their exact accepted activity without the proof lock, // then revalidate the same record under the lock before marking or delivering its proof. @@ -1003,7 +1009,7 @@ class PaykitPaymentProofRepo @Inject constructor( if (!proof.paymentStarted || !proof.proofData.isHex(HASH_BYTE_COUNT) || !proof.paymentIdentifier.equals(proof.proofData, ignoreCase = true) ) return false - if (proof.onchainWalletId != WalletScope.default) return reconcileHardwareOnchainProof(proof) + if (proof.onchainWalletId != WalletScope.default) return reconcileCompletedHardwareProof(proof) if (proof.onchainAcceptanceVerified != true) { if (!attempt.matchesPositiveShopProof(proof)) return false val proofs = loadProofs().toMutableList() @@ -1080,7 +1086,13 @@ class PaykitPaymentProofRepo @Inject constructor( return retained } - private suspend fun reconcileHardwareOnchainProof(proof: PendingPaykitPaymentProof): Boolean { + private suspend fun reconcileCompletedHardwareProof(proof: PendingPaykitPaymentProof): Boolean = + if (proof in observedHardwareProofs) submitReady(proof) else reconcileHardwareOnchainProof(proof) + + private suspend fun reconcileHardwareOnchainProof( + proof: PendingPaykitPaymentProof, + awaitDelivery: Boolean = true, + ): Boolean { val txid = proof.paymentIdentifier?.takeIf { it.isHex(HASH_BYTE_COUNT) } ?: return false if (!proof.paymentStarted || proof.onchainWalletId == WalletScope.default || (proof.proofData != null && !proof.proofData.equals(txid, true)) @@ -1097,8 +1109,11 @@ class PaykitPaymentProofRepo @Inject constructor( if (index < 0) return false val completed = proof.copy(proofData = txid.lowercase(), onchainAcceptanceVerified = true) proofs[index] = completed - val retained = persistAndSubmit(listOf(completed), proofs) - if (retained) publishOnchainResolution(completed, txid) + val retained = persistAndSubmit(listOf(completed), proofs, awaitDelivery) + if (retained) { + observedHardwareProofs += completed + publishOnchainResolution(completed, txid) + } // Hardware completion never acknowledges an unrelated node-wallet guard. return retained } @@ -1257,6 +1272,7 @@ class PaykitPaymentProofRepo @Inject constructor( private suspend fun persistAndSubmit( completedProofs: List, allProofs: List, + awaitDelivery: Boolean = true, ): Boolean { val didPersist = runSuspendCatching { persist(allProofs) } .onFailure { @@ -1266,6 +1282,7 @@ class PaykitPaymentProofRepo @Inject constructor( context = TAG, ) }.isSuccess + if (didPersist && !awaitDelivery) return true var hasUndeliveredProof = false completedProofs.forEach { proof -> val wasDelivered = runSuspendCatching { submitReady(proof) } @@ -1314,10 +1331,17 @@ class PaykitPaymentProofRepo @Inject constructor( ) } - private fun loadProofs(): List = store.load() + private suspend fun hasPendingProofs(): Boolean = operationMutex.withLock { + store.hasPendingProofs().also { if (!it) observedHardwareProofs.clear() } + } + + private fun loadProofs(): List = store.load().also { + observedHardwareProofs.retainAll(it.toSet()) + } private suspend fun persist(proofs: List) { store.save(proofs) + observedHardwareProofs.retainAll(proofs.toSet()) } } diff --git a/app/src/main/java/to/bitkit/repositories/PaykitPaymentRequestRepo.kt b/app/src/main/java/to/bitkit/repositories/PaykitPaymentRequestRepo.kt index bc24602548..53d4e3acd6 100644 --- a/app/src/main/java/to/bitkit/repositories/PaykitPaymentRequestRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/PaykitPaymentRequestRepo.kt @@ -550,7 +550,7 @@ class PaykitPaymentRequestRepo @Inject constructor( } ?: return@refresh val snapshot = runSuspendCatching { val priority = if (forceFresh) Priority.Ordered else Priority.Background - fetchRequestSnapshot(expectedIdentity, mode, priority, messagePriority) + fetchRequestSnapshot(expectedIdentity, mode, priority, messagePriority, observationOnly = true) } operationMutex.withLock { if (!isCurrentState(generation, expectedIdentity)) return@withLock @@ -1137,14 +1137,23 @@ class PaykitPaymentRequestRepo @Inject constructor( mode: PaykitPaymentRequestRefreshMode, priority: Priority = Priority.Ordered, messagePriority: Priority = Priority.Ordered, + observationOnly: Boolean = false, ): RequestSnapshot { if (mode == PaykitPaymentRequestRefreshMode.FULL) processPendingMessages(priority = messagePriority) if (mode != PaykitPaymentRequestRefreshMode.STORED) { paykitSdkService.receivePrivateMessagesFromLinkedPeers(messagePriority).also(::logIntakeFailures) } - val allRecords = paykitSdkService.allPaymentRequests(expectedIdentity, priority) - val blockedPeers = paykitSdkService.linkedPeers(priority).filter { it.state == LinkedPeerState.BLOCKED } - return RequestSnapshot(allRecords, blockedPeers) + if (observationOnly) { + val observation = paykitSdkService.observeState(requireNotNull(expectedIdentity), priority) + return RequestSnapshot( + observation.paymentRequests, + observation.linkedPeers.filter { it.state == LinkedPeerState.BLOCKED }, + ) + } + return RequestSnapshot( + paykitSdkService.allPaymentRequests(expectedIdentity, priority), + paykitSdkService.linkedPeers(priority).filter { it.state == LinkedPeerState.BLOCKED }, + ) } @Suppress("LongMethod", "CyclomaticComplexMethod") diff --git a/app/src/main/java/to/bitkit/repositories/PubkyRepo.kt b/app/src/main/java/to/bitkit/repositories/PubkyRepo.kt index b44d004dcd..2ac8c41429 100644 --- a/app/src/main/java/to/bitkit/repositories/PubkyRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/PubkyRepo.kt @@ -1141,9 +1141,10 @@ class PubkyRepo @Inject constructor( * was resolved less than [CONTACT_PROFILE_FRESHNESS] ago. */ suspend fun loadContacts() { - val pk = _publicKey.value ?: return + val signIn = currentSignIn() ?: return + val pk = signIn.publicKey loadContactsMutex.lock() - if (_publicKey.value != pk) { + if (!isCurrent(signIn)) { loadContactsMutex.unlock() return } @@ -1158,12 +1159,12 @@ class PubkyRepo @Inject constructor( reload = false runSuspendCatching { withContext(ioDispatcher) { - val records = pubkyService.contactRecords() + val records = pubkyService.observedContactRecords(pk) val overrides = pubkyStore.data.first().contactProfileOverrides records.map { savedContact(it, overrides) } } }.onSuccess { savedContacts -> - if (_publicKey.value != pk) { + if (!isCurrent(signIn)) { Logger.debug("Skipped stale contacts load for '${redacted(pk)}'", context = TAG) return@onSuccess } @@ -1180,16 +1181,16 @@ class PubkyRepo @Inject constructor( markContactsLoaded() shouldMarkLoadCompleted = true }.onFailure { - shouldMarkLoadCompleted = _publicKey.value == pk + shouldMarkLoadCompleted = isCurrent(signIn) Logger.error("Failed to load contacts", it, context = TAG) } - } while (reload && _publicKey.value == pk) + } while (reload && isCurrent(signIn)) } finally { _isLoadingContacts.update { false } loadContactsMutex.unlock() - if (shouldMarkLoadCompleted && _publicKey.value == pk) markContactsLoadCompleted() + if (shouldMarkLoadCompleted && isCurrent(signIn)) markContactsLoadCompleted() } - refreshContactProfiles(pk, contactsToRefresh) + if (isCurrent(signIn)) refreshContactProfiles(pk, contactsToRefresh) } /** diff --git a/app/src/main/java/to/bitkit/services/PaykitSdkService.kt b/app/src/main/java/to/bitkit/services/PaykitSdkService.kt index f04d68ee7d..e5cc363f39 100644 --- a/app/src/main/java/to/bitkit/services/PaykitSdkService.kt +++ b/app/src/main/java/to/bitkit/services/PaykitSdkService.kt @@ -18,6 +18,7 @@ import com.synonym.paykit.PaykitProfile import com.synonym.paykit.PaykitProfileRecord import com.synonym.paykit.PaykitSdk import com.synonym.paykit.PaykitSdkDefaults +import com.synonym.paykit.PaykitStateObservation import com.synonym.paykit.PaymentAmountContext import com.synonym.paykit.PaymentPayload import com.synonym.paykit.PaymentProofSubmission @@ -958,6 +959,31 @@ class PaykitSdkService @Inject constructor( suspend fun paymentRequests(): List = allPaymentRequests().filter(::isBitkitPaymentRequest) + /** Refreshes display projections only; payment authority remains with the action APIs. */ + internal suspend fun observeState( + expectedIdentity: String, + priority: Priority = Priority.Background, + ): PaykitStateObservation { + isSetup.await() + val generation = runtimeGeneration + return operationLock.withLock(priority) { + check(runtimeGeneration == generation) { "Paykit runtime changed before observation" } + withPaykitKey { handle -> + val observation = completeSdkCall { handle.observeState() } + check(PubkyPublicKeyFormat.matches(observation.publicKey, expectedIdentity)) { + "Paykit observation identity changed" + } + cachedPaykitKey?.let { + check( + PubkyPublicKeyFormat.matches(observation.publicKey, it.publicKey) && + observation.keyGeneration == it.generation, + ) { "Paykit observation key changed" } + } + observation + } + } + } + suspend fun allPaymentRequests(expectedIdentity: String? = null): List = allPaymentRequests(expectedIdentity, Priority.Ordered) diff --git a/app/src/main/java/to/bitkit/services/PubkyService.kt b/app/src/main/java/to/bitkit/services/PubkyService.kt index 9a13d4dc7b..02fda605b4 100644 --- a/app/src/main/java/to/bitkit/services/PubkyService.kt +++ b/app/src/main/java/to/bitkit/services/PubkyService.kt @@ -205,6 +205,10 @@ class PubkyService @Inject constructor( paykitSdkService.contactRecords() } + suspend fun observedContactRecords(expectedIdentity: String): List = ServiceQueue.CORE.background { + paykitSdkService.observeState(expectedIdentity).contacts + } + @Suppress("LongParameterList") suspend fun saveContact( publicKey: String, diff --git a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt index 418b2bf8eb..36d11745eb 100644 --- a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt @@ -968,6 +968,7 @@ class AppViewModel @Inject constructor( forceFresh: Boolean = false, messagePriority: Priority = Priority.Ordered, onlyWhileActive: Boolean = false, + reconcileProofs: Boolean = mode == PaykitPaymentRequestRefreshMode.FULL, ): Result { if (!isPaykitEnabled.value || pubkyRepo.publicKey.value == null || !walletRepo.walletExists()) { return Result.failure(PaykitPaymentRequestError.RequestUnavailable) @@ -975,7 +976,7 @@ class AppViewModel @Inject constructor( if (onlyWhileActive && isPaymentRequestPollingStopped.value) { return Result.failure(PaykitPaymentRequestError.RequestUnavailable) } - if (mode == PaykitPaymentRequestRefreshMode.FULL) paykitPaymentProofRepo.reconcile() + if (reconcileProofs) paykitPaymentProofRepo.reconcile() if (onlyWhileActive && isPaymentRequestPollingStopped.value) { return Result.failure(PaykitPaymentRequestError.RequestUnavailable) } @@ -1137,22 +1138,7 @@ class AppViewModel @Inject constructor( } }.filterNotNull().collect(::clearPaymentRequestPreparation) } - viewModelScope.launch { - var changeVersion = 0L - paykitPaymentProofRepo.paymentRequestStateChanges(pubkyRepo.publicKey) - .map { ++changeVersion } - .combine(paykitPaymentRequestRepo.isPaymentSubmissionActive) { version, active -> - version.takeUnless { active } - } - .combine(isPaymentRequestPollingStopped) { version, stopped -> - version.takeUnless { stopped } - } - .filterNotNull() - .distinctUntilChanged() - .collect { - refreshIncomingPaykitPaymentRequests(PaykitPaymentRequestRefreshMode.STORED, forceFresh = true) - } - } + observePaymentProofChanges() viewModelScope.launch { var previousSheet: Sheet? = null currentSheet.collect { sheet -> @@ -1187,6 +1173,30 @@ class AppViewModel @Inject constructor( } } + private fun observePaymentProofChanges() { + viewModelScope.launch { + var changeVersion = 0L + paykitPaymentProofRepo.paymentRequestStateChanges(pubkyRepo.publicKey) + .map { ++changeVersion } + .combine(paykitPaymentRequestRepo.isPaymentSubmissionActive) { version, active -> + version.takeUnless { active } + } + .combine(isPaymentRequestPollingStopped) { version, stopped -> + version.takeUnless { stopped } + } + .filterNotNull() + .distinctUntilChanged() + .collect { + refreshIncomingPaykitPaymentRequests( + PaykitPaymentRequestRefreshMode.STORED, + forceFresh = true, + onlyWhileActive = true, + reconcileProofs = true, + ) + } + } + } + fun onSheetVisible(sheet: Sheet?) { if (sheet is Sheet.Subscription && sheet.route is SubscriptionRoute.Review) { subscription(sheet.route.id)?.let { subscription -> diff --git a/app/src/test/java/to/bitkit/repositories/PaykitPaymentProofRepoTest.kt b/app/src/test/java/to/bitkit/repositories/PaykitPaymentProofRepoTest.kt index 05d3c032d2..1982c67471 100644 --- a/app/src/test/java/to/bitkit/repositories/PaykitPaymentProofRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/PaykitPaymentProofRepoTest.kt @@ -14,24 +14,27 @@ import com.synonym.paykit.PaymentRequestLocalRole import com.synonym.paykit.PaymentRequestRecord import com.synonym.paykit.PaymentRequestTerms import com.synonym.paykit.PrivateJsonObject -import kotlinx.coroutines.CompletableDeferred -import kotlinx.coroutines.launch -import kotlinx.coroutines.withTimeout -import kotlinx.coroutines.test.StandardTestDispatcher -import kotlinx.coroutines.test.advanceUntilIdle import com.synonym.paykit.PubkyIdentityCapability import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.StandardTestDispatcher import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.advanceUntilIdle import kotlinx.coroutines.test.runCurrent -import kotlinx.serialization.json.Json +import kotlinx.coroutines.withTimeout import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json import org.junit.Before +import org.junit.Rule import org.junit.Test +import org.junit.rules.TemporaryFolder import org.lightningdevkit.ldknode.NodeException import org.lightningdevkit.ldknode.PaymentDetails import org.lightningdevkit.ldknode.PaymentDirection @@ -49,6 +52,7 @@ import org.mockito.kotlin.never import org.mockito.kotlin.times import org.mockito.kotlin.verify import org.mockito.kotlin.whenever +import to.bitkit.data.keychain.Keychain import to.bitkit.models.HwConnectedDevice import to.bitkit.models.HwFundingSignedTx import to.bitkit.models.HwFundingTransaction @@ -56,7 +60,6 @@ import to.bitkit.models.NodeLifecycleState import to.bitkit.models.PaykitPaymentStateBackup import to.bitkit.models.WalletScope import to.bitkit.services.CoreService -import to.bitkit.data.keychain.Keychain import to.bitkit.services.PaykitSdkService import to.bitkit.test.BaseUnitTest import to.bitkit.ui.screens.wallets.send.HwSendRequest @@ -65,6 +68,7 @@ import to.bitkit.utils.AppError import to.bitkit.utils.LdkError import to.bitkit.utils.ServiceError import to.bitkit.utils.SignedTransactionId +import java.io.FileOutputStream import kotlin.test.assertEquals import kotlin.test.assertFailsWith import kotlin.test.assertFalse @@ -84,6 +88,9 @@ class PaykitPaymentProofRepoTest : BaseUnitTest(StandardTestDispatcher()) { private val PREIMAGE = "00".repeat(32) } + @get:Rule + val temporaryFolder = TemporaryFolder() + private val paykitSdkService = mock() private val lightningRepo = mock() private val store = mock() @@ -141,6 +148,120 @@ class PaykitPaymentProofRepoTest : BaseUnitTest(StandardTestDispatcher()) { whenever(keychain.delete(any())).doSuspendableAnswer { storedProjectionJson = null } } + @Test + fun `durable proof completion returns before delayed submission`() = test { + val file = temporaryFolder.root.resolve("proofs.json") + val diskKeychain = mock() + whenever(diskKeychain.loadString(any())).thenAnswer { file.takeIf { it.exists() }?.readText() } + whenever(diskKeychain.exists(any())).thenAnswer { file.exists() } + whenever(diskKeychain.upsertString(any(), any())).doSuspendableAnswer { + FileOutputStream(file).use { output -> + output.write(it.getArgument(1).toByteArray()) + output.fd.sync() + } + } + whenever(diskKeychain.delete(any())).doSuspendableAnswer { check(file.delete()) } + val diskStore = PaykitPaymentProofStore(diskKeychain) + val txid = "ab".repeat(32) + val request = paymentRequest(MethodId.P2wpkh.rawValue) + val record = paymentRequestRecord() + whenever(paykitSdkService.paymentRequests()).thenReturn(listOf(record)) + whenever(paykitSdkService.submitPaymentProof(any(), any(), any(), any(), any(), isNull())) + .doSuspendableAnswer { + assertEquals(txid, PaykitPaymentProofStore(diskKeychain).load().single().proofData) + delay(5.seconds) + record + } + val repo = paymentProofRepo(diskStore) + repo.prepare(request, MethodId.P2wpkh.rawValue, "bitkit", PaykitPaymentProofKind.Onchain).getOrThrow() + repo.markOnchainPaymentStarted(request, ONCHAIN_ADDRESS).getOrThrow() + val started = testScheduler.currentTime + assertTrue( + repo.completeOnchainPayment( + request, + txid, + MethodId.P2wpkh.rawValue, + "bitkit", + OnchainSendOutcome.Accepted(txid), + ) + ) + + assertEquals(0L, testScheduler.currentTime - started) + verify(lightningRepo).completeAcceptedShopFollowup(request.id, txid) + paymentProofRepo(PaykitPaymentProofStore(diskKeychain)).reconcile() + assertFalse(file.exists()) + } + + @Test + fun `failed persistence and delivery retain the unfinished payment guard`() = test { + val txid = "ab".repeat(32) + val request = paymentRequest(MethodId.P2wpkh.rawValue) + val record = paymentRequestRecord() + whenever(paykitSdkService.paymentRequests()).thenReturn(listOf(record)) + val repo = paymentProofRepo() + repo.prepare(request, MethodId.P2wpkh.rawValue, "bitkit", PaykitPaymentProofKind.Onchain).getOrThrow() + repo.markOnchainPaymentStarted(request, ONCHAIN_ADDRESS).getOrThrow() + whenever(store.save(any())).doSuspendableAnswer { throw AppError("disk unavailable") } + whenever(paykitSdkService.submitPaymentProof(any(), any(), any(), any(), any(), isNull())) + .doSuspendableAnswer { + delay(5.seconds) + throw AppError("delivery unavailable") + } + val started = testScheduler.currentTime + + assertFalse( + repo.completeOnchainPayment( + request, + txid, + MethodId.P2wpkh.rawValue, + "bitkit", + OnchainSendOutcome.Accepted(txid), + ) + ) + + assertEquals(5.seconds.inWholeMilliseconds, testScheduler.currentTime - started) + assertNull(storedProofs.single().proofData) + assertTrue(storedProofs.single().paymentStarted) + assertTrue(repo.onchainPaymentResolutions.value.isEmpty()) + verify(lightningRepo, never()).completeAcceptedShopFollowup(any(), any()) + assertEquals( + PaykitPaymentRequestError.OperationInProgress, + repo.prepare(request, MethodId.P2wpkh.rawValue, "bitkit", PaykitPaymentProofKind.Onchain).exceptionOrNull(), + ) + } + + @Test + fun `durable handoff does not deliver under another identity or after reset`() = test { + val txid = "ab".repeat(32) + val request = paymentRequest(MethodId.P2wpkh.rawValue) + whenever(paykitSdkService.paymentRequests()).thenReturn(listOf(paymentRequestRecord())) + val repo = paymentProofRepo() + repo.prepare(request, MethodId.P2wpkh.rawValue, "bitkit", PaykitPaymentProofKind.Onchain).getOrThrow() + repo.markOnchainPaymentStarted(request, ONCHAIN_ADDRESS).getOrThrow() + assertTrue( + repo.completeOnchainPayment( + request, + txid, + MethodId.P2wpkh.rawValue, + "bitkit", + OnchainSendOutcome.Accepted(txid), + ) + ) + + listOf(null, IdentityStatus(COUNTERPARTY, PubkyIdentityCapability.PRIVATE_LINK_CAPABLE)).forEach { status -> + whenever(paykitSdkService.identityStatus()).thenReturn(status) + paymentProofRepo().reconcile() + assertEquals(txid, storedProofs.single().proofData) + verify(paykitSdkService, never()).submitPaymentProof(any(), any(), any(), any(), any(), isNull()) + } + 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()) + } + @Test fun `proof without original app provenance stays retained and is not submitted`() = test { storedProofs = listOf(readyLightningProof(PAYMENT_REQUEST_ID).copy(paymentAppId = "")) @@ -356,6 +477,7 @@ class PaykitPaymentProofRepoTest : BaseUnitTest(StandardTestDispatcher()) { firstRepo.prepare(request, MethodId.Bolt11.rawValue, "merchant", PaykitPaymentProofKind.Lightning).getOrThrow() firstRepo.associateLightningPayment(request, PAYMENT_HASH, MethodId.Bolt11.rawValue, "merchant").getOrThrow() firstRepo.completeLightningPayment(PAYMENT_HASH, PREIMAGE) + firstRepo.reconcile() assertEquals(PREIMAGE, storedProofs.single().proofData) @@ -508,6 +630,7 @@ class PaykitPaymentProofRepoTest : BaseUnitTest(StandardTestDispatcher()) { repo.associateLightningPayment(request, PAYMENT_HASH, MethodId.Bolt11.rawValue, "bitkit").getOrThrow() repo.completeLightningPayment(PAYMENT_HASH, PREIMAGE) + repo.reconcile() verify(paykitSdkService).submitPaymentProof(any(), any(), any(), any(), any(), isNull()) assertTrue(storedProofs.isEmpty()) } @@ -608,6 +731,7 @@ class PaykitPaymentProofRepoTest : BaseUnitTest(StandardTestDispatcher()) { assertTrue(retry.exceptionOrNull() is PaykitPaymentRequestError.OperationInProgress) restartedRepo.completeLightningPayment(PAYMENT_HASH, PREIMAGE) + restartedRepo.reconcile() assertTrue(storedProofs.isEmpty()) } verify(paykitSdkService, times(errors.size)).submitPaymentProof(any(), any(), any(), any(), any(), isNull()) @@ -650,6 +774,7 @@ class PaykitPaymentProofRepoTest : BaseUnitTest(StandardTestDispatcher()) { repo.markOnchainPaymentStarted(request, ONCHAIN_ADDRESS).getOrThrow() assertTrue(storedProofs.single().paymentStarted) repo.completeOnchainPayment(request, txid, MethodId.P2wpkh.rawValue, "bitkit", OnchainSendOutcome.Accepted(txid)) + repo.reconcile() val endpointCaptor = argumentCaptor() val proofCaptor = argumentCaptor() @@ -784,6 +909,7 @@ class PaykitPaymentProofRepoTest : BaseUnitTest(StandardTestDispatcher()) { repo.completeOnchainPayment(request, txid, endpoint, "bitkit", OnchainSendOutcome.Accepted(txid)) + repo.reconcile() verify(paykitSdkService).submitPaymentProof(any(), any(), any(), eq(endpoint), any(), isNull()) assertTrue(storedProofs.isEmpty()) } @@ -804,6 +930,7 @@ class PaykitPaymentProofRepoTest : BaseUnitTest(StandardTestDispatcher()) { repo.associateLightningPayment(request, PAYMENT_HASH, MethodId.Bolt11.rawValue, "bitkit").getOrThrow() repo.completeLightningPayment(PAYMENT_HASH, PREIMAGE) + repo.reconcile() val periodCaptor = argumentCaptor() verify(paykitSdkService).submitPaymentProof( counterparty = any(), @@ -844,6 +971,7 @@ class PaykitPaymentProofRepoTest : BaseUnitTest(StandardTestDispatcher()) { repo.associateLightningPayment(request, PAYMENT_HASH, MethodId.Bolt11.rawValue, "bitkit").getOrThrow() repo.completeLightningPayment(PAYMENT_HASH, PREIMAGE) + repo.reconcile() verify(paykitSdkService).submitPaymentProof(any(), any(), any(), any(), any(), any()) } @@ -929,6 +1057,7 @@ class PaykitPaymentProofRepoTest : BaseUnitTest(StandardTestDispatcher()) { repo.markOnchainPaymentStarted(request, ONCHAIN_ADDRESS).getOrThrow() shouldFailProofRemoval = true repo.completeOnchainPayment(request, txid, MethodId.P2wpkh.rawValue, "bitkit", OnchainSendOutcome.Accepted(txid)) + repo.reconcile() verify(paykitSdkService).submitPaymentProof(any(), any(), any(), any(), any(), isNull()) assertEquals(txid, storedProofs.single().proofData) @@ -948,6 +1077,7 @@ class PaykitPaymentProofRepoTest : BaseUnitTest(StandardTestDispatcher()) { repo.markOnchainPaymentStarted(request, ONCHAIN_ADDRESS).getOrThrow() shouldFailNextLoad = true repo.completeOnchainPayment(request, txid, endpoint, "bitkit", OnchainSendOutcome.Accepted(txid)) + repo.reconcile() val endpointCaptor = argumentCaptor() val proofCaptor = argumentCaptor() @@ -1272,6 +1402,7 @@ class PaykitPaymentProofRepoTest : BaseUnitTest(StandardTestDispatcher()) { repo.prepare(request, MethodId.P2wpkh.rawValue, "bitkit", PaykitPaymentProofKind.Onchain).getOrThrow() repo.markOnchainPaymentStarted(request, ONCHAIN_ADDRESS).getOrThrow() assertTrue(repo.completeOnchainPayment(request, txid, MethodId.P2wpkh.rawValue, "bitkit", OnchainSendOutcome.Accepted(txid))) + repo.reconcile() assertEquals(txid, storedProofs.single().proofData) assertTrue(storedProofs.single().onchainAcceptanceVerified) @@ -1880,6 +2011,48 @@ class PaykitPaymentProofRepoTest : BaseUnitTest(StandardTestDispatcher()) { assertTrue(storedProofs.single().onchainAcceptanceVerified) assertEquals(walletId, storedProofs.single().onchainWalletId) assertEquals(request.id, repo.onchainPaymentResolutions.value.single().requestId) + whenever(hwWalletRepo.observeExactTransaction(walletId, txid, ONCHAIN_ADDRESS, request.amountSats)) + .thenReturn(Result.success(false)) + whenever(paykitSdkService.paymentRequests()).thenReturn(listOf(paymentRequestRecord())) + whenever(paykitSdkService.submitPaymentProof(any(), any(), any(), any(), any(), isNull())) + .thenReturn(paymentRequestRecord()) + repo.reconcile() + assertTrue(storedProofs.isEmpty()) + verify(hwWalletRepo).observeExactTransaction(walletId, txid, ONCHAIN_ADDRESS, request.amountSats) + verify(paykitSdkService).submitPaymentProof( + any(), + any(), + any(), + eq(MethodId.P2wpkh.rawValue), + eq("""{"data":"$txid","type":"bitcoin-onchain-txid"}"""), + isNull(), + ) + verify(hwWalletRepo, never()).broadcastFunding(any(), org.mockito.kotlin.anyOrNull()) + } + + @Test + fun `restoring an observed hardware proof requires activity reconstruction again`() = test { + val request = paymentRequest(MethodId.P2wpkh.rawValue) + val txid = "cd".repeat(32) + val walletId = "original-hardware-wallet" + val repo = paymentProofRepo() + repo.prepare(request, MethodId.P2wpkh.rawValue, "bitkit", PaykitPaymentProofKind.Onchain).getOrThrow() + repo.markOnchainPaymentStarted(request, ONCHAIN_ADDRESS, walletId).getOrThrow() + whenever(hwWalletRepo.observeExactTransaction(walletId, txid, ONCHAIN_ADDRESS, request.amountSats)) + .thenReturn(Result.success(true)) + assertTrue(repo.completeHardwareOnchainPayment(request.id, walletId, txid, LOCAL_IDENTITY)) + val completed = storedProofs.single() + repo.restoreBackup(repo.backupSnapshot()) + assertEquals(completed, storedProofs.single()) + whenever(hwWalletRepo.observeExactTransaction(walletId, txid, ONCHAIN_ADDRESS, request.amountSats)) + .thenReturn(Result.success(false)) + whenever(paykitSdkService.paymentRequests()).thenReturn(listOf(paymentRequestRecord())) + + repo.reconcile() + + assertEquals(completed, storedProofs.single()) + verify(hwWalletRepo, times(2)).observeExactTransaction(walletId, txid, ONCHAIN_ADDRESS, request.amountSats) + verify(paykitSdkService, never()).submitPaymentProof(any(), any(), any(), any(), any(), isNull()) verify(hwWalletRepo, never()).broadcastFunding(any(), org.mockito.kotlin.anyOrNull()) } diff --git a/app/src/test/java/to/bitkit/repositories/PaykitPaymentRequestRepoSubscriptionTest.kt b/app/src/test/java/to/bitkit/repositories/PaykitPaymentRequestRepoSubscriptionTest.kt index 412ec3f700..2ae695da73 100644 --- a/app/src/test/java/to/bitkit/repositories/PaykitPaymentRequestRepoSubscriptionTest.kt +++ b/app/src/test/java/to/bitkit/repositories/PaykitPaymentRequestRepoSubscriptionTest.kt @@ -8,6 +8,7 @@ import com.synonym.paykit.LinkedPeerRecord import com.synonym.paykit.LinkedPeerState import com.synonym.paykit.OutboundPrivateMessageStatus import com.synonym.paykit.OutboundPrivateSendReport +import com.synonym.paykit.PaykitStateObservation import com.synonym.paykit.PaymentDeadline import com.synonym.paykit.PaymentProofRecord import com.synonym.paykit.PaymentReference @@ -108,13 +109,7 @@ class PaykitPaymentRequestRepoSubscriptionTest : BaseUnitTest(StandardTestDispat whenever(paykitSdkService.receivePrivateMessagesFromLinkedPeers(any())).doSuspendableAnswer { paykitSdkService.receivePrivateMessagesFromLinkedPeers() } - whenever(paykitSdkService.allPaymentRequests(anyOrNull())).thenReturn(emptyList()) - whenever(paykitSdkService.linkedPeers()).thenReturn(emptyList()) - whenever(paykitSdkService.identityStatus(any())).doSuspendableAnswer { paykitSdkService.identityStatus() } - whenever(paykitSdkService.linkedPeers(any())).doSuspendableAnswer { paykitSdkService.linkedPeers() } - whenever(paykitSdkService.allPaymentRequests(anyOrNull(), any())).doSuspendableAnswer { - paykitSdkService.allPaymentRequests(it.getArgument(0)) - } + stubRequestReads() whenever(paykitSdkService.processOutboundPrivateMessages(any(), any())).doSuspendableAnswer { paykitSdkService.processOutboundPrivateMessages(it.getArgument(0)) } @@ -144,6 +139,28 @@ class PaykitPaymentRequestRepoSubscriptionTest : BaseUnitTest(StandardTestDispat sut.activate(LOCAL_IDENTITY) } + private fun stubRequestReads() { + whenever { paykitSdkService.allPaymentRequests(anyOrNull()) }.thenReturn(emptyList()) + whenever { paykitSdkService.linkedPeers() }.thenReturn(emptyList()) + whenever { paykitSdkService.identityStatus(any()) }.doSuspendableAnswer { paykitSdkService.identityStatus() } + whenever { paykitSdkService.linkedPeers(any()) }.doSuspendableAnswer { paykitSdkService.linkedPeers() } + whenever { paykitSdkService.allPaymentRequests(anyOrNull(), any()) }.doSuspendableAnswer { + paykitSdkService.allPaymentRequests(it.getArgument(0)) + } + whenever { paykitSdkService.observeState(any(), any()) }.doSuspendableAnswer { + val records = paykitSdkService.allPaymentRequests(it.getArgument(0)) + PaykitStateObservation( + publicKey = it.getArgument(0), + keyGeneration = 0uL, + observedAt = clock.now().toString(), + contacts = emptyList(), + linkedPeers = paykitSdkService.linkedPeers(), + paymentRequests = records, + receipts = emptyList(), + ) + } + } + @After fun tearDown() = test { sut.clear() diff --git a/app/src/test/java/to/bitkit/repositories/PaykitPaymentRequestRepoTest.kt b/app/src/test/java/to/bitkit/repositories/PaykitPaymentRequestRepoTest.kt index e11987608a..2777e9cb97 100644 --- a/app/src/test/java/to/bitkit/repositories/PaykitPaymentRequestRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/PaykitPaymentRequestRepoTest.kt @@ -14,6 +14,7 @@ import com.synonym.paykit.OutboundPrivateMessageStatus import com.synonym.paykit.OutboundPrivateSendFailure import com.synonym.paykit.OutboundPrivateSendReport import com.synonym.paykit.PaykitException +import com.synonym.paykit.PaykitStateObservation import com.synonym.paykit.PaymentConversion import com.synonym.paykit.PaymentDeadline import com.synonym.paykit.PaymentProofRecord @@ -269,13 +270,7 @@ class PaykitPaymentRequestRepoTest : BaseUnitTest(StandardTestDispatcher()) { whenever(paykitSdkService.receivePrivateMessagesFromLinkedPeers(any())).doSuspendableAnswer { paykitSdkService.receivePrivateMessagesFromLinkedPeers() } - whenever(paykitSdkService.allPaymentRequests(anyOrNull())).thenReturn(emptyList()) - whenever(paykitSdkService.linkedPeers()).thenReturn(emptyList()) - whenever(paykitSdkService.identityStatus(any())).doSuspendableAnswer { paykitSdkService.identityStatus() } - whenever(paykitSdkService.linkedPeers(any())).doSuspendableAnswer { paykitSdkService.linkedPeers() } - whenever(paykitSdkService.allPaymentRequests(anyOrNull(), any())).doSuspendableAnswer { - paykitSdkService.allPaymentRequests(it.getArgument(0)) - } + stubRequestReads() whenever(paykitSdkService.processOutboundPrivateMessages(any(), any())).doSuspendableAnswer { paykitSdkService.processOutboundPrivateMessages(it.getArgument(0)) } @@ -311,11 +306,90 @@ class PaykitPaymentRequestRepoTest : BaseUnitTest(StandardTestDispatcher()) { sut.activate(LOCAL_IDENTITY) } + private fun stubRequestReads() { + whenever { paykitSdkService.allPaymentRequests(anyOrNull()) }.thenReturn(emptyList()) + whenever { paykitSdkService.linkedPeers() }.thenReturn(emptyList()) + whenever { paykitSdkService.identityStatus(any()) }.doSuspendableAnswer { paykitSdkService.identityStatus() } + whenever { paykitSdkService.linkedPeers(any()) }.doSuspendableAnswer { paykitSdkService.linkedPeers() } + whenever { paykitSdkService.allPaymentRequests(anyOrNull(), any()) }.doSuspendableAnswer { + paykitSdkService.allPaymentRequests(it.getArgument(0)) + } + whenever { paykitSdkService.observeState(any(), any()) }.doSuspendableAnswer { + val records = paykitSdkService.allPaymentRequests(it.getArgument(0)) + PaykitStateObservation( + publicKey = it.getArgument(0), + keyGeneration = 0uL, + observedAt = clock.now().toString(), + contacts = emptyList(), + linkedPeers = paykitSdkService.linkedPeers(), + paymentRequests = records, + receipts = emptyList(), + ) + } + } + @After fun tearDown() = test { sut.clear() } + @Test + fun `warm request observations remain readable while refresh waits and clear on identity change`() = test { + val record = paymentRequestRecord() + whenever(paykitSdkService.allPaymentRequests(anyOrNull())).thenReturn(listOf(record)) + sut.refresh(PaykitPaymentRequestRefreshMode.STORED).getOrThrow() + val pending = sut.pendingRequests.value + val history = sut.paymentRequestHistory.value + assertTrue(pending.isNotEmpty()) + val reading = CompletableDeferred() + val resume = CompletableDeferred() + whenever(paykitSdkService.allPaymentRequests(anyOrNull())).doSuspendableAnswer { + reading.complete(Unit) + resume.await() + listOf(record) + } + val refresh = async { sut.refresh(PaykitPaymentRequestRefreshMode.STORED) } + reading.await() + val started = testScheduler.currentTime + assertEquals(pending, sut.pendingRequests.value) + assertEquals(history, sut.paymentRequestHistory.value) + assertEquals(0L, testScheduler.currentTime - started) + assertFalse(refresh.isCompleted) + sut.activate(SECOND_IDENTITY) + assertTrue(sut.pendingRequests.value.isEmpty()) + assertTrue(sut.paymentRequestHistory.value.isEmpty()) + resume.complete(Unit) + refresh.await().getOrThrow() + assertTrue(sut.pendingRequests.value.isEmpty()) + assertTrue(sut.paymentRequestHistory.value.isEmpty()) + } + + @Test + fun `failed state observation preserves warm requests and history but fails cold refresh`() = test { + val pending = paymentRequestRecord() + val rejected = paymentRequestRecord().copy( + paymentRequestId = "rejected", + state = PaymentRequestLifecycleState.REJECTED, + ) + whenever(paykitSdkService.allPaymentRequests(anyOrNull())).thenReturn(listOf(pending, rejected)) + sut.refresh(PaykitPaymentRequestRefreshMode.STORED).getOrThrow() + val requests = sut.pendingRequests.value + val history = sut.paymentRequestHistory.value + assertTrue(requests.isNotEmpty()) + assertTrue(history.isNotEmpty()) + val failure = PaykitException.SharedStateBusy("shared_state_busy", "Unavailable") + whenever(paykitSdkService.observeState(any(), any())).doSuspendableAnswer { throw failure } + + assertSame(failure, sut.refresh(PaykitPaymentRequestRefreshMode.STORED).exceptionOrNull()) + assertEquals(requests, sut.pendingRequests.value) + assertEquals(history, sut.paymentRequestHistory.value) + sut.clear() + sut.activate(LOCAL_IDENTITY) + assertSame(failure, sut.refresh(PaykitPaymentRequestRefreshMode.STORED).exceptionOrNull()) + assertTrue(sut.pendingRequests.value.isEmpty()) + assertTrue(sut.paymentRequestHistory.value.isEmpty()) + } + @Test fun `refresh modes control message intake and outbound maintenance`() = test { val record = paymentRequestRecord() @@ -336,8 +410,9 @@ class PaykitPaymentRequestRepoTest : BaseUnitTest(StandardTestDispatcher()) { verify(paykitSdkService).processPendingPrivateMessages() verify(paykitSdkService, times(2)).receivePrivateMessagesFromLinkedPeers() - verify(paykitSdkService, times(3)).allPaymentRequests(LOCAL_IDENTITY, Priority.Background) - verify(paykitSdkService, times(3)).linkedPeers(Priority.Background) + 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) } @@ -360,7 +435,7 @@ class PaykitPaymentRequestRepoTest : BaseUnitTest(StandardTestDispatcher()) { sut.refreshAfterStateChange().getOrThrow() verify(paykitSdkService).processPendingPrivateMessages(Priority.Ordered) verify(paykitSdkService).receivePrivateMessagesFromLinkedPeers(Priority.Ordered) - verify(paykitSdkService).allPaymentRequests(LOCAL_IDENTITY, Priority.Ordered) + verify(paykitSdkService).observeState(LOCAL_IDENTITY, Priority.Ordered) verify(paykitSdkService, never()).processPendingPrivateMessages(Priority.Background) val record = paymentRequestRecord() @@ -419,7 +494,7 @@ class PaykitPaymentRequestRepoTest : BaseUnitTest(StandardTestDispatcher()) { assertEquals(2, reads) verify(paykitSdkService, times(if (failure == null) 2 else 1)).processPendingPrivateMessages() verify(paykitSdkService).receivePrivateMessagesFromLinkedPeers() - verify(paykitSdkService).allPaymentRequests(LOCAL_IDENTITY, Priority.Background) + verify(paykitSdkService).observeState(LOCAL_IDENTITY, Priority.Background) verify(paykitSdkService).allPaymentRequests(LOCAL_IDENTITY, Priority.Ordered) } } @@ -590,7 +665,7 @@ class PaykitPaymentRequestRepoTest : BaseUnitTest(StandardTestDispatcher()) { sut.paymentRequestHistory.value.single().lifecycleState, ) } - verify(paykitSdkService).allPaymentRequests(LOCAL_IDENTITY, Priority.Background) + verify(paykitSdkService).observeState(LOCAL_IDENTITY, Priority.Background) verify(paykitSdkService).allPaymentRequests(LOCAL_IDENTITY, Priority.Ordered) verify(paykitSdkService, never()).processPendingPrivateMessages() verify(paykitSdkService, never()).receivePrivateMessagesFromLinkedPeers() @@ -625,10 +700,10 @@ class PaykitPaymentRequestRepoTest : BaseUnitTest(StandardTestDispatcher()) { assertTrue(sut.pendingRequests.value.isEmpty()) verify(paykitSdkService, times(2)).linkedPeers() - verify(paykitSdkService).linkedPeers(Priority.Background) - verify(paykitSdkService).linkedPeers(Priority.Ordered) - verify(paykitSdkService).allPaymentRequests(LOCAL_IDENTITY, Priority.Background) - verify(paykitSdkService).allPaymentRequests(LOCAL_IDENTITY, Priority.Ordered) + verify(paykitSdkService).observeState(LOCAL_IDENTITY, Priority.Background) + verify(paykitSdkService).observeState(LOCAL_IDENTITY, Priority.Ordered) + verify(paykitSdkService, never()).allPaymentRequests(anyOrNull(), any()) + verify(paykitSdkService, never()).linkedPeers(any()) } @Test diff --git a/app/src/test/java/to/bitkit/repositories/PubkyRepoTest.kt b/app/src/test/java/to/bitkit/repositories/PubkyRepoTest.kt index ddb9d16f6b..13e049ec03 100644 --- a/app/src/test/java/to/bitkit/repositories/PubkyRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/PubkyRepoTest.kt @@ -54,6 +54,7 @@ import org.mockito.kotlin.any import org.mockito.kotlin.anyOrNull import org.mockito.kotlin.atLeastOnce import org.mockito.kotlin.doAnswer +import org.mockito.kotlin.doReturn import org.mockito.kotlin.doSuspendableAnswer import org.mockito.kotlin.eq import org.mockito.kotlin.mock @@ -154,6 +155,7 @@ class PubkyRepoTest : BaseUnitTest() { Unit } whenever(pubkyService.contactRecords()).thenReturn(emptyList()) + whenever { pubkyService.observedContactRecords(any()) }.doSuspendableAnswer { pubkyService.contactRecords() } whenever { pubkyService.initializeAndImportSession(any()) }.doSuspendableAnswer { runSuspendCatching { pubkyService.importSession(it.getArgument(0)) } } @@ -2788,7 +2790,7 @@ class PubkyRepoTest : BaseUnitTest() { } @Test - fun `initialize should complete contacts load after contact fetch failure`() = test { + fun `initialize completes failed contact observation without marking a successful load`() = test { val session = "saved_session" val unprefixedPublicKey = VALID_SELF_KEY.removePrefix("pubky") val pubkyProfile = createPubkyProfile(name = "Restored User") @@ -2797,13 +2799,14 @@ class PubkyRepoTest : BaseUnitTest() { whenever(pubkyService.importSession(session)).thenReturn(unprefixedPublicKey) whenever(pubkyService.resolveContactProfile(VALID_SELF_KEY, true)) .thenReturn(createResolution(VALID_SELF_KEY, pubkyProfile = pubkyProfile)) - whenever(pubkyService.contactRecords()).thenAnswer { throw TestAppError("Offline") } + whenever(pubkyService.observedContactRecords(VALID_SELF_KEY)).thenAnswer { throw TestAppError("Offline") } sut.initialize() assertEquals(1L, sut.contactsLoadCompletionVersion.value) assertEquals(0L, sut.contactsLoadVersion.value) assertTrue(sut.contacts.value.isEmpty()) + assertFalse(sut.isLoadingContacts.value) } @Test @@ -3500,6 +3503,27 @@ class PubkyRepoTest : BaseUnitTest() { assertEquals(listOf(VALID_CONTACT_KEY_A), sut.contacts.value.map { it.publicKey }) } + @Test + fun `failed contact observation preserves warm display without advancing successful load`() = test { + authenticateForTesting(publicKey = VALID_SELF_KEY) + whenever(pubkyService.observedContactRecords(VALID_SELF_KEY)).thenReturn( + listOf(createContactRecord(VALID_CONTACT_KEY_A, profile = createPaykitProfile("Saved"))), + ) + sut.loadContacts() + val contacts = sut.contacts.value + val version = sut.contactsLoadVersion.value + val completionVersion = sut.contactsLoadCompletionVersion.value + whenever(pubkyService.observedContactRecords(VALID_SELF_KEY)) + .doSuspendableAnswer { throw PaykitException.SharedStateBusy("shared_state_busy", "Unavailable") } + + sut.loadContacts() + + assertEquals(contacts, sut.contacts.value) + assertEquals(version, sut.contactsLoadVersion.value) + assertEquals(completionVersion + 1, sut.contactsLoadCompletionVersion.value) + assertFalse(sut.isLoadingContacts.value) + } + @Test fun `loadContacts should return early when no public key`() = test { sut.loadContacts() @@ -3822,6 +3846,29 @@ class PubkyRepoTest : BaseUnitTest() { assertEquals(listOf("Edited"), sut.contacts.value.map { it.name }) } + @Test + fun `contact observation from an ended sign-in is hidden after the same identity returns`() = test { + authenticateForTesting(publicKey = VALID_SELF_KEY) + val release = CompletableDeferred() + whenever(pubkyService.observedContactRecords(VALID_SELF_KEY)).doSuspendableAnswer { + release.await() + listOf(createContactRecord(VALID_CONTACT_KEY_A, "Stale")) + } + val load = async { sut.loadContacts() } + runCurrent() + assertTrue(sut.signOut().isSuccess) + assertTrue(sut.contacts.value.isEmpty()) + doReturn(emptyList()).whenever(pubkyService).observedContactRecords(VALID_SELF_KEY) + val signIn = async { sut.initialize() } + runCurrent() + release.complete(Unit) + load.await() + signIn.await() + + assertEquals(VALID_SELF_KEY, sut.publicKey.value) + assertTrue(sut.contacts.value.isEmpty()) + } + @Test fun `a contact edit from an ended sign-in saves nothing, also once the same identity signs back in`() = test { val store = stubGatedPubkyStore() diff --git a/app/src/test/java/to/bitkit/services/PaykitKeyGenerationTest.kt b/app/src/test/java/to/bitkit/services/PaykitKeyGenerationTest.kt index 88d71dcff6..3a548d24cf 100644 --- a/app/src/test/java/to/bitkit/services/PaykitKeyGenerationTest.kt +++ b/app/src/test/java/to/bitkit/services/PaykitKeyGenerationTest.kt @@ -6,6 +6,7 @@ import com.synonym.paykit.PaykitAppRegistry import com.synonym.paykit.PaykitException import com.synonym.paykit.PaykitIdentitySecretKey import com.synonym.paykit.PaykitSdk +import com.synonym.paykit.PaykitStateObservation import com.synonym.paykit.PubkyIdentityCapability import com.synonym.paykit.PubkyLocalSecretKey import com.synonym.paykit.PubkySessionAccess @@ -33,6 +34,21 @@ class PaykitKeyGenerationTest { private const val RING_PUBKY = "3rsduhcxpw74snwyct86m38c63j3pq8x4ycqikxg64roik8yw5xg" } + private suspend fun assertObservationGeneration(sdk: PaykitSdk, service: PaykitSdkService, generation: ULong) { + val observation = PaykitStateObservation( + publicKey = RING_PUBKY, + keyGeneration = generation, + observedAt = "2026-10-11T00:00:00Z", + contacts = emptyList(), + linkedPeers = emptyList(), + paymentRequests = emptyList(), + receipts = emptyList(), + ) + whenever { sdk.observeState() }.thenReturn(observation.copy(keyGeneration = 1uL), observation) + assertFailsWith { service.observeState(RING_PUBKY) } + assertEquals(observation, service.observeState(RING_PUBKY)) + } + @Test fun `authorization derives the registry generation and persists its identity scoped floor`() = runTest { for (registryGeneration in listOf(null, 7uL)) { @@ -98,8 +114,10 @@ class PaykitKeyGenerationTest { val provider = providers.constructed().single() repeat(2) { assertEquals(RING_PUBKY, service.currentPublicKey()) } verify(sdk).paykitAppRegistry(RING_PUBKY) + assertObservationGeneration(sdk, service, 2uL) assertSame(staleKey, assertFailsWith { service.currentPublicKey() }) assertEquals(RING_PUBKY, service.currentPublicKey()) + assertObservationGeneration(sdk, service, 3uL) assertSame(staleKey, assertFailsWith { service.currentPublicKey() }) repeat(2) { val error = assertFailsWith { service.currentPublicKey() } diff --git a/app/src/test/java/to/bitkit/services/PaykitSdkServiceTest.kt b/app/src/test/java/to/bitkit/services/PaykitSdkServiceTest.kt index e25f9f4476..75cdb94d48 100644 --- a/app/src/test/java/to/bitkit/services/PaykitSdkServiceTest.kt +++ b/app/src/test/java/to/bitkit/services/PaykitSdkServiceTest.kt @@ -15,6 +15,7 @@ import com.synonym.paykit.PaykitAppRegistry import com.synonym.paykit.PaykitException import com.synonym.paykit.PaykitIdentitySecretKey import com.synonym.paykit.PaykitSdk +import com.synonym.paykit.PaykitStateObservation import com.synonym.paykit.PaymentRequestLifecycleState import com.synonym.paykit.PaymentRequestLocalRole import com.synonym.paykit.PaymentRequestRecord @@ -104,6 +105,84 @@ class PaykitSdkServiceTest { private val READ_TIMEOUT = 10.seconds } + @Test + fun `state observation validates identity and propagates unavailable session failures`() = runTest { + val observation = PaykitStateObservation( + publicKey = "pubky$RING_PUBKY", + keyGeneration = 0uL, + observedAt = "2026-10-11T00:00:00Z", + contacts = emptyList(), + linkedPeers = emptyList(), + paymentRequests = emptyList(), + receipts = emptyList(), + ) + val sdk = mock() + whenever(sdk.observeState()).thenReturn(observation) + val service = PaykitSdkService(mock(), mock(), mock(), settingsStore = mock()) { sdk } + + assertEquals(observation, service.observeState(RING_PUBKY)) + assertFailsWith { service.observeState("another-identity") } + val failure = PaykitException.Identity("identity_error", "No active session") + whenever(sdk.observeState()).thenThrow(failure) + assertSame(failure, assertFailsWith { service.observeState(RING_PUBKY) }) + verify(sdk, never()).identityStatus() + verify(sdk, never()).contactRecords() + verify(sdk, never()).listPaymentRequests(any()) + } + + @Test + fun `queued state observation is rejected after runtime replacement`() = runTest { + val sdk = mock() + val release = CompletableDeferred() + whenever(sdk.contactRecords()).doSuspendableAnswer { + release.await() + emptyList() + } + val service = PaykitSdkService(mock(), mock(), mock(), settingsStore = mock()) { sdk } + val active = async { service.contactRecords() } + runCurrent() + val reset = async { service.clearState() } + val observation = async { runSuspendCatching { service.observeState(RING_PUBKY) } } + runCurrent() + release.complete(Unit) + active.await() + reset.await() + + assertIs(observation.await().exceptionOrNull()) + verify(sdk, never()).observeState() + } + + @Test + fun `cancelled state observation retains native ownership until completion`() = runTest { + val sdk = mock() + val release = CompletableDeferred() + whenever(sdk.observeState()).doSuspendableAnswer { + release.await() + PaykitStateObservation( + publicKey = "pubky$RING_PUBKY", + keyGeneration = 0uL, + observedAt = "2026-10-11T00:00:00Z", + contacts = emptyList(), + linkedPeers = emptyList(), + paymentRequests = emptyList(), + receipts = emptyList(), + ) + } + whenever(sdk.contactRecords()).thenReturn(emptyList()) + val service = PaykitSdkService(mock(), mock(), mock(), settingsStore = mock()) { sdk } + val observation = async { service.observeState(RING_PUBKY) } + runCurrent() + observation.cancel() + val contacts = async { service.contactRecords() } + runCurrent() + assertFalse(contacts.isCompleted) + verify(sdk, never()).contactRecords() + release.complete(Unit) + + assertFailsWith { observation.await() } + assertEquals(emptyList(), contacts.await()) + } + @Test @OptIn(ExperimentalCoroutinesApi::class) fun `request discovery completes while unrelated SDK work is blocked`() = runTest { diff --git a/app/src/test/java/to/bitkit/ui/screens/contacts/ContactSaveSessionChangeTest.kt b/app/src/test/java/to/bitkit/ui/screens/contacts/ContactSaveSessionChangeTest.kt index 148b447c0f..9645c467ea 100644 --- a/app/src/test/java/to/bitkit/ui/screens/contacts/ContactSaveSessionChangeTest.kt +++ b/app/src/test/java/to/bitkit/ui/screens/contacts/ContactSaveSessionChangeTest.kt @@ -104,6 +104,7 @@ class ContactSaveSessionChangeTest : BaseUnitTest() { Unit } whenever { pubkyService.contactRecords() }.thenReturn(emptyList()) + whenever { pubkyService.observedContactRecords(any()) }.doSuspendableAnswer { pubkyService.contactRecords() } whenever { pubkyService.signOut() }.thenAnswer { sdkIdentity = null Unit diff --git a/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt b/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt index ce0a4a79d7..971a1c52af 100644 --- a/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt +++ b/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt @@ -8963,22 +8963,25 @@ class AppViewModelSendFlowTest : BaseUnitTest() { pubkyPublicKey.value = testPublicKey enablePaykitUi() runCurrent() - clearInvocations(paykitPaymentRequestRepo) + clearInvocations(paykitPaymentRequestRepo, paykitPaymentProofRepo) paymentSubmissionActive.value = true repeat(3) { proofStateVersion.value++ runCurrent() } verify(paykitPaymentRequestRepo, never()).refreshAfterStateChange(any()) + verify(paykitPaymentProofRepo, never()).reconcile() paymentSubmissionActive.value = false runCurrent() verify(paykitPaymentRequestRepo).refreshAfterStateChange(PaykitPaymentRequestRefreshMode.STORED) + verify(paykitPaymentProofRepo).reconcile() paymentSubmissionActive.value = true runCurrent() paymentSubmissionActive.value = false runCurrent() verify(paykitPaymentRequestRepo, times(1)).refreshAfterStateChange(any()) + verify(paykitPaymentProofRepo, times(1)).reconcile() } @Test @@ -9047,7 +9050,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { completionStarted.await() verify(paykitPaymentRequestRepo).refreshAfterStateChange(PaykitPaymentRequestRefreshMode.STORED) - verify(paykitPaymentProofRepo, never()).reconcile() + verify(paykitPaymentProofRepo).reconcile() verify(paykitPaymentRequestRepo, never()).refresh(PaykitPaymentRequestRefreshMode.FULL) finishCompletion.complete(Unit) advanceUntilIdle() diff --git a/changelog.d/next/1458.fixed.md b/changelog.d/next/1458.fixed.md new file mode 100644 index 0000000000..11387854d3 --- /dev/null +++ b/changelog.d/next/1458.fixed.md @@ -0,0 +1 @@ +Completed Paykit payments no longer wait for proof delivery after their payment details have been safely saved. diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index b47a6d06f7..9562316c50 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -22,7 +22,7 @@ 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.5.18" } -paykit = { module = "com.synonym:paykit-android", version = "0.1.0-rc72" } +paykit = { module = "com.synonym:paykit-android", version = "0.1.0-rc73" } bouncycastle-provider-jdk = { module = "org.bouncycastle:bcprov-jdk18on", version = "1.83" } camera-camera2 = { module = "androidx.camera:camera-camera2", version.ref = "camera" } camera-lifecycle = { module = "androidx.camera:camera-lifecycle", version.ref = "camera" } diff --git a/journeys/payment-requests/README.md b/journeys/payment-requests/README.md index 4b7ae32035..c2eeb861eb 100644 --- a/journeys/payment-requests/README.md +++ b/journeys/payment-requests/README.md @@ -58,6 +58,28 @@ explicit refreshes, and maintenance rounds after 30 seconds, then every 60 secon Maintenance uses elapsed time, including slow requests; polling remains serialized and does not start catch-up rounds. Incoming messages wait for the next poll plus synchronization time. +## Durable proof handoff (manual) + +This check needs a disposable regtest fixture with independently controlled payment acceptance, +local storage failures, and delayed Paykit proof submission. These fault controls are not journey +capabilities. Report it as blocked when the fixture or payment authorization is unavailable. + +1. Hold remote proof submission after a successful payment and verify the accepted activity and + exact proof are durably stored. Local completion must finish without waiting for the hold, + without claiming the merchant has unlocked anything. No additional payment may be dispatched. +2. Restart before releasing the hold. Release it and verify the same proof is delivered once the + original identity is active, without another payment or duplicate activity. +3. Repeat with an unknown payment outcome. It must remain unresolved, retaining the original + payment guard and not claiming success or allowing a fresh payment. +4. Fail local proof storage and hold delivery. Local completion must wait for successful delivery + or durable retry storage; failing both must preserve the unresolved follow-up. +5. Switch identities or sign out before delivery, then reset the wallet in a separate run. Proofs + must not be submitted under a different identity, and reset state must not be repopulated by a + delayed in-memory handoff. Use only disposable fixture data for reset. + +The automated repository tests exercise these boundaries without broadcasting funds. Controlled +submission delays establish local completion isolation, not real-network linking performance. + ## Accepting install Acceptance intent is saved before the remote operation and included in wallet backups.