From 4d86489c360e60167fbc9cf5f25291268975f372 Mon Sep 17 00:00:00 2001 From: benk10 Date: Sun, 25 Oct 2026 00:49:48 +0200 Subject: [PATCH 1/3] fix: decouple wallet restore from profile refresh --- .../java/to/bitkit/repositories/BackupRepo.kt | 7 +- .../java/to/bitkit/repositories/PubkyRepo.kt | 43 +++++++++- .../to/bitkit/viewmodels/WalletViewModel.kt | 4 +- .../to/bitkit/repositories/BackupRepoTest.kt | 52 ++++++++++++ .../to/bitkit/repositories/PubkyRepoTest.kt | 81 +++++++++++++++++++ .../java/to/bitkit/ui/WalletViewModelTest.kt | 28 ++++++- changelog.d/next/restore-identity.fixed.md | 1 + .../wallet/restore-during-identity-outage.xml | 18 +++++ 8 files changed, 224 insertions(+), 10 deletions(-) create mode 100644 changelog.d/next/restore-identity.fixed.md create mode 100644 journeys/wallet/restore-during-identity-outage.xml diff --git a/app/src/main/java/to/bitkit/repositories/BackupRepo.kt b/app/src/main/java/to/bitkit/repositories/BackupRepo.kt index dabef786d4..f9ea2491d3 100644 --- a/app/src/main/java/to/bitkit/repositories/BackupRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/BackupRepo.kt @@ -743,8 +743,8 @@ class BackupRepo @Inject constructor( // propagate and fail the backup rather than upload an empty set over the stored ones. val hwWalletNames = hwWalletStore.backupSnapshot().takeIf { it.isNotEmpty() } val cacheData = cacheStore.data.first() - val pubkySession = pubkyRepo.snapshotSessionBackupState().getOrDefault(null) - val pubkyContactProfileOverrides = pubkyRepo.snapshotContactProfileOverrides().getOrDefault(null) + val pubkySession = pubkyRepo.snapshotSessionBackupState().getOrThrow() + val pubkyContactProfileOverrides = pubkyRepo.snapshotContactProfileOverrides().getOrThrow() val payload = MetadataBackupV1( createdAt = currentTimeMillis(), @@ -782,6 +782,9 @@ class BackupRepo @Inject constructor( val privateRepo = privatePaykitRepo.get() val privateVersion = privateRepo.backupStateVersion.value val paykitSdkBackupState = privateRepo.backupSnapshot().getOrThrow() + check(paykitSdkBackupState.state != null || !pubkyRepo.hasIdentityForBackup()) { + "Paykit identity is unavailable for backup" + } val reservationRepo = privatePaykitAddressReservationRepo.get() val reservationVersion = reservationRepo.backupStateVersion.value val privateReservations = reservationRepo.backupSnapshot().getOrThrow() diff --git a/app/src/main/java/to/bitkit/repositories/PubkyRepo.kt b/app/src/main/java/to/bitkit/repositories/PubkyRepo.kt index 39a03aefb4..8340349b42 100644 --- a/app/src/main/java/to/bitkit/repositories/PubkyRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/PubkyRepo.kt @@ -333,6 +333,8 @@ class PubkyRepo @Inject constructor( } private val initializationReady = CompletableDeferred() + private val restoreInitializationLock = Any() + private var restoreInitialization: Pair? = null init { scope.launch { initialize() }.invokeOnCompletion { @@ -350,9 +352,38 @@ class PubkyRepo @Inject constructor( runSuspendCatching { pubkyService.republishIdentityIfNeeded(publicKey.value) } } - suspend fun initialize() = withContext(ioDispatcher) { - val restored = initializeMutex.withLock { initializeSession() } + suspend fun initialize() = initializeForRevision() + + fun scheduleInitializationAfterRestore() { + val job = synchronized(restoreInitializationLock) { + val revision = storedIdentityRevision.get() + restoreInitialization?.takeIf { it.first == revision && !it.second.isCompleted }?.second + ?: scope.launch(start = CoroutineStart.LAZY) { + initializeForRevision(revision) + }.also { pending -> + restoreInitialization = revision to pending + pending.invokeOnCompletion { + synchronized(restoreInitializationLock) { + if (restoreInitialization?.second === pending) restoreInitialization = null + } + } + } + } + job.start() + } + + private suspend fun initializeForRevision( + expectedRevision: Long? = null, + ): Result = withContext(ioDispatcher) { + val (restored, revision) = initializeMutex.withLock { + if (expectedRevision != null && expectedRevision != storedIdentityRevision.get()) { + return@withContext Result.success(Unit) + } + initializeSession() to storedIdentityRevision.get() + } + if (revision != storedIdentityRevision.get()) return@withContext Result.success(Unit) if (restored) loadIdentityData() + if (revision != storedIdentityRevision.get()) return@withContext Result.success(Unit) checkAdoptedSource() } @@ -1576,6 +1607,14 @@ class PubkyRepo @Inject constructor( !keychain.loadString(Keychain.Key.PUBKY_SECRET_KEY.name).isNullOrEmpty() } + internal suspend fun hasIdentityForBackup(): Boolean = withContext(ioDispatcher) { + _publicKey.value != null || listOf( + Keychain.Key.PAYKIT_SESSION, + Keychain.Key.PUBKY_SECRET_KEY, + Keychain.Key.SHARED_PUBKY_SOURCE, + ).any { !keychain.loadString(it.name).isNullOrEmpty() } + } + suspend fun parseAuthUrl(authUrl: String): Result = runSuspendCatching { withContext(ioDispatcher) { if (PubkyAuthRequest.isSignupUrl(authUrl) && !PubkyAuthRequest.isGrantSignupUrl(authUrl)) { diff --git a/app/src/main/java/to/bitkit/viewmodels/WalletViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/WalletViewModel.kt index 114798b9d0..1d74917e2e 100644 --- a/app/src/main/java/to/bitkit/viewmodels/WalletViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/WalletViewModel.kt @@ -235,7 +235,7 @@ class WalletViewModel @Inject constructor( if (resumePendingWalletBackup) { backupRepo.performFullRestoreFromLatestBackup(onCacheRestored = walletRepo::loadFromCache) .getOrThrow() - pubkyRepo.initialize() + pubkyRepo.scheduleInitializationAfterRestore() } else { restoreFromMostRecentBackup() } @@ -273,7 +273,7 @@ class WalletViewModel @Inject constructor( .getOrThrow() } - pubkyRepo.initialize() + pubkyRepo.scheduleInitializationAfterRestore() } private suspend fun restoreFromRNRemoteBackup() { diff --git a/app/src/test/java/to/bitkit/repositories/BackupRepoTest.kt b/app/src/test/java/to/bitkit/repositories/BackupRepoTest.kt index cd7289587f..1eee30e7dd 100644 --- a/app/src/test/java/to/bitkit/repositories/BackupRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/BackupRepoTest.kt @@ -189,9 +189,61 @@ class BackupRepoTest : BaseUnitTest() { }.thenReturn(Result.success(Unit)) whenever { vssBackupClient.listKeys() }.thenReturn(Result.success(emptyList())) + whenever(pubkyRepo.hasIdentityForBackup()).thenReturn(false) sut = createSut() } + @Test + fun `metadata backup preserves complete data when identity records cannot be read`() = test { + stubMetadataBackupReads() + val error = AppError("keychain unavailable") + whenever(pubkyRepo.snapshotSessionBackupState()).thenReturn(Result.failure(error)) + assertEquals(error, sut.triggerBackup(BackupCategory.METADATA).exceptionOrNull()) + verify(vssBackupClient, never()).putObject(eq(BackupCategory.METADATA.name), any()) + + stubMetadataBackupReads() + sut.triggerBackup(BackupCategory.METADATA).getOrThrow() + verify(vssBackupClient).putObject(eq(BackupCategory.METADATA.name), any()) + } + + @Test + fun `metadata backup preserves complete data when contact overrides cannot be read`() = test { + stubMetadataBackupReads() + val error = AppError("store unavailable") + whenever(pubkyRepo.snapshotContactProfileOverrides()).thenReturn(Result.failure(error)) + assertEquals(error, sut.triggerBackup(BackupCategory.METADATA).exceptionOrNull()) + verify(vssBackupClient, never()).putObject(eq(BackupCategory.METADATA.name), any()) + + stubMetadataBackupReads() + sut.triggerBackup(BackupCategory.METADATA).getOrThrow() + verify(vssBackupClient).putObject(eq(BackupCategory.METADATA.name), any()) + } + + @Test + fun `wallet backup preserves complete data while stored identity is unresolved`() = test { + whenever(pubkyRepo.hasIdentityForBackup()).thenReturn(true) + assertTrue(sut.triggerBackup(BackupCategory.WALLET).isFailure) + verify(vssBackupClient, never()).putObject(eq(BackupCategory.WALLET.name), any()) + + whenever(privatePaykitRepo.backupSnapshot()).thenReturn( + Result.success(PrivatePaykitBackupSnapshot("fresh-sdk-state", 0)), + ) + 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("fresh-sdk-state", backup.paykitSdkBackupState) + } + + @Test + fun `wallet backup cannot treat unreadable identity as absent`() = test { + whenever(pubkyRepo.hasIdentityForBackup()).doSuspendableAnswer { + throw IllegalStateException("keychain unavailable") + } + assertTrue(sut.triggerBackup(BackupCategory.WALLET).isFailure) + verify(vssBackupClient, never()).putObject(eq(BackupCategory.WALLET.name), any()) + } + @Test fun `wallet backup waits for submission and includes the latest pending state`() = test { val statuses = MutableStateFlow( diff --git a/app/src/test/java/to/bitkit/repositories/PubkyRepoTest.kt b/app/src/test/java/to/bitkit/repositories/PubkyRepoTest.kt index 1a5b05c58f..a6d429a882 100644 --- a/app/src/test/java/to/bitkit/repositories/PubkyRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/PubkyRepoTest.kt @@ -2412,6 +2412,87 @@ class PubkyRepoTest : BaseUnitTest() { assertNull(result.getOrNull()) } + @Test + fun `scheduled restore activation returns while authentication is held and coalesces duplicates`() = test { + sut.awaitInitialization() + whenever(keychain.loadString(Keychain.Key.PAYKIT_SESSION.name)).thenReturn("restored_session") + val release = CompletableDeferred() + whenever(pubkyService.importSession("restored_session")).doSuspendableAnswer { release.await() } + clearInvocations(pubkyService) + try { + repeat(3) { sut.scheduleInitializationAfterRestore() } + runCurrent() + assertNull(sut.publicKey.value) + assertFalse(sut.isAuthenticated.value) + verify(pubkyService).importSession("restored_session") + } finally { + release.complete(VALID_SELF_KEY) + } + advanceUntilIdle() + assertEquals(VALID_SELF_KEY, sut.publicKey.value) + verify(pubkyService).importSession("restored_session") + } + + @Test + fun `new restored identity proceeds while old scheduled profile loading is held`() = test { + sut.awaitInitialization() + var session: String? = "first_session" + whenever(keychain.loadString(Keychain.Key.PAYKIT_SESSION.name)).thenAnswer { session } + whenever(keychain.delete(Keychain.Key.PAYKIT_SESSION.name)).thenAnswer { session = null } + whenever(pubkyService.importSession("first_session")).thenReturn(VALID_SELF_KEY) + whenever(pubkyService.importSession("second_session")).thenReturn(VALID_CONTACT_KEY_B) + val releaseFirst = CompletableDeferred() + val releaseSecond = CompletableDeferred() + whenever(pubkyService.resolveContactProfile(VALID_SELF_KEY, true)).doSuspendableAnswer { + releaseFirst.await() + createResolution(VALID_SELF_KEY, pubkyProfile = createPubkyProfile(name = "Obsolete")) + } + whenever(pubkyService.resolveContactProfile(VALID_CONTACT_KEY_B, true)).doSuspendableAnswer { + releaseSecond.await() + createResolution(VALID_CONTACT_KEY_B, pubkyProfile = createPubkyProfile(name = "Current")) + } + try { + sut.scheduleInitializationAfterRestore() + runCurrent() + assertEquals(VALID_SELF_KEY, sut.publicKey.value) + sut.restoreSessionBackupState(null).getOrThrow() + assertNull(sut.publicKey.value) + session = "second_session" + sut.scheduleInitializationAfterRestore() + runCurrent() + assertEquals(VALID_CONTACT_KEY_B, sut.publicKey.value) + releaseFirst.complete(Unit) + runCurrent() + sut.scheduleInitializationAfterRestore() + runCurrent() + verify(pubkyService).importSession("second_session") + } finally { + releaseFirst.complete(Unit) + releaseSecond.complete(Unit) + } + advanceUntilIdle() + assertEquals(VALID_CONTACT_KEY_B, sut.publicKey.value) + assertEquals("Current", sut.profile.value?.name) + } + + @Test + fun `backup identity includes persisted and adopted credentials and fails on unreadable storage`() = test { + sut.awaitInitialization() + assertFalse(sut.hasIdentityForBackup()) + val identityKeys = listOf( + Keychain.Key.PAYKIT_SESSION, + Keychain.Key.PUBKY_SECRET_KEY, + Keychain.Key.SHARED_PUBKY_SOURCE, + ) + for (key in identityKeys) { + whenever(keychain.loadString(key.name)).thenReturn("stored") + assertTrue(sut.hasIdentityForBackup()) + whenever(keychain.loadString(key.name)).thenReturn(null) + } + whenever(keychain.loadString(Keychain.Key.PAYKIT_SESSION.name)).thenAnswer { throw TestAppError("unreadable") } + assertTrue(runSuspendCatching { sut.hasIdentityForBackup() }.isFailure) + } + @Test fun `awaitInitialization shares startup and preserves it when a waiter is cancelled`() = test { val imported = CompletableDeferred() diff --git a/app/src/test/java/to/bitkit/ui/WalletViewModelTest.kt b/app/src/test/java/to/bitkit/ui/WalletViewModelTest.kt index 91cdfe0477..2dbdff6abe 100644 --- a/app/src/test/java/to/bitkit/ui/WalletViewModelTest.kt +++ b/app/src/test/java/to/bitkit/ui/WalletViewModelTest.kt @@ -23,7 +23,6 @@ import org.mockito.kotlin.mock import org.mockito.kotlin.never import org.mockito.kotlin.times import org.mockito.kotlin.verify -import org.mockito.kotlin.verifyBlocking import org.mockito.kotlin.whenever import to.bitkit.data.SettingsData import to.bitkit.data.SettingsStore @@ -411,12 +410,33 @@ class WalletViewModelTest : BaseUnitTest() { } @Test - fun `post restore profile failure allows proceeding only while safety markers stay clear`() = test { + fun `completed wallet restore does not wait for optional identity activation`() = test { + val release = CompletableDeferred() whenever(backupRepo.performFullRestoreFromLatestBackup(any())).thenReturn(Result.success(Unit)) - whenever(pubkyRepo.initialize()).doSuspendableAnswer { throw AppError("profile cache unavailable") } + whenever(pubkyRepo.initialize()).doSuspendableAnswer { + release.await() + Result.success(Unit) + } + try { + sut.onBackupRestoreRetry() + runCurrent() + assertEquals(RestoreState.Completed, sut.restoreState.value) + verify(pubkyRepo).scheduleInitializationAfterRestore() + verify(pubkyRepo, never()).initialize() + verify(backupRepo).setRestorePending(false) + } finally { + release.complete(Unit) + } + } + + @Test + fun `proceed rechecks safety markers after an optional restore failure`() = test { + whenever(backupRepo.performFullRestoreFromLatestBackup(any())) + .thenReturn(Result.failure(AppError("metadata cache unavailable"))) sut.onBackupRestoreRetry() advanceUntilIdle() assertEquals(RestoreState.BackupFailed(1, hasPendingWalletRestore = false), sut.restoreState.value) + verify(pubkyRepo, never()).scheduleInitializationAfterRestore() whenever(backupRepo.hasPendingWalletRestore()).thenReturn(true) var dismissed = false @@ -589,7 +609,7 @@ class WalletViewModelTest : BaseUnitTest() { walletState.value = walletState.value.copy(walletExists = true) advanceUntilIdle() - verifyBlocking(pubkyRepo) { initialize() } + verify(pubkyRepo).scheduleInitializationAfterRestore() } @Test diff --git a/changelog.d/next/restore-identity.fixed.md b/changelog.d/next/restore-identity.fixed.md new file mode 100644 index 0000000000..7d934b9487 --- /dev/null +++ b/changelog.d/next/restore-identity.fixed.md @@ -0,0 +1 @@ +Wallet restore finishes without waiting for optional profile refresh, while incomplete identity data cannot replace complete backups. diff --git a/journeys/wallet/restore-during-identity-outage.xml b/journeys/wallet/restore-during-identity-outage.xml new file mode 100644 index 0000000000..fcaad5e534 --- /dev/null +++ b/journeys/wallet/restore-during-identity-outage.xml @@ -0,0 +1,18 @@ + + + Use a disposable test-network wallet with a complete VSS backup and a saved Pubky identity. + Required wallet, private reservation and USDT recovery imports must succeed before the injected + delay starts. Delay only final identity activation or subsequent profile/contact reads. + Keep metadata credential replacement and required restore imports available. These complete-app + fault controls are not currently provisioned; component test results are separate evidence. + Never use live funds or reset an existing wallet. + + + Restore the complete backup and hold final identity activation; verify ordinary wallet access resumes after required recovery completes, without reporting Paykit authenticated or payment-ready + Trigger a full backup while the saved identity cannot be exported; verify failure retains the previous complete WALLET record and required pending backup work + Close the restore screen, release the identity fault and verify retained work completes and a fresh complete backup succeeds once + Repeat with delayed profile/contact enrichment, then replace or remove the identity; verify late results cannot republish the old identity or overwrite current profile data + Restart during the outage; verify saved credentials resume recovery without duplicating a payment or contact mutation + Fail a required wallet or USDT restore instead; verify spending and address allocation remain gated and retry resumes the same required recovery + + From ac2a7c6680589bd80e75de1ffb097b44f1cf2f28 Mon Sep 17 00:00:00 2001 From: benk10 Date: Sun, 25 Oct 2026 00:50:23 +0200 Subject: [PATCH 2/3] docs: associate restore note with PR 1686 --- changelog.d/next/{restore-identity.fixed.md => 1686.fixed.md} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename changelog.d/next/{restore-identity.fixed.md => 1686.fixed.md} (100%) diff --git a/changelog.d/next/restore-identity.fixed.md b/changelog.d/next/1686.fixed.md similarity index 100% rename from changelog.d/next/restore-identity.fixed.md rename to changelog.d/next/1686.fixed.md From d4ba9076b18901721fd9c5a156a0005d16fe6380 Mon Sep 17 00:00:00 2001 From: benk10 Date: Mon, 26 Oct 2026 11:09:53 +0200 Subject: [PATCH 3/3] fix: preserve active identity during scheduled restore setup --- .../java/to/bitkit/repositories/PubkyRepo.kt | 7 ++++- .../to/bitkit/repositories/PubkyRepoTest.kt | 30 +++++++++++++++++++ 2 files changed, 36 insertions(+), 1 deletion(-) diff --git a/app/src/main/java/to/bitkit/repositories/PubkyRepo.kt b/app/src/main/java/to/bitkit/repositories/PubkyRepo.kt index 8340349b42..41854bbf9a 100644 --- a/app/src/main/java/to/bitkit/repositories/PubkyRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/PubkyRepo.kt @@ -379,7 +379,12 @@ class PubkyRepo @Inject constructor( if (expectedRevision != null && expectedRevision != storedIdentityRevision.get()) { return@withContext Result.success(Unit) } - initializeSession() to storedIdentityRevision.get() + val restored = if (expectedRevision != null && _publicKey.value != null) { + true + } else { + initializeSession() + } + restored to storedIdentityRevision.get() } if (revision != storedIdentityRevision.get()) return@withContext Result.success(Unit) if (restored) loadIdentityData() diff --git a/app/src/test/java/to/bitkit/repositories/PubkyRepoTest.kt b/app/src/test/java/to/bitkit/repositories/PubkyRepoTest.kt index a6d429a882..937806b02b 100644 --- a/app/src/test/java/to/bitkit/repositories/PubkyRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/PubkyRepoTest.kt @@ -48,6 +48,8 @@ import kotlinx.coroutines.test.advanceUntilIdle import kotlinx.coroutines.test.currentTime import kotlinx.coroutines.test.runCurrent import kotlinx.coroutines.withContext +import kotlinx.coroutines.withTimeout +import kotlinx.coroutines.yield import org.junit.Before import org.junit.Test import org.mockito.Mockito.clearInvocations @@ -2433,6 +2435,34 @@ class PubkyRepoTest : BaseUnitTest() { verify(pubkyService).importSession("restored_session") } + @Test + fun `scheduled metadata refresh preserves an identity already activated by restore`() = test { + sut.awaitInitialization() + whenever(keychain.loadString(Keychain.Key.PAYKIT_SESSION.name)).thenReturn("restored_session") + whenever(pubkyService.importSession("restored_session")).thenReturn(VALID_SELF_KEY) + assertTrue(sut.restoreSessionIfNeeded()) + val originalSignIn = assertNotNull(sut.currentSignIn()) + assertEquals(VALID_SELF_KEY, sut.publicKey.value) + clearInvocations(pubkyService) + whenever(pubkyService.importSession("restored_session")).doSuspendableAnswer { + throw PaykitException.Transport("transport_error", "controlled unavailable re-import") + } + val refreshed = CompletableDeferred() + whenever(pubkyService.resolveContactProfile(VALID_SELF_KEY, true)).doSuspendableAnswer { + refreshed.complete(Unit) + createResolution(VALID_SELF_KEY, pubkyProfile = createPubkyProfile(name = "Refreshed")) + } + sut.scheduleInitializationAfterRestore() + yield() + advanceUntilIdle() + assertEquals(VALID_SELF_KEY, sut.publicKey.value) + assertTrue(sut.isCurrent(originalSignIn)) + assertFalse(sut.sessionRestorationFailed.value) + verify(pubkyService, never()).importSession(any()) + withTimeout(1.seconds) { refreshed.await() } + verify(pubkyService).resolveContactProfile(VALID_SELF_KEY, true) + } + @Test fun `new restored identity proceeds while old scheduled profile loading is held`() = test { sut.awaitInitialization()