From 9d601a107dccd938c3770bd2e55ef4524339cb48 Mon Sep 17 00:00:00 2001 From: benk10 Date: Thu, 15 Oct 2026 16:10:14 -0500 Subject: [PATCH 1/2] fix: keep wallet backups progressing during paykit activity --- .../java/to/bitkit/repositories/BackupRepo.kt | 57 ++++-- .../bitkit/repositories/PrivatePaykitRepo.kt | 16 +- .../to/bitkit/services/PaykitSdkService.kt | 12 +- .../to/bitkit/repositories/BackupRepoTest.kt | 177 +++++++++++++++++- .../repositories/PrivatePaykitRepoTest.kt | 6 +- .../bitkit/services/PaykitSdkServiceTest.kt | 34 +++- changelog.d/next/1506.fixed.md | 1 + .../wallet-snapshot-consistency.md | 27 +++ 8 files changed, 300 insertions(+), 30 deletions(-) create mode 100644 changelog.d/next/1506.fixed.md create mode 100644 journeys/backup-restore/wallet-snapshot-consistency.md diff --git a/app/src/main/java/to/bitkit/repositories/BackupRepo.kt b/app/src/main/java/to/bitkit/repositories/BackupRepo.kt index 0e043fab7c..b3ea6df740 100644 --- a/app/src/main/java/to/bitkit/repositories/BackupRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/BackupRepo.kt @@ -27,6 +27,7 @@ import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.withContext import kotlinx.coroutines.withTimeout +import kotlinx.coroutines.yield import kotlinx.serialization.Serializable import kotlinx.serialization.json.JsonArray import kotlinx.serialization.json.JsonObject @@ -69,6 +70,7 @@ import to.bitkit.services.CoreService import to.bitkit.services.LightningService import to.bitkit.services.PaykitSdkService import to.bitkit.ui.shared.toast.ToastEventBus +import to.bitkit.utils.AppError import to.bitkit.utils.Logger import to.bitkit.utils.NetworkValidationHelper import to.bitkit.utils.jsonLogOf @@ -583,7 +585,7 @@ class BackupRepo @Inject constructor( private suspend fun markBackupFailed(category: BackupCategory, backupRequired: Long, e: Throwable) { withContext(NonCancellable) { cacheStore.updateBackupStatus(category) { - if (it.required == backupRequired) { + if (it.required == backupRequired && e !is WalletBackupSnapshotChanged) { failedBackupRequired[category] = backupRequired } else { failedBackupRequired -= category @@ -592,7 +594,11 @@ class BackupRepo @Inject constructor( } runningBackups -= category } - Logger.error("Backup failed for: '$category'", e, context = TAG) + if (e is WalletBackupSnapshotChanged) { + Logger.debug("Deferred changing backup snapshot for: '$category'", context = TAG) + } else { + Logger.error("Backup failed for: '$category'", e, context = TAG) + } } private suspend fun getBackupDataBytes(category: BackupCategory): ByteArray = when (category) { @@ -679,15 +685,32 @@ class BackupRepo @Inject constructor( } private suspend fun getWalletBackupDataBytes(usdt: String? = null): ByteArray { + val sdkGeneration = paykitSdkService.runtimeGeneration + var attemptsRemaining = WALLET_SNAPSHOT_ATTEMPTS + while (true) { + check(paykitSdkService.runtimeGeneration == sdkGeneration) { + "Paykit identity changed during wallet snapshot" + } + val snapshot = runSuspendCatching { captureWalletBackupDataBytes(usdt, sdkGeneration) } + attemptsRemaining-- + if (snapshot.exceptionOrNull() !is WalletBackupSnapshotChanged || attemptsRemaining == 0) { + return snapshot.getOrThrow() + } + yield() + } + } + + private suspend fun captureWalletBackupDataBytes(usdt: String?, sdkGeneration: Long): ByteArray { check(!keychain.exists(Keychain.Key.PAYKIT_PENDING_BACKUP_RESTORE.name)) { "Wallet backup restore is incomplete" } lateinit var transfers: List - val privateReservations = privatePaykitAddressReservationRepo.get().backupSnapshot().getOrThrow() val privateRepo = privatePaykitRepo.get() val privateVersion = privateRepo.backupStateVersion.value - val sdkVersion = paykitSdkService.backupStateVersion.value val paykitSdkBackupState = privateRepo.backupSnapshot().getOrThrow() + val reservationRepo = privatePaykitAddressReservationRepo.get() + val reservationVersion = reservationRepo.backupStateVersion.value + val privateReservations = reservationRepo.backupSnapshot().getOrThrow() val watchOnlyAccountSnapshot = watchOnlyAccountStore.backupSnapshot() val walletIndex = backupWalletIndex() @@ -705,37 +728,41 @@ class BackupRepo @Inject constructor( it.originalInputs == null && it.candidateTxids.isEmpty() } != true ) { "Waiting for signed receipt before wallet backup" } - check( - privateRepo.backupStateVersion.value == privateVersion && - paykitSdkService.backupStateVersion.value == sdkVersion - ) { "Private payment state changed during wallet snapshot" } - val proofs = snapshotProofs val active = snapshotAttempt?.let { attempt -> val wire = ActiveOnchainAttemptBackup.from( attempt, Env.network.name.lowercase(), vssStoreIdProvider.getBackupWalletBinding(walletIndex), ) - validateActiveProof(wire, proofs) + validateActiveProof(wire, snapshotProofs) wire } val payload = WalletBackupV1( createdAt = currentTimeMillis(), transfers = transfers, privatePaykitHighestReservedReceiveIndexByAddressType = privateReservations, - paykitSdkBackupState = paykitSdkBackupState, + paykitSdkBackupState = paykitSdkBackupState.state, watchOnlyAccounts = watchOnlyAccountSnapshot.accounts, watchOnlyAccountAllocationState = watchOnlyAccountSnapshot.allocationState, usdtWallet = usdt ?: usdtRepo.get().backupSnapshot(), paykitPaymentState = PaykitPaymentStateBackup( subscriptions = paykitPresentationStore.backupSnapshot(), - pendingProofs = proofs, + pendingProofs = snapshotProofs, activeOnchainAttempt = active, usdt = paykitUsdtPaymentRepo.get().backupSnapshot(), acceptedOneTimeRequests = paykitPresentationStore.acceptedOneTimeBackupSnapshot(), ), ) + check(paykitSdkService.runtimeGeneration == sdkGeneration) { + "Paykit identity changed during wallet snapshot" + } + if (privateRepo.backupStateVersion.value != privateVersion || + paykitSdkService.backupStateVersion.value != paykitSdkBackupState.sdkVersion || + reservationRepo.backupStateVersion.value != reservationVersion + ) { + throw WalletBackupSnapshotChanged() + } return json.encodeToString(payload).toByteArray() } @@ -1080,6 +1107,10 @@ class BackupRepo @Inject constructor( private const val MINUTE_IN_MS = 60_000 private const val BACKUP_DEBOUNCE = 5000L // 5 seconds + + /** Maximum captures before deferring a changing wallet to the next scheduled backup. */ + private const val WALLET_SNAPSHOT_ATTEMPTS = 3 + private const val BACKUP_CHECK_INTERVAL = 60 * 1000L // 1 minute private const val FAILED_BACKUP_CHECK_TIME = 30 * 60 * 1000L // 30 minutes private const val FAILED_BACKUP_NOTIFICATION_INTERVAL = 10 * 60 * 1000L // 10 minutes @@ -1094,6 +1125,8 @@ class BackupRepo @Inject constructor( } } +internal class WalletBackupSnapshotChanged : AppError("Private payment state changed during wallet snapshot") + /** * Result of handing a backup envelope's Core-owned fields to Core for migration. * diff --git a/app/src/main/java/to/bitkit/repositories/PrivatePaykitRepo.kt b/app/src/main/java/to/bitkit/repositories/PrivatePaykitRepo.kt index 960c29d236..0b4759a492 100644 --- a/app/src/main/java/to/bitkit/repositories/PrivatePaykitRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/PrivatePaykitRepo.kt @@ -697,20 +697,24 @@ class PrivatePaykitRepo @Inject constructor( } } - suspend fun backupSnapshot(): Result = + internal suspend fun backupSnapshot(): Result = withContext(serializedDispatcher) { runSuspendCatching { - pubkyService.currentPublicKey() ?: return@runSuspendCatching null - json.encodeToString( + val sdkVersion = paykitSdkService.backupStateVersion.value + pubkyService.currentPublicKey() + ?: return@runSuspendCatching PrivatePaykitBackupSnapshot(null, sdkVersion) + // This snapshot can be required before broadcast; it must not wait for payment to finish. + val exported = paykitSdkService.exportBackupState(Priority.Interactive) + val state = json.encodeToString( PrivatePaykitBackup( - // This snapshot can be required before broadcast; it must not wait for payment to finish. - sdkState = paykitSdkService.exportBackupState(Priority.Interactive), + sdkState = exported.state, consumedPrivatePaymentListVersions = ensureState().contacts .mapNotNull { (publicKey, contactState) -> contactState.consumedPrivatePaymentListVersion?.let { publicKey to it } }.toMap(), ) ) + PrivatePaykitBackupSnapshot(state, exported.version) } } @@ -2262,3 +2266,5 @@ class PrivatePaykitRepo @Inject constructor( _backupStateVersion.update { it + 1 } } } + +internal data class PrivatePaykitBackupSnapshot(val state: String?, val sdkVersion: Long) diff --git a/app/src/main/java/to/bitkit/services/PaykitSdkService.kt b/app/src/main/java/to/bitkit/services/PaykitSdkService.kt index 9d1e6ac01c..cca86961c0 100644 --- a/app/src/main/java/to/bitkit/services/PaykitSdkService.kt +++ b/app/src/main/java/to/bitkit/services/PaykitSdkService.kt @@ -265,7 +265,8 @@ class PaykitSdkService @Inject constructor( private var sdk: PaykitSdk? = null @Volatile - private var runtimeGeneration = 0L + internal var runtimeGeneration = 0L + private set private var cachedPaykitKey: PaykitKeyGeneration? = null private var cachedBackupState: PaykitBackupStateSnapshot? = null @@ -1295,17 +1296,18 @@ class PaykitSdkService @Inject constructor( }, ) - internal suspend fun exportBackupState(priority: Priority = Priority.Background): String { + internal suspend fun exportBackupState(priority: Priority = Priority.Background): PaykitSdkBackupExport { isSetup.await() val generation = runtimeGeneration return operationLock.withoutLock { - var backup: String? + var backup: PaykitSdkBackupExport? do { if (priority == Priority.Background) isPaymentSubmissionActive.first { !it } backup = operationLock.withLock(priority) { check(runtimeGeneration == generation) { "Paykit runtime changed before backup export" } if (priority == Priority.Background && isPaymentSubmissionActive.value) return@withLock null - withPaykitKey { completeSdkCall { it.exportBackupString() } } + val state = withPaykitKey { completeSdkCall { it.exportBackupString() } } + PaykitSdkBackupExport(state, backupStateVersion.value) } } while (backup == null) backup @@ -1628,6 +1630,8 @@ private data class PaykitKeyGeneration(val publicKey: String, val generation: UL internal data class PaykitBackupStateSnapshot(val stateRevision: String, val backupRevision: String) +internal data class PaykitSdkBackupExport(val state: String, val version: Long) + @Suppress("LongParameterList") internal suspend fun withPaykitBackupStateTracking( readRevision: suspend () -> String, diff --git a/app/src/test/java/to/bitkit/repositories/BackupRepoTest.kt b/app/src/test/java/to/bitkit/repositories/BackupRepoTest.kt index 28e5da5884..f32da1cd04 100644 --- a/app/src/test/java/to/bitkit/repositories/BackupRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/BackupRepoTest.kt @@ -163,7 +163,7 @@ class BackupRepoTest : BaseUnitTest() { whenever { vssBackupClientLdk.setup() }.thenReturn(Result.success(Unit)) whenever { transferDao.getAll() }.thenReturn(emptyList()) whenever { privatePaykitRepo.restoreBackup(anyOrNull()) }.thenReturn(Result.success(Unit)) - whenever { privatePaykitRepo.backupSnapshot() }.thenReturn(Result.success(null)) + whenever { privatePaykitRepo.backupSnapshot() }.thenReturn(Result.success(PrivatePaykitBackupSnapshot(null, 0))) whenever(privatePaykitRepo.backupStateVersion).thenReturn(MutableStateFlow(0L)) whenever(paykitSdkService.backupStateVersion).thenReturn(MutableStateFlow(0L)) whenever { paykitPaymentProofRepo.backupSnapshot() }.thenReturn(emptyList()) @@ -175,6 +175,7 @@ class BackupRepoTest : BaseUnitTest() { whenever(paykitPresentationStore.backupSnapshot()).thenReturn(emptyMap()) whenever { privatePaykitAddressReservationRepo.restoreBackup(any()) }.thenReturn(Result.success(Unit)) whenever { privatePaykitAddressReservationRepo.backupSnapshot() }.thenReturn(Result.success(null)) + whenever(privatePaykitAddressReservationRepo.backupStateVersion).thenReturn(MutableStateFlow(0L)) whenever { privatePaykitAddressReservationRepo.reconcileReservedIndexesWithLdk() }.thenReturn(Result.success(Unit)) @@ -207,7 +208,9 @@ class BackupRepoTest : BaseUnitTest() { verify(privatePaykitRepo, never()).backupSnapshot() verify(vssBackupClient, never()).putObject(eq(BackupCategory.WALLET.name), any()) - whenever(privatePaykitRepo.backupSnapshot()).thenReturn(Result.success("pending-write")) + whenever(privatePaykitRepo.backupSnapshot()).thenReturn( + Result.success(PrivatePaykitBackupSnapshot("pending-write", 0)), + ) paymentSubmissionActive.value = false runCurrent() @@ -277,7 +280,7 @@ class BackupRepoTest : BaseUnitTest() { paymentSubmissionActive.value = true whenever(privatePaykitRepo.backupSnapshot()).doSuspendableAnswer { paymentSubmissionActive.first { !it } - Result.success("pending-write") + Result.success(PrivatePaykitBackupSnapshot("pending-write", 0)) } val backup = launch { sut.triggerBackup(BackupCategory.WALLET) } @@ -437,13 +440,177 @@ class BackupRepoTest : BaseUnitTest() { val version = MutableStateFlow(0L) whenever(privatePaykitRepo.backupStateVersion).thenReturn(version) whenever { onchainSendAttemptStore.backupSnapshot(any(), any()) }.doSuspendableAnswer { - version.value = 1L - null to emptyList() + version.update { current -> current + 1 } + val capture = it.getArgument List>(1) + null to capture() + } + assertTrue(sut.triggerBackup(BackupCategory.WALLET).isFailure) + verify(privatePaykitRepo, times(3)).backupSnapshot() + verify(vssBackupClient, never()).putObject(eq(BackupCategory.WALLET.name), any()) + } + + @Test + fun `wallet backup recaptures after private state changes without uploading the discarded snapshot`() = test { + val version = MutableStateFlow(0L) + whenever(privatePaykitRepo.backupStateVersion).thenReturn(version) + whenever(privatePaykitRepo.backupSnapshot()).doSuspendableAnswer { + Result.success(PrivatePaykitBackupSnapshot("private-state-${version.value}", 0)) + } + var captures = 0 + whenever(onchainSendAttemptStore.backupSnapshot(eq(0), any())).doSuspendableAnswer { + captures++ + if (captures == 1) version.update { current -> current + 1 } + val capture = it.getArgument List>(1) + null to capture() + } + + sut.triggerBackup(BackupCategory.WALLET).getOrThrow() + + assertEquals(2, captures) + val payload = argumentCaptor() + verify(vssBackupClient).putObject(eq(BackupCategory.WALLET.name), payload.capture()) + val backup = json.decodeFromString(payload.firstValue.decodeToString()) + assertEquals("private-state-1", backup.paykitSdkBackupState) + } + + @Test + fun `wallet backup accepts SDK changes included in its queued export`() = test { + val sdkVersion = MutableStateFlow(0L) + whenever(paykitSdkService.backupStateVersion).thenReturn(sdkVersion) + whenever(privatePaykitRepo.backupSnapshot()).doSuspendableAnswer { + sdkVersion.update { current -> current + 1 } + Result.success(PrivatePaykitBackupSnapshot("latest-sdk-state", sdkVersion.value)) + } + + sut.triggerBackup(BackupCategory.WALLET).getOrThrow() + + val payload = argumentCaptor() + verify(vssBackupClient).putObject(eq(BackupCategory.WALLET.name), payload.capture()) + val backup = json.decodeFromString(payload.firstValue.decodeToString()) + assertEquals("latest-sdk-state", backup.paykitSdkBackupState) + verify(privatePaykitRepo).backupSnapshot() + } + + @Test + fun `wallet backup checks SDK and reservation changes through the last capture`() = test { + val sdkVersion = MutableStateFlow(0L) + val reservationVersion = MutableStateFlow(0L) + whenever(paykitSdkService.backupStateVersion).thenReturn(sdkVersion) + whenever(privatePaykitAddressReservationRepo.backupStateVersion).thenReturn(reservationVersion) + for (version in listOf(sdkVersion, reservationVersion)) { + whenever(privatePaykitRepo.backupSnapshot()).doSuspendableAnswer { + Result.success(PrivatePaykitBackupSnapshot("sdk-${sdkVersion.value}", sdkVersion.value)) + } + whenever(paykitUsdtPaymentRepo.backupSnapshot()).thenAnswer { + version.update { current -> current + 1 } + PaykitUsdtStateBackup(emptyList(), emptyList()) + } + + assertTrue(sut.triggerBackup(BackupCategory.WALLET).exceptionOrNull() is WalletBackupSnapshotChanged) + } + verify(vssBackupClient, never()).putObject(eq(BackupCategory.WALLET.name), any()) + } + + @Test + fun `wallet backup rejects identity replacement between snapshot attempts`() = test { + var generation = 0L + val version = MutableStateFlow(0L) + var versionReads = 0 + whenever(paykitSdkService.runtimeGeneration).thenAnswer { generation } + whenever(privatePaykitRepo.backupStateVersion).thenAnswer { + // Replace the identity after its final check, as the first snapshot is discarded. + if (++versionReads == 2) generation++ + version + } + whenever(onchainSendAttemptStore.backupSnapshot(eq(0), any())).doSuspendableAnswer { + version.update { current -> current + 1 } + val capture = it.getArgument List>(1) + null to capture() + } + + val error = sut.triggerBackup(BackupCategory.WALLET).exceptionOrNull() + + assertEquals("Paykit identity changed during wallet snapshot", error?.message) + verify(privatePaykitRepo).backupSnapshot() + verify(vssBackupClient, never()).putObject(eq(BackupCategory.WALLET.name), any()) + } + + @Test + fun `wallet backup rejects identity replacement without retrying into the new identity`() = test { + var generation = 0L + whenever(paykitSdkService.runtimeGeneration).thenAnswer { generation } + whenever(privatePaykitRepo.backupSnapshot()).doSuspendableAnswer { + generation++ + Result.success(PrivatePaykitBackupSnapshot("replacement", 0)) } + assertTrue(sut.triggerBackup(BackupCategory.WALLET).isFailure) + + verify(privatePaykitRepo).backupSnapshot() verify(vssBackupClient, never()).putObject(eq(BackupCategory.WALLET.name), any()) } + @Test + fun `automatic backup retries exhausted contention after state becomes quiet`() = test { + val statuses = MutableStateFlow(mapOf(BackupCategory.WALLET to BackupItemStatus(required = 1_000))) + stubBackupStatuses(statuses, CompletableDeferred(Unit)) {} + stubBackupObservers() + val version = MutableStateFlow(0L) + whenever(privatePaykitRepo.backupStateVersion).thenReturn(version) + var collisions = 3 + whenever(onchainSendAttemptStore.backupSnapshot(eq(0), any())).doSuspendableAnswer { + if (collisions-- > 0) version.update { current -> current + 1 } + val capture = it.getArgument List>(1) + null to capture() + } + try { + sut.startObservingBackups() + runCurrent() + advanceTimeBy(5_000) + runCurrent() + verify(vssBackupClient, never()).putObject(eq(BackupCategory.WALLET.name), any()) + assertTrue(statuses.value.getValue(BackupCategory.WALLET).isRequired) + + advanceTimeBy(5_000) + runCurrent() + + verify(vssBackupClient).putObject(eq(BackupCategory.WALLET.name), any()) + assertFalse(statuses.value.getValue(BackupCategory.WALLET).isRequired) + } finally { + sut.stopObservingBackups() + } + } + + @Test + fun `automatic backup reschedules snapshot contention without a new required revision`() = test { + val statuses = MutableStateFlow(mapOf(BackupCategory.WALLET to BackupItemStatus(required = 1_000))) + stubBackupStatuses(statuses, CompletableDeferred(Unit)) {} + stubBackupObservers() + whenever(privatePaykitRepo.backupSnapshot()).thenReturn( + Result.failure(WalletBackupSnapshotChanged()), + Result.failure(WalletBackupSnapshotChanged()), + Result.failure(WalletBackupSnapshotChanged()), + Result.success(PrivatePaykitBackupSnapshot(null, 0)), + ) + try { + sut.startObservingBackups() + runCurrent() + advanceTimeBy(5_000) + runCurrent() + verify(privatePaykitRepo, times(3)).backupSnapshot() + assertEquals(1_000, statuses.value.getValue(BackupCategory.WALLET).required) + verify(vssBackupClient, never()).putObject(eq(BackupCategory.WALLET.name), any()) + + advanceTimeBy(5_000) + runCurrent() + + verify(vssBackupClient).putObject(eq(BackupCategory.WALLET.name), any()) + assertFalse(statuses.value.getValue(BackupCategory.WALLET).isRequired) + } finally { + sut.stopObservingBackups() + } + } + @Test fun `restored accepted Shop reconciles after proof and private state`() = test { val bytes = requireNotNull(javaClass.getResourceAsStream("/candidate-fee-rates-golden.json")).readBytes() diff --git a/app/src/test/java/to/bitkit/repositories/PrivatePaykitRepoTest.kt b/app/src/test/java/to/bitkit/repositories/PrivatePaykitRepoTest.kt index 36ec8f8abd..eb32f9122b 100644 --- a/app/src/test/java/to/bitkit/repositories/PrivatePaykitRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/PrivatePaykitRepoTest.kt @@ -70,6 +70,7 @@ import to.bitkit.services.CoreService import to.bitkit.services.PaykitPreparedPrivateContactPayment import to.bitkit.services.PaykitPrivateContactPaymentResolution import to.bitkit.services.PaykitResolvedPaymentEndpoint +import to.bitkit.services.PaykitSdkBackupExport import to.bitkit.services.PaykitSdkOperationLock.Priority import to.bitkit.services.PaykitSdkService import to.bitkit.services.PubkyService @@ -3564,13 +3565,14 @@ class PrivatePaykitRepoTest : BaseUnitTest(StandardTestDispatcher()) { @Test fun `backupSnapshot and restoreBackup use SDK backup state`() = test { val backup = "sdk-backup" - whenever(paykitSdkService.exportBackupState(Priority.Interactive)).thenReturn(backup) + whenever(paykitSdkService.backupStateVersion).thenReturn(MutableStateFlow(0L)) + whenever(paykitSdkService.exportBackupState(Priority.Interactive)).thenReturn(PaykitSdkBackupExport(backup, 0)) sut.consumePrivatePaymentList( CONTACT_KEY, PrivatePaykitPaymentContext(mapOf(MethodId.Bolt11.rawValue to "bitkit"), 7uL), ).getOrThrow() - val snapshot = sut.backupSnapshot().getOrThrow() + val snapshot = sut.backupSnapshot().getOrThrow().state sut.restoreBackup(snapshot).getOrThrow() assertTrue(snapshot?.contains(backup) == true) diff --git a/app/src/test/java/to/bitkit/services/PaykitSdkServiceTest.kt b/app/src/test/java/to/bitkit/services/PaykitSdkServiceTest.kt index 355418a4ce..551190f1ac 100644 --- a/app/src/test/java/to/bitkit/services/PaykitSdkServiceTest.kt +++ b/app/src/test/java/to/bitkit/services/PaykitSdkServiceTest.kt @@ -748,7 +748,7 @@ class PaykitSdkServiceTest { assertFalse(backup.isCompleted) verify(sdk, never()).exportBackupString() service.linkedPeers(Priority.Interactive) - assertEquals("pending-write-backup", service.exportBackupState(Priority.Interactive)) + assertEquals("pending-write-backup", service.exportBackupState(Priority.Interactive).state) assertFalse(backup.isCompleted) if (cancelBackup) { backup.cancel() @@ -756,11 +756,41 @@ class PaykitSdkServiceTest { } service.setPaymentSubmissionActive(false) runCurrent() - if (!cancelBackup) assertEquals("pending-write-backup", backup.await()) + if (!cancelBackup) assertEquals("pending-write-backup", backup.await().state) verify(sdk, times(if (cancelBackup) 1 else 2)).exportBackupString() } } + @Test + fun `backup export pairs its version after queued mutations and before later work`() = runTest { + val sdk = mock() + val release = CompletableDeferred() + var revision = "before" + whenever(sdk.backupStateRevision()).thenAnswer { revision } + whenever(sdk.processPendingPrivateMessages()).doSuspendableAnswer { + release.await() + revision = "after" + emptyList() + } + whenever(sdk.exportBackupString()).thenAnswer { revision } + val service = PaykitSdkService(mock(), mock(), mock(), settingsStore = mock()) { sdk } + val mutation = async { service.processPendingPrivateMessages() } + runCurrent() + val backup = async { service.exportBackupState(Priority.Interactive) } + runCurrent() + assertFalse(backup.isCompleted) + + release.complete(Unit) + mutation.await() + val exported = backup.await() + + assertEquals("after", exported.state) + assertEquals(1L, exported.version) + assertEquals(service.backupStateVersion.value, exported.version) + service.clearState() + assertTrue(service.backupStateVersion.value > exported.version) + } + @Test fun `deferred backup cannot export a replaced identity or wiped wallet`() = runTest { val resets = listOf Unit>( diff --git a/changelog.d/next/1506.fixed.md b/changelog.d/next/1506.fixed.md new file mode 100644 index 0000000000..1b62e7adca --- /dev/null +++ b/changelog.d/next/1506.fixed.md @@ -0,0 +1 @@ +Keep wallet backups progressing during Paykit activity without accepting inconsistent payment snapshots. diff --git a/journeys/backup-restore/wallet-snapshot-consistency.md b/journeys/backup-restore/wallet-snapshot-consistency.md new file mode 100644 index 0000000000..6b58ef5e31 --- /dev/null +++ b/journeys/backup-restore/wallet-snapshot-consistency.md @@ -0,0 +1,27 @@ +# Wallet snapshot consistency + +Use an isolated test wallet and backup destination. Controlled SDK/capture/upload +gates and inspection of stored backup envelopes are not journey Capabilities; +these checks require a dedicated fault-injection fixture. Do not inject into a +funded personal wallet or shorten payment safety waits. + +1. Queue a wallet backup behind a Paykit mutation, then release the mutation. + Verify the exported SDK state includes it and a consistent snapshot uploads. +2. Hold snapshot capture after the SDK export, change private payment state, and + release capture. Verify the discarded snapshot never uploads and a fresh, + consistent capture succeeds when activity settles. +3. Keep changing state through each capture attempt. Verify no mixed snapshot + uploads, the wallet remains pending backup, and automatic backup succeeds + after the fixture becomes quiet without requiring another state change. +4. Change state during a held upload. Verify completion does not mark the newer + state backed up, and the subsequent upload contains that state. +5. Cancel capture or replace the identity while capture/retry is held. Verify no + mixed-identity snapshot uploads. Verify SDK/export/upload failures and unsigned + payment attempts still fail without being reported as successful backups. +6. Restore a successful captured envelope into an isolated fixture. Verify its + SDK state, private consumption state, reservations, proofs and signed active + payment attempt agree. Do not broadcast a payment as part of this check. + +Record the tested commit, gate locations, attempts and upload counts, and the +restored state. Unit coverage does not establish a device pass or a maximum +backup delay during sustained activity. From f1d167978027057a0b9889a8eaf901b889a4c56d Mon Sep 17 00:00:00 2001 From: benk10 Date: Thu, 15 Oct 2026 16:12:04 -0500 Subject: [PATCH 2/2] chore: rename changelog fragment --- changelog.d/next/{1506.fixed.md => 1507.fixed.md} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename changelog.d/next/{1506.fixed.md => 1507.fixed.md} (100%) diff --git a/changelog.d/next/1506.fixed.md b/changelog.d/next/1507.fixed.md similarity index 100% rename from changelog.d/next/1506.fixed.md rename to changelog.d/next/1507.fixed.md