diff --git a/app/src/main/java/to/bitkit/repositories/ActivityRepo.kt b/app/src/main/java/to/bitkit/repositories/ActivityRepo.kt index 624a4bcac9..4c88415a40 100644 --- a/app/src/main/java/to/bitkit/repositories/ActivityRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/ActivityRepo.kt @@ -986,19 +986,41 @@ class ActivityRepo @Inject constructor( } } + /** + * Applies each slice of the backup envelope on its own so one rejected record cannot discard the others. + * Core fails a bulk write as a whole, so applying all three slices together would let a single unusable tag cost + * the activities and the closed channels too. The overall result still fails when any slice failed, keeping + * [BackupRepo] from treating a partial restore as authoritative and rewriting a good backup with it. + * Observers are notified whenever at least one slice was applied, and the tag signal only fires when the + * tags slice itself was applied, so a rejected tags slice cannot mark the metadata backup as changed. + */ suspend fun restoreFromBackup(payload: ActivityBackupV1): Result = withContext(bgDispatcher) { - runCatching { - coreService.activity.upsertList(payload.activities) - coreService.activity.upsertTags(payload.activityTags) + val activities = runSuspendCatching { coreService.activity.upsertList(payload.activities) } + val activityTags = runSuspendCatching { coreService.activity.upsertTags(payload.activityTags) } + val closedChannels = runSuspendCatching { coreService.activity.upsertClosedChannelList(payload.closedChannels) - }.onSuccess { - Logger.debug( - "Restored ${payload.activities.size} activities, ${payload.activityTags.size} activity tags, " + - "${payload.closedChannels.size} closed channels", - context = TAG, - ) - notifyActivitiesChanged(tagsChanged = true) } + val results = listOf( + "activities" to activities, + "activityTags" to activityTags, + "closedChannels" to closedChannels, + ) + val failures = results.mapNotNull { (slice, result) -> + result.exceptionOrNull()?.also { + Logger.error("Failed to restore '$slice' activity backup slice", it, context = TAG) + } + } + + if (failures.size < results.size) notifyActivitiesChanged(tagsChanged = activityTags.isSuccess) + + failures.firstOrNull()?.let { return@withContext Result.failure(it) } + + Logger.debug( + "Restored ${payload.activities.size} activities, ${payload.activityTags.size} activity tags, " + + "${payload.closedChannels.size} closed channels", + context = TAG, + ) + return@withContext Result.success(Unit) } suspend fun markAllUnseenActivitiesAsSeen(): Result = withContext(bgDispatcher) { diff --git a/app/src/main/java/to/bitkit/repositories/BackupRepo.kt b/app/src/main/java/to/bitkit/repositories/BackupRepo.kt index f55770be20..a874b9a183 100644 --- a/app/src/main/java/to/bitkit/repositories/BackupRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/BackupRepo.kt @@ -63,6 +63,7 @@ import javax.inject.Inject import javax.inject.Provider import javax.inject.Singleton import kotlin.time.Clock +import kotlin.time.Duration.Companion.minutes import kotlin.time.Duration.Companion.seconds import kotlin.time.ExperimentalTime @@ -124,6 +125,8 @@ class BackupRepo @Inject constructor( private val _isWiping = MutableStateFlow(false) val isWiping: StateFlow = _isWiping.asStateFlow() + private val restorePendingUntil = MutableStateFlow(0L) + fun reset() { stopObservingBackups() vssBackupClient.reset() @@ -131,8 +134,25 @@ class BackupRepo @Inject constructor( } fun setWiping(isWiping: Boolean) = _isWiping.update { isWiping } + + /** + * Holds ordinary uploads from the moment a restore is announced, closing the window between the + * restore flow starting and [performFullRestoreFromLatestBackup] raising [_isRestoring]: the node + * starts and syncs while the backup is still being read, and the resulting activity upload would + * replace the stored envelope with the fresh wallet's state before it is read. + * + * The gate cannot suppress uploads indefinitely: it expires after [RESTORE_PENDING_TIMEOUT_MS], it is + * held in memory only so a process death clears it, and it never blocks an explicit + * [triggerBackup], including the migration rewrite a restore ends with. + */ + fun setRestorePending(isPending: Boolean) { + restorePendingUntil.update { if (isPending) currentTimeMillis() + RESTORE_PENDING_TIMEOUT_MS else 0L } + Logger.debug("Set restore pending to '$isPending'", context = TAG) + } + private fun currentTimeMillis(): Long = nowMillis(clock) - private fun shouldSkipBackup(): Boolean = _isRestoring.value || _isWiping.value + private fun isRestorePending(): Boolean = currentTimeMillis() < restorePendingUntil.value + private fun shouldSkipBackup(): Boolean = _isRestoring.value || _isWiping.value || isRestorePending() private fun BackupItemStatus.shouldBackup(category: BackupCategory) = this.isRequired && !this.running && @@ -708,7 +728,7 @@ class BackupRepo @Inject constructor( ) val parsed = json.decodeFromString(migration.json) val persisted = activityRepo.restoreFromBackup(parsed) - .onFailure { Logger.warn("Failed to restore activity backup", it, context = TAG) } + .onFailure { Logger.warn("Skipped activity backup rewrite after a failed restore", context = TAG) } .isSuccess return RestoredCoreBackup(createdAt = parsed.createdAt, needsRewrite = migration.changed && persisted) @@ -864,6 +884,12 @@ class BackupRepo @Inject constructor( private const val FAILED_BACKUP_NOTIFICATION_INTERVAL = 10 * 60 * 1000L // 10 minutes private const val SYNC_STATUS_DEBOUNCE = 500L // 500ms debounce for sync status updates private val VSS_TIMESTAMP_TIMEOUT = 60.seconds + + /** + * How long a pending restore gates ordinary uploads for. Longer than any restore in practice, + * short enough that a restore that never returns cannot hold the gate for the session. + */ + private val RESTORE_PENDING_TIMEOUT_MS = 10.minutes.inWholeMilliseconds } } diff --git a/app/src/main/java/to/bitkit/viewmodels/WalletViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/WalletViewModel.kt index 24b1ff556f..0315c8bbd2 100644 --- a/app/src/main/java/to/bitkit/viewmodels/WalletViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/WalletViewModel.kt @@ -219,6 +219,7 @@ class WalletViewModel @Inject constructor( Logger.error("Restore from backup failed", it, context = TAG) } _restoreState.update { RestoreState.Completed } + backupRepo.setRestorePending(false) } private suspend fun restoreFromMostRecentBackup() { @@ -560,11 +561,16 @@ class WalletViewModel @Inject constructor( suspend fun restoreWallet(mnemonic: String, bip39Passphrase: String?) { setInitNodeLifecycleState() _restoreState.update { RestoreState.InProgress.Wallet } + // The node starts and syncs long before the backup is read, so ordinary uploads are held from + // here rather than from the restore itself, which would upload over the backup it has not read. + backupRepo.setRestorePending(true) walletRepo.restoreWallet( mnemonic = mnemonic, bip39Passphrase = bip39Passphrase, ).onFailure { + // Nothing reaches restoreFromBackup when the wallet was never created, so release here. + backupRepo.setRestorePending(false) ToastEventBus.send(it) } } diff --git a/app/src/test/java/to/bitkit/repositories/ActivityRepoTest.kt b/app/src/test/java/to/bitkit/repositories/ActivityRepoTest.kt index 593bd0e50a..c751f52286 100644 --- a/app/src/test/java/to/bitkit/repositories/ActivityRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/ActivityRepoTest.kt @@ -3,6 +3,7 @@ package to.bitkit.repositories import com.synonym.bitkitcore.Activity import com.synonym.bitkitcore.ActivityFilter import com.synonym.bitkitcore.ActivityTags +import com.synonym.bitkitcore.ClosedChannelDetails import com.synonym.bitkitcore.IcJitEntry import com.synonym.bitkitcore.LightningActivity import com.synonym.bitkitcore.OnchainActivity @@ -31,6 +32,7 @@ import to.bitkit.data.dto.PendingBoostActivity import to.bitkit.ext.create import to.bitkit.ext.createChannelDetails import to.bitkit.ext.mock +import to.bitkit.models.ActivityBackupV1 import to.bitkit.models.WalletScope import to.bitkit.services.CoreService import to.bitkit.services.HwSnapshotResult @@ -70,6 +72,10 @@ class ActivityRepoTest : BaseUnitTest() { on { v1 } doReturn testActivityV1 } + private val backupTags by lazy { ActivityTags(WalletScope.default, "activity1", listOf("daily")) } + + private val backupClosedChannel = mock() + private val baseOnchainActivity = OnchainActivity.create( walletId = "wallet0", id = "base_activity_id", @@ -1024,6 +1030,110 @@ class ActivityRepoTest : BaseUnitTest() { assertEquals(listOf("hw-txid"), result.map { it.paymentId }) } + @Test + fun `restoreFromBackup applies every slice and signals tag changes`() = test { + val tagsBefore = sut.activityTagsChanged.value + + val result = sut.restoreFromBackup(backupPayload()) + + assertTrue(result.isSuccess) + verify(coreService.activity).upsertList(listOf(testActivity)) + verify(coreService.activity).upsertTags(listOf(backupTags)) + verify(coreService.activity).upsertClosedChannelList(listOf(backupClosedChannel)) + assertTrue(sut.activityTagsChanged.value > tagsBefore) + } + + @Test + fun `restoreFromBackup applies remaining slices when the tags slice fails`() = test { + whenever(coreService.activity.upsertTags(any())) + .thenThrow(RuntimeException("Failed to insert tag: FOREIGN KEY constraint failed")) + val activitiesBefore = sut.activitiesChanged.value + val tagsBefore = sut.activityTagsChanged.value + + val result = sut.restoreFromBackup(backupPayload()) + + // One unusable tag must not cost the activities or the closed channels. + verify(coreService.activity).upsertList(listOf(testActivity)) + verify(coreService.activity).upsertClosedChannelList(listOf(backupClosedChannel)) + // Still a failure, so BackupRepo never rewrites a good backup with partial state. + assertTrue(result.isFailure) + assertTrue(sut.activitiesChanged.value > activitiesBefore) + // No tag was stored, so the metadata backup must not be marked as changed. + assertEquals(tagsBefore, sut.activityTagsChanged.value) + } + + @Test + fun `restoreFromBackup applies remaining slices when the activities slice fails`() = test { + whenever(coreService.activity.upsertList(any())).thenThrow(RuntimeException("upsert failed")) + + val result = sut.restoreFromBackup(backupPayload()) + + verify(coreService.activity).upsertTags(listOf(backupTags)) + verify(coreService.activity).upsertClosedChannelList(listOf(backupClosedChannel)) + assertTrue(result.isFailure) + } + + @Test + fun `restoreFromBackup applies remaining slices when the closed channels slice fails`() = test { + val failure = RuntimeException("closed channels upsert failed") + whenever(coreService.activity.upsertClosedChannelList(any())).thenThrow(failure) + val activitiesBefore = sut.activitiesChanged.value + + val result = sut.restoreFromBackup(backupPayload()) + + verify(coreService.activity).upsertList(listOf(testActivity)) + verify(coreService.activity).upsertTags(listOf(backupTags)) + assertEquals(failure, result.exceptionOrNull()) + assertTrue(sut.activitiesChanged.value > activitiesBefore) + } + + @Test + fun `restoreFromBackup returns the first failure when several slices fail`() = test { + val activitiesFailure = RuntimeException("activities upsert failed") + whenever(coreService.activity.upsertList(any())).thenThrow(activitiesFailure) + whenever(coreService.activity.upsertTags(any())).thenThrow(RuntimeException("tags upsert failed")) + + val result = sut.restoreFromBackup(backupPayload()) + + verify(coreService.activity).upsertClosedChannelList(listOf(backupClosedChannel)) + assertEquals(activitiesFailure, result.exceptionOrNull()) + } + + @Test + fun `restoreFromBackup does not notify observers when every slice fails`() = test { + whenever(coreService.activity.upsertList(any())).thenThrow(RuntimeException("activities upsert failed")) + whenever(coreService.activity.upsertTags(any())).thenThrow(RuntimeException("tags upsert failed")) + whenever(coreService.activity.upsertClosedChannelList(any())) + .thenThrow(RuntimeException("closed channels upsert failed")) + val activitiesBefore = sut.activitiesChanged.value + val tagsBefore = sut.activityTagsChanged.value + + val result = sut.restoreFromBackup(backupPayload()) + + assertTrue(result.isFailure) + assertEquals(activitiesBefore, sut.activitiesChanged.value) + assertEquals(tagsBefore, sut.activityTagsChanged.value) + } + + @Test + fun `restoreFromBackup rethrows cancellation`() = test { + val cancellation = CancellationException("cancelled") + whenever(coreService.activity.upsertTags(any())).thenThrow(cancellation) + + val thrown = assertFailsWith { + sut.restoreFromBackup(backupPayload()) + } + + assertEquals(cancellation.message, thrown.message) + } + + private fun backupPayload() = ActivityBackupV1( + createdAt = 1234567890L, + activities = listOf(testActivity), + activityTags = listOf(backupTags), + closedChannels = listOf(backupClosedChannel), + ) + private suspend fun stubHardwareTagLookup(activity: Activity.Onchain) { whenever { coreService.activity.getAllActivitiesTags() } .thenReturn(listOf(ActivityTags(HARDWARE_WALLET_ID, "hw-activity", listOf("cold")))) diff --git a/app/src/test/java/to/bitkit/repositories/BackupRepoTest.kt b/app/src/test/java/to/bitkit/repositories/BackupRepoTest.kt index 7aa8fcf911..3912faa1ed 100644 --- a/app/src/test/java/to/bitkit/repositories/BackupRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/BackupRepoTest.kt @@ -662,6 +662,117 @@ class BackupRepoTest : BaseUnitTest() { } } + @Test + fun `ordinary activity upload is skipped while a restore is pending`() = test { + val activitiesChanged = MutableStateFlow(0L) + stubBackupObservers() + stubActivityBackupReads() + whenever(activityRepo.activitiesChanged).thenReturn(activitiesChanged) + stubBackupStatuses( + MutableStateFlow(emptyMap()), + CompletableDeferred().apply { complete(Unit) }, + ) {} + + try { + // The restore flow announces itself long before it reads the backup. + sut.setRestorePending(true) + sut.startObservingBackups() + runCurrent() + + // Meanwhile the node started and synced the fresh wallet's activities. + activitiesChanged.update { 1L } + runCurrent() + advanceTimeBy(10_000) + runCurrent() + + // Uploading here replaces the stored envelope with the fresh wallet's state before the + // restore reads it, which loses the tags and the closed channels it still holds. + verify(vssBackupClient, never()).putObject(eq(BackupCategory.ACTIVITY.name), any()) + } finally { + sut.stopObservingBackups() + } + } + + @Test + fun `ordinary activity upload resumes once the restore is no longer pending`() = test { + val activitiesChanged = MutableStateFlow(0L) + stubBackupObservers() + stubActivityBackupReads() + whenever(activityRepo.activitiesChanged).thenReturn(activitiesChanged) + stubBackupStatuses( + MutableStateFlow(emptyMap()), + CompletableDeferred().apply { complete(Unit) }, + ) {} + + try { + sut.setRestorePending(true) + sut.startObservingBackups() + runCurrent() + + activitiesChanged.update { 1L } + runCurrent() + advanceTimeBy(10_000) + runCurrent() + + verify(vssBackupClient, never()).putObject(eq(BackupCategory.ACTIVITY.name), any()) + + sut.setRestorePending(false) + whenever(clock.now()).thenReturn(Instant.fromEpochMilliseconds(2_000)) + + activitiesChanged.update { 2L } + runCurrent() + advanceTimeBy(10_000) + runCurrent() + + verifyBlocking(vssBackupClient) { putObject(eq(BackupCategory.ACTIVITY.name), any()) } + } finally { + sut.stopObservingBackups() + } + } + + @Test + fun `pending restore stops gating uploads once it expires`() = test { + val activitiesChanged = MutableStateFlow(0L) + stubBackupObservers() + stubActivityBackupReads() + whenever(activityRepo.activitiesChanged).thenReturn(activitiesChanged) + stubBackupStatuses( + MutableStateFlow(emptyMap()), + CompletableDeferred().apply { complete(Unit) }, + ) {} + + try { + sut.setRestorePending(true) + sut.startObservingBackups() + runCurrent() + + // The restore never returns, so nothing ever clears the gate. + whenever(clock.now()).thenReturn(Instant.fromEpochMilliseconds(RESTORE_GATE_EXPIRED_AT)) + + activitiesChanged.update { 1L } + runCurrent() + advanceTimeBy(10_000) + runCurrent() + + verifyBlocking(vssBackupClient) { putObject(eq(BackupCategory.ACTIVITY.name), any()) } + } finally { + sut.stopObservingBackups() + } + } + + @Test + fun `migration rewrite still uploads while a restore is pending`() = test { + stubWalletBackup() + stubActivityRestore() + sut.setRestorePending(true) + + val result = sut.performFullRestoreFromLatestBackup() + + // The rewrite is the restore's own upload, so the gate must never hold it. + assertTrue(result.isSuccess) + verifyBlocking(vssBackupClient) { putObject(eq(BackupCategory.ACTIVITY.name), any()) } + } + @Test fun `metadata backup fails when pre-activity metadata cannot be read`() = test { stubMetadataBackupReads() @@ -736,6 +847,12 @@ class BackupRepoTest : BaseUnitTest() { verify(vssBackupClient, never()).putObject(eq(BackupCategory.ACTIVITY.name), any()) } + private fun stubActivityBackupReads() { + whenever { activityRepo.getActivities() }.thenReturn(Result.success(emptyList())) + whenever { activityRepo.getClosedChannels() }.thenReturn(Result.success(emptyList())) + whenever { activityRepo.getAllActivitiesTags() }.thenReturn(Result.success(emptyList())) + } + private fun stubMetadataBackupReads() { whenever { preActivityMetadataRepo.getAllPreActivityMetadata() } .thenReturn(Result.success(listOf(preActivityMetadata()))) @@ -947,6 +1064,9 @@ class BackupRepoTest : BaseUnitTest() { private companion object { const val HARDWARE_WALLET_ID = "trezor:abc123" + /** Past the 10 minute restore gate, counted from the 1 000 ms the clock starts at. */ + const val RESTORE_GATE_EXPIRED_AT = 700_000L + /** Core-owned slices as written before `walletId` existed. */ const val LEGACY_ACTIVITIES_JSON = "[]" const val LEGACY_TAGS_JSON = """[{"activityId":"a1","tags":["coffee"]}]""" diff --git a/app/src/test/java/to/bitkit/ui/WalletViewModelTest.kt b/app/src/test/java/to/bitkit/ui/WalletViewModelTest.kt index 46f82e2e24..9cbf3af699 100644 --- a/app/src/test/java/to/bitkit/ui/WalletViewModelTest.kt +++ b/app/src/test/java/to/bitkit/ui/WalletViewModelTest.kt @@ -14,6 +14,7 @@ import org.lightningdevkit.ldknode.PeerDetails import org.mockito.kotlin.any import org.mockito.kotlin.anyOrNull import org.mockito.kotlin.doSuspendableAnswer +import org.mockito.kotlin.inOrder import org.mockito.kotlin.mock import org.mockito.kotlin.never import org.mockito.kotlin.times @@ -37,6 +38,7 @@ import to.bitkit.repositories.WalletState import to.bitkit.services.BoltzService import to.bitkit.services.MigrationService import to.bitkit.test.BaseUnitTest +import to.bitkit.utils.AppError import to.bitkit.viewmodels.RestoreState import to.bitkit.viewmodels.WalletViewModel import kotlin.test.assertEquals @@ -280,6 +282,40 @@ class WalletViewModelTest : BaseUnitTest() { assertEquals(RestoreState.Settled, sut.restoreState.value) } + @Test + fun `restore should hold ordinary backups from its start until it completes`() = test { + whenever(walletRepo.restoreWallet(any(), anyOrNull())).thenReturn(Result.success(Unit)) + whenever(backupRepo.getLatestBackupTime()).thenReturn(1uL) + whenever(backupRepo.performFullRestoreFromLatestBackup()).thenReturn(Result.success(Unit)) + + sut.restoreWallet("mnemonic", "passphrase") + + // The node starts and syncs from here, so the gate must already be closed. + verify(backupRepo).setRestorePending(true) + verify(backupRepo, never()).setRestorePending(false) + + walletState.value = walletState.value.copy(walletExists = true) + advanceUntilIdle() + + assertEquals(RestoreState.Completed, sut.restoreState.value) + inOrder(backupRepo) { + verify(backupRepo).setRestorePending(true) + verify(backupRepo).setRestorePending(false) + } + } + + @Test + fun `restore should release ordinary backups when the wallet is never created`() = test { + whenever(walletRepo.restoreWallet(any(), anyOrNull())) + .thenReturn(Result.failure(AppError("restore failed"))) + + sut.restoreWallet("mnemonic", "passphrase") + advanceUntilIdle() + + // Nothing reaches the restore itself, so the gate would otherwise hold until it expires. + verify(backupRepo).setRestorePending(false) + } + @Test fun `backup restore should reinitialize pubky state after metadata restore`() = test { whenever(backupRepo.getLatestBackupTime()).thenReturn(1uL) diff --git a/changelog.d/next/1171.fixed.md b/changelog.d/next/1171.fixed.md new file mode 100644 index 0000000000..8e76884aad --- /dev/null +++ b/changelog.d/next/1171.fixed.md @@ -0,0 +1 @@ +Restoring a wallet now keeps its activity tags and closed connections: a single tag that cannot be applied no longer discards the rest of the backup, and ordinary backups are held until the restore has finished so they cannot overwrite it first. diff --git a/journeys/README.md b/journeys/README.md index 18d1a5c654..6a68ea3b57 100644 --- a/journeys/README.md +++ b/journeys/README.md @@ -115,6 +115,7 @@ fixtures, push notifications) live in each suite's README. | Suite | Journeys | Notes | | --- | --- | --- | | [amount-limits](amount-limits) | 4 | Number pad caps on all four amount screens | +| [backup-restore](backup-restore) | 1 | VSS restore keeps tags and closed channels; wipes the wallet | | [cjit-notifications](cjit-notifications) | 3 | CJIT channel-ready notifications; needs FCM push | | [deeplinks](deeplinks) | 2 | `bitkit://screen/…` and sheet routing behind the dev-mode gate; no README | | [hardware-wallet](hardware-wallet) | 17 | Trezor over USB; needs the Trezor emulator | @@ -145,6 +146,7 @@ Known differences in the corpus, as of the iOS port (synonymdev/bitkit-ios#691): | `hardware-wallet/receive-onchain.xml`, `hardware-wallet/send-onchain.xml` | not ported | | `payment-requests/requested-resolution-failure.xml` | not ported | | `deeplinks/*` | not ported — iOS registers the `bitkit` scheme but has no screen or sheet router | +| `backup-restore/restore-keeps-tags-and-closed-channels.xml` | not ported yet — iOS already gates uploads across the whole restore (`AppScene.restoreFromMostRecentBackup` sets `BackupService.setRestoring(true)` before the timestamp probe), but still applies the three activity slices in one block (`BackupService.performFullRestoreFromLatestBackup`), which is the half this journey pins; port it with the iOS slice fix | | `home/pull-to-refresh-rates.xml` | not ported — iOS does not refresh exchange rates on pull to refresh | | `security/pin-result-long-label.xml` | not ported — the toggle exists on the iOS security success screen, but the overlap check is a follow-up | | — | `hardware-wallet/transfer-to-spending-over-max.xml` exists only on iOS | diff --git a/journeys/backup-restore/README.md b/journeys/backup-restore/README.md new file mode 100644 index 0000000000..9f4460b390 --- /dev/null +++ b/journeys/backup-restore/README.md @@ -0,0 +1,58 @@ +# Backup and restore journeys + +These journeys exercise the VSS restore path: what a wallet gets back when it is restored from its +recovery phrase, and what the app is allowed to upload while that restore is still running. + +## What the behaviour is + +- The **activity backup envelope carries three Core-owned slices** — activities, activity tags and + closed channels. Each is applied on its own, so one rejected record costs its slice and nothing + else. The restore logs what it applied: + `Restored 3 activities, 1 activity tags, 1 closed channels`. +- **Ordinary uploads are gated from the moment a restore starts**, not from the moment the restore + reads the backup. The node starts and syncs long before the envelope is fetched, and the activity + sync that follows used to upload the fresh wallet's state over the stored envelope — three + `Backup succeeded for: 'ACTIVITY'` lines before `Full restore starting`, and a restored wallet + without its tag and without its closed connection. +- The gate expires on its own and is held in memory only, so a restore that never returns, or an app + killed mid-restore, cannot suppress backups for good. + +## Mandatory setup + +1. **Use a throwaway dev wallet.** The journey resets the wallet half way through; anything left in + it is gone. +2. **Record the recovery phrase before funding anything.** Settings ▸ Security ▸ Back Up Your Money. + Without it there is no second half of the journey. +3. **Fund through the staging LSP**, as the top-level README describes: + `./lsp POST /regtest/chain/deposit '{"address":"","amountSat":500000}'` then + `./lsp POST /regtest/chain/mine '{"count":3}'`. Give the wallet ~20s to sync. +4. **The channel needs the node connected to the LSP.** On the spending amount screen the max starts + at `0` behind a spinner — wait for it to populate before entering an amount. + +## Gotchas + +- **Run the reset and restore twice.** The first restore passed even before the upload gate existed; + the second is where the race was lost, because by then the wallet has a synced activity list to + upload over the backup. +- **The Data Backups rows carry no test tags.** `BackupScrollView` is the list, and the "Transaction + Log" row has to be read by its text and its relative time. +- **Wait for the Transaction Log to report a recent backup before resetting.** Resetting while it is + still pending tests nothing: there is no complete envelope to restore. +- Reading the log is the cheapest confirmation of what really happened: + `adb shell "run-as to.bitkit.dev ls -la files/logs/"`, then `cat` the newest file and look for + `Full restore starting`, `Restored N activities, …` and any `Backup succeeded for: 'ACTIVITY'`. + +## Test tags used + +- Settings tabs `Tab-security` / `Tab-advanced`; rows `BackupWallet`, `BackupSettings`, + `ResetAndRestore`, `Channels`. +- Backup flow: `BackupIntroViewContinue`, `TapToReveal`, `SeedContainer`; data backups list + `BackupScrollView`. +- Reset: `restore_reset_button`, `restore_reset_dialog`. +- Restore: `RestoreWallet`, `Word-`, `RestoreButton`, `GetStartedButton`. +- Receive: `Receive`, `ShowDetails`, `ReceiveCopyQR`. +- Activity: `ActivityShort-`, `ActivityTag`, `ActivityTags`, `AddTagInput`, `AddTagSave`. +- Transfer: `ActivitySavings`, `TransferToSpending`, `TransferIntro-button`, `FundTransfer`, + `SpendingIntro-button`, `SpendingAmount`, `SpendingAmountContinue`, `GRAB`, + `TransferSuccess-button`. +- Lightning connections: `Channel`, `CloseConnection`, `CloseConnectionButton`, `ChannelsClosed`. diff --git a/journeys/backup-restore/restore-keeps-tags-and-closed-channels.xml b/journeys/backup-restore/restore-keeps-tags-and-closed-channels.xml new file mode 100644 index 0000000000..70cc5f0442 --- /dev/null +++ b/journeys/backup-restore/restore-keeps-tags-and-closed-channels.xml @@ -0,0 +1,45 @@ + + + A wallet restored from its recovery phrase must come back with its activity tags and its closed + Lightning connections, not only its activities. The backup envelope carries three Core-owned + slices — activities, activity tags and closed channels — and the restore applies each of them + independently, so one rejected record can no longer discard the rest. + + It also covers the upload gate the restore depends on: from the moment the restore flow starts, + ordinary activity uploads are held, so the fresh wallet's own state cannot replace the stored + envelope before the restore reads it. Running this twice on the same wallet is the sharper check, + because the second restore is where the ungated upload used to win the race. + + Precondition: a dev (regtest) build, onboarded with a NEW wallet, and the staging LSP reachable + through the `lsp` helper at the repo root for funding and mining (see ../README.md). The journey + wipes the wallet half way through, so do not run it against a wallet whose funds matter. + + + Launch the Bitkit app and go to the wallet home screen + Open the menu, navigate to Settings, then the "Security" tab (testTag "Tab-security"), then tap "Back Up Your Money" (testTag "BackupWallet") + Continue past the intro (testTag "BackupIntroViewContinue"), tap to reveal the phrase (testTag "TapToReveal") and record every word of the seed container (testTag "SeedContainer") — the rest of the journey depends on them + Leave the backup flow and return to the wallet home screen + Tap the Receive button (testTag "Receive"), tap "Show Details" (testTag "ShowDetails") and copy the on-chain savings address (testTag "ReceiveCopyQR") + Run `./lsp POST /regtest/chain/deposit '{"address":"<savings address>","amountSat":500000}'` then `./lsp POST /regtest/chain/mine '{"count":3}'`, and wait for the 500 000 sat receive to appear on Home + Tap the received activity row (testTag "ActivityShort-0") and verify its detail screen opens + Tap "Tag" (testTag "ActivityTag"), enter the tag restoretag in the tag input (testTag "AddTagInput") and save it (testTag "AddTagSave") + Verify the tag restoretag is listed on the activity detail screen (testTag "ActivityTags"), then go back to Home + Tap the SAVINGS balance tile (testTag "ActivitySavings"), then "Transfer To Spending" (testTag "TransferToSpending") + Pass any first-run intros on the way: transfer intro (testTag "TransferIntro-button"), the transfer funding option (testTag "FundTransfer") and the spending intro (testTag "SpendingIntro-button") + On the spending amount screen (testTag "SpendingAmount") enter 100 000 on the number pad ("N1" then "N0" five times) and tap Continue (testTag "SpendingAmountContinue") + Wait for the Blocktank order to be quoted, then confirm the transfer by swiping the confirm handle (testTag "GRAB") + Wait for the channel to open and leave the flow through its final button (testTag "TransferSuccess-button") + Open Settings, then the "Advanced" tab (testTag "Tab-advanced"), then "Lightning Connections" (testTag "Channels") + Tap the open connection (testTag "Channel"), tap "Close Connection" (testTag "CloseConnection") and confirm (testTag "CloseConnectionButton") + Verify the connection has left the open list, and that it is listed under "Show Closed & Failed" (testTag "ChannelsClosed") + Open Settings, then the "Security" tab, then "Data Backups" (testTag "BackupSettings"), and wait on the list (testTag "BackupScrollView") until the "Transaction Log" row reports a recent backup — that row carries no testTag, so read its text + Open Settings, then the "Security" tab, then "Reset And Restore" (testTag "ResetAndRestore"), tap "Reset Wallet" (testTag "restore_reset_button") and confirm in the dialog (testTag "restore_reset_dialog") + On the onboarding screen tap "Restore" (testTag "RestoreWallet"), enter the recorded words into the word inputs (testTag "Word-0" onwards) and tap Restore (testTag "RestoreButton") + Wait for the restore to finish and tap "Get Started" (testTag "GetStartedButton") + Verify Home lists the restored activities, including the 500 000 sat receive + Tap the received activity row (testTag "ActivityShort-0") and verify it still shows the tag restoretag (testTag "ActivityTags") + Open Settings, then the "Advanced" tab, then "Lightning Connections", tap "Show Closed & Failed" (testTag "ChannelsClosed") and verify the closed connection is listed + Read the app log and verify the restore applied all three slices, as `Restored 3 activities, 1 activity tags, 1 closed channels`, and that no `Backup succeeded for: 'ACTIVITY'` line sits between the reset and `Full restore starting` + Repeat the reset and the restore once more from the same recovery phrase, and verify the tag and the closed connection are still there — the second restore is the one the ungated upload used to lose + +