Conversation
|
Regtest APKDownload bitkit-dev-debug universal APK (expires in 30 days). |
2a079f7 to
68dbba4
Compare
68dbba4 to
a9e08f8
Compare
| return try { | ||
| runSuspendCatching { | ||
| backupRepo.reset() | ||
| lightningRepo.wipeStorage(walletIndex).getOrThrow() |
There was a problem hiding this comment.
Moving the LDK storage deletion to the beginning of the wipe makes it possible to erase the node data and then abort on a later cleanup failure, such as removePublishedEndpointsForCleanup. In that case, the keychain and wallet-state cleanup are skipped, and the caller only reports an error. The app can therefore continue to present the wallet as existing even though its Lightning storage has already been removed.
Knowledge Base Used:
There was a problem hiding this comment.
Agreed. Fixed in 2b5431e by wiping the keychain right after the LDK storage, so any later cleanup failure leaves the same state as before this PR (no keychain, no node data) instead of a wallet without its node data.
piotr-iohk
left a comment
There was a problem hiding this comment.
QA review from the diff and thread only — not run on device.
-
[gap]
VssBackupClientLdk.setup()still doesisSetup.completeExceptionally(it)without replacing the deferred — the same latch fixed here inVssBackupClient. Callers areclearNetworkGraphandresetPathfindingScores(LightningRepo.kt:665,:1904): one failed LDK-client setup makes every later "Reset network graph" / "Reset pathfinding scores" fail with the stale error and restart the node until the app is restarted. Same two-line fix; arguably in #1256's scope. -
[+1 greptile] The partial-wipe window (LDK deleted, keychain kept) is real but narrow — I checked the steps between
wipeStorageandkeychain.wipe()and onlyprivatePaykitAddressReservationRepo.clear()andkeychain.wipe()itself can throw. Still, if it happens the app presents a wallet whose channel state is gone. Consider callingresetWalletState()regardless of failure oncewipeStoragehas succeeded, or treating the remaining local wipes as non-fatal. -
[gap] Reset is now a long blocking operation with no UI feedback.
ResetAndRestoreScreennever clearsshowDialogon confirm, so the dialog stays up untilresetWalletState(), which now runs afterstop()— the full node build + initial sync, or minutes in the #1257 stop-hang case.rememberDebouncedClickonly debounces the tap; nothing stops a secondwipeWallet()mid-flight, andWipeWalletUseCasehas no re-entrancy guard, so the second run'sfinally { setWiping(false) }can clear the flag while the first is still wiping. Before this PR the onboarding switch was near-immediate so the window was tiny. Awipingflag on the dialog/button or a mutex in the use case would close it. -
[nit] If
stop()fails,wipeStorage().getOrThrow()aborts afterbackupRepo.reset()already stopped the observers and reset the VSS clients. Wallet survives, but backups are silently off until the nextRunningtransition (app restart). Pre-existing ordering, new abort point — worth restarting observers on that failure path.
|
Thanks, all four addressed in 2b5431e:
|
|
An agent is running and will check the Test 3 checkbox if it succeeds. |
piotr-iohk
left a comment
There was a problem hiding this comment.
Re-reviewed 2b5431e from the diff — not run on device. Items 1, 3 and the Greptile P1 look good; two new issues from the fix-up:
-
[blocker]
keychain.wipe()now runs beforeremovePublishedEndpointsForCleanup,removeBitkitPaymentEndpointsandcloseAndClear. All three go throughPaykitSdkService, whosePaykitSdkSessionProvider.loadSessionAccess()readsPAYKIT_SESSIONfrom the keychain on every call and returnsnullonce it is gone (liveSessionAccessis only returned when its secret matches the keychain value). SosyncPublicEndpoints(emptyList())/removePaykitReceiverMarker()run without a session, fail, and are swallowed — and thecontactSharingCleanupPendingflag is then erased bysettingsStore.reset(). Result: the wiped wallet's public endpoints and receiver marker stay published after Reset; on master they were removed. Every step betweenwipeStorageand the oldkeychain.wipe()position returnsResultor swallows (onlyprivatePaykitAddressReservationRepo.clear()can throw), so movingkeychain.wipe()back afterpubkyRepo.wipeLocalState()keeps Greptile's window closed in practice; wrapclear()if you want it airtight. -
[gap] The observer restart in
onFailureis dead code:backupRepo.startObservingBackups()runs while_isWipingis stilltrue(cleared infinally), so the guard added in this PR returns early with "Skipped observing backups while wiping". The unit test only passes becauseBackupRepois a mock. CallsetWiping(false)before the restart, or move the restart after thefinally.
|
Both addressed in 47187b3:
|
|
Handed off to |
47187b3 to
43449af
Compare
|
Both addressed in 43449af, which replaces the previous fix-up rather than moving lines around again. The wipe is now phased:
|
jvsena42
left a comment
There was a problem hiding this comment.
No blockers. Two LOW observations below, both pre-existing on v2.4.1 and neither introduced by this PR — I'm raising them only because they sit inside the window this PR's title claims to close, and in one case the PR body's wording is stronger than the code.
Neither is a reversal: greptile's guard, piotr's items 1-4 and the phased-wipe design in 43449af all stay as landed.
Checked and clean.
Exits from ResetAndRestoreScreen: system Back while wiping is swallowed by BackHandler(enabled = isWiping) {} (:76); top-bar back is hidden via onBackClick = null (:81); dialog dismiss only flips showDialog; Confirm launches on the activity-scoped WalletViewModel, so navigating away via the still-enabled drawer icon doesn't cancel the wipe; backgrounding pauses collection but the wipe continues on viewModelScope, and ON_STOP's stopDebounced() is a no-op against an already-stopped node; on failure isWiping clears, a toast fires, and the wallet is intact.
Ordering and lifecycle: remote cleanup doesn't depend on the LDK node, so moving it after stop() is safe, and it runs before keychain.wipe() so the Paykit session is still present — the ordering piotr asked for. stop() can't fail merely because the node never started (LightningService.stop() returns early on node == null), so Forgot-PIN before node start and ErrorStarting still reset. Observer restart on stop() failure runs after setWiping(false), so the new _isWiping guard in startObservingBackups doesn't short-circuit it.
Concurrency: wipeMutex.tryLock + finally unlock is correct, and a second concurrent wipe gets WipeAlreadyInProgress. step() uses runSuspendCatching, so a genuine CancellationException propagates out of the wipe. Cancelling during stopNode/cleanupRemote leaves the wallet intact — stop() is NonCancellable inside its lock and nothing destructive has run. An ON_START node restart mid-wipe is self-healing: wipeStorage's own stop() serialises on lifecycleMutex.
Key material: nothing in the touched code logs a mnemonic or passphrase; VssBackupClient logs only the VSS/LNURL URLs. Keychain.wipe() clears the DataStore and resets the keystore key, and the PR adds no seed-derived artifact that survives it — except the LDK-directory case below. Remote VSS state is intentionally kept for restore; unchanged.
Upgrade: no persisted format changed, and a v2.4.1 VSS backup is read by unchanged BackupRepo code.
I also checked the claim that setup() does no network I/O and it holds — VssClient::new_with_lnurl_auth only derives xprivs and builds the header provider; the JWT is fetched lazily on first request. That's what makes finding 1 below a millisecond-wide window rather than a 30-second one.
| if (isSetup.isCompleted && !isSetup.isCancelled) { | ||
| runCatching { isSetup.await() }.onSuccess { return@runCatching } | ||
| } | ||
| if (isSetup.isCompleted && !isSetup.isCancelled) return@runCatching |
There was a problem hiding this comment.
LOW / hardening. setup() reads isSetup as a field at every use rather than capturing it, so a setup() that is mid-flight when reset() swaps the deferred completes the replacement.
The real line is :65 (isSetup.complete(Unit)), which isn't in the diff — anchoring here since this is the guard the PR rewrote.
reset() (:110-117) does synchronized(this) { isSetup.cancel(); isSetup = CompletableDeferred() } and does not take setupMutex, so it doesn't wait for an in-flight setup(). If one is between :43 (mnemonic read) and :65 when that happens, :65 completes the fresh deferred. Then the next wallet's setup() short-circuits at this line and every VSS call uses the Rust client built for the old wallet — vss_new_client_with_lnurl_auth stores into a global and vssStore/vssGet take no client handle, so the stale client really is reachable. putObject/getObject await the field (:123, :138), i.e. the already-completed replacement, so it returns immediately and silently: the new wallet's backups land in the old store encrypted with the old key, and on the restore path getLatestBackupTime() → setup().getOrThrow() (BackupRepo.kt:742) reads the old wallet's store into a wallet restored from a different seed. It persists until process death.
Why it's LOW rather than what that description sounds like: I couldn't construct a realistic trigger. setup() does no network I/O, so the window is milliseconds of key derivation. The _isWiping guard you added at :144 already keeps startObservingBackups from launching a fresh setup() once a wipe starts, setupWithRetry only re-enters on MnemonicNotAvailableException, and the other callers are user-driven. Identical code is on v2.4.1, so this is not a regression — the PR only rewrote the guard, added @Volatile, and added the onFailure reset.
Fix, if you want it closed while you're in here — capture the gate under the mutex:
setupMutex.withLock {
val gate = isSetup
if (gate.isCompleted && !gate.isCancelled) return@runCatching
...
gate.complete(Unit)
}and in onFailure: gate.completeExceptionally(it); if (isSetup === gate) isSetup = CompletableDeferred(). complete() on the cancelled old gate is a no-op, so the replacement stays incomplete and the next setup() rebuilds the global. Three lines here and the same three in VssBackupClientLdk.kt (:49, :66). Equally fine to leave it — it's theoretical hardening.
There was a problem hiding this comment.
Applied in ee283d4: setup() captures gate = isSetup under the mutex, completes that gate, and on failure only replaces isSetup when it still is that gate. Same change in VssBackupClientLdk.
| } | ||
|
|
||
| private suspend fun wipeLocal(walletIndex: Int, resetWalletState: () -> Unit) { | ||
| step("wipe LDK storage") { lightningRepo.wipeStorage(walletIndex) } |
There was a problem hiding this comment.
LOW. stopNode() can succeed vacuously while the node object is still alive, and step() then swallows the wipeStorage failure — so the keychain and stores are destroyed with the old seed's LDK directory left behind.
The PR body says an in-flight node start is "awaited under the lifecycle mutex instead of raced", and "closed by construction". That holds when the start succeeds. It doesn't when the start fails into the retry path:
LightningRepo.start():lightningService.setup()succeeds (node assigned, LightningService.kt:209),lightningService.start()throws. ThegetOrElseat :418 reverts state toinitialLifecycleState—Stopped(:427) — without nulling the node, releaseslifecycleMutex, thendelay(2.seconds)(:439) and retries (:440).- Reset confirmed inside that window:
stop()waits onlifecycleMutex, gets it, seesisStoppedOrStopping()(LightningRepo.kt:618-621) and returns success without touching the node. - This line:
wipeStorage→stop()succeeds vacuously again →LightningService.wipeStorage:506 throwsNodeStillRunning→step()logs a warning and continues. - :91
keychain.wipe(), then core, DB, settings.onSuccess()runs, onboarding shows. - 2s later the queued retry
start()runs.node != nullso setup is skipped, and the old seed's node starts on the retained old directory. Create a new wallet andstart()sees "LDK node already running" — the new seed's wallet drives the old seed's node. Next process start builds the new seed on the old LDK dir and fails.
Trigger B is the same ending via awaitNodeRelease() throwing NodeReleaseTimeout after 90s when node.destroy() wedges.
Pre-existing, and head is arguably better: on v2.4.1 keychain.wipe() ran unconditionally before wipeStorage(...).getOrThrow(), with the same vacuous early return, so the same trigger left the same on-disk state — but it surfaced an error and dropped the user on a dead wallet screen instead of onboarding. The PR didn't touch LightningRepo/LightningService at all; the vacuous gate is entirely theirs. What changed is that the failure is now only a Logger.warn.
Fix, if you want the first local step to be the gate it reads as — nothing local is destroyed before it, so it's the one safe place to abort:
lightningRepo.wipeStorage(walletIndex).onFailure { return Result.failure(it) }with wipeLocal returning Result and :59 becoming stopNode().mapCatching { cleanupRemote(); wipeLocal(...).getOrThrow(); onSuccess() }. ~4 lines, no new abstractions. It does not reverse greptile's P1 (that was aborting after LDK deletion) and is compatible with piotr's "treat the remaining local wipes as non-fatal".
One collateral: it flips invoke should complete the wipe when lightningRepo wipeStorage fails (WipeWalletUseCaseTest.kt:288), which encodes the never-abort choice. If that choice is deliberate, leave the code and soften the PR body — "closed by construction" is what I'd push back on, not the design.
There was a problem hiding this comment.
Agreed, and taken in ee283d4: wipeLocal() returns a Result and the LDK storage wipe is its abort point, since nothing local is destroyed before it. Later local steps stay best-effort. The test now asserts the no-wipe path when wipeStorage fails, and the PR body no longer claims the window is closed by construction alone.
There was a problem hiding this comment.
ee283d43f does what I asked, and I walked trigger A end to end at the new head to confirm: the vacuous stop() still returns success, wipeStorage still throws NodeStillRunning, and wipeLocal now returns before :92 keychain.wipe(). Nothing local is destroyed. The flipped test is load-bearing — I reverted the three main-code files to 43449afd6 while keeping the new tests and got exactly one failure, invoke should fail before wiping local state when LDK storage wipe fails, and it asserts verify(keychain, never()).wipe() rather than just a Result.
One consequence of my own request, which I should have called out when I made it. The abort lands after cleanupRemote(), so it is now the first exit that leaves the wallet alive with remote state already torn down. At the previous head the only fatal step was stopNode(), before any cleanup, so "keychain intact + remote torn down" was unreachable except by process death in that window.
What the user is left in on trigger A: mnemonic, passphrase, PAYKIT_SESSION, PUBKY_SECRET_KEY, LDK dir, Room and settings all intact — but private endpoints unpublished with contactSharingCleanupPending(false) so nothing retries, public endpoints synced to empty, the receiver marker removed, PAYKIT_SDK_STATE deleted and the cache store reset. The wallet is briefly "Paykit-dark": payers resolving its endpoints get nothing until the next foreground cycle republishes them (AppViewModel.refreshPublicPaykitEndpoints / refreshPrivatePaykitEndpoints), and the SDK handle rebuilds lazily from the surviving session. Backup observers stay stopped until the next Running transition, which is the same shape as the stop()-fails case piotr already accepted.
This is still the better trade — a recoverable half-torn remote beats a destroyed keychain with the old seed's LDK directory left behind — so I am not asking you to change it. Two things worth knowing:
Reset is not permanently blocked in the realistic trigger. 2s after the failed start the queued retry runs, state leaves Stopped, and the next stop() really stops and nulls the node, so a second Reset succeeds. The one contrived exception is a recovery-mode deeplink inside that 2s window, which makes the retry return RecoveryModeError before touching state and leaves every Recovery-screen wipe aborting until relaunch.
If you want the half-torn state unreachable rather than merely recoverable, the root cause is one condition: LightningRepo.stop() :618 treats Stopped as "nothing to stop" while a failed start leaves lightningService.node alive. Requiring lightningService.node == null there turns the vacuous success into a real stop and trigger A never reaches cleanupRemote(). That is in LightningRepo, which this PR deliberately does not touch, so deferring it to the #1256 follow-up is a fine call.
Fix #1 also checks out: the gate is captured once under setupMutex and used at the guard, at complete, and in onFailure in both clients, and isSetup === gate keeps a stale setup from clobbering a fresh deferred. I traced the setupWithRetry path too — an exceptionally-completed gate is isCancelled, so the guard does not short-circuit and the next setup() gets a fresh one. No poisoned gate. Neither client has a test covering the race in either direction; just an observation, not a request.
There was a problem hiding this comment.
Thanks for walking trigger A through. Took the root fix in ed82850 rather than leaving the half-torn state reachable: LightningRepo.stop() now only short-circuits when the state is stopped and lightningService.node == null, so a node left alive by a failed start gets a real stop before cleanupRemote() runs. Covered by two new cases in LightningRepoTest.kt.
There was a problem hiding this comment.
ed82850af closes trigger A properly. At :618 Stopped && node != null is now false, so stop() takes the real path and LightningService.stop() handles the never-started node cleanly — listenerJob is null because start() threw before assigning it, node.stop() throws NotRunning which is swallowed, and node = null. stopNode() returns success with the node genuinely gone, so wipeStorage no longer throws NodeStillRunning and the half-torn "wallet alive, remote already cleaned up" state is unreachable via that trigger rather than merely less likely.
I checked the thing I was most worried about — that widening into LightningRepo would regress the shipped stop() path — and it does not. "Stopped with a live node" is bounded to the 2s retry window plus a recovery-mode edge, because the only writer of a non-null node is setup() under start()'s mutex, and every other initial state (Initializing, ErrorStarting) already took the real path before this commit. stopDebounced's 5s delay outlives the 2s retry, so a normal background cycle is unchanged. LightningService.stop() cannot realistically throw (NonCancellable, node.stop() inside runSuspendCatching, releaseHandle catches destroy failures), so no caller inherits a failure it never used to get. The node read is safe: @Volatile at LightningService.kt:171, and writes and reads are both under lifecycleMutex anyway. In recovery mode the new behaviour is strictly better — that state previously made every Recovery-screen wipe abort.
The tests pin it. Reverting line 618 alone fails exactly stop tears down a node object left alive by a failed start with WantedButNotInvoked: lightningService.stop(); the second case passes either way and pins the no-op branch. 115/115 at head.
One refinement, LOW, and it is a consequence of the option I offered rather than a new problem. Now that the retry no longer finds a retained node, it does a full setup() — and LightningRepo.wipeStorage runs lightningService.wipeStorage at :815 outside the mutex that stop() released at :642. So if the 2s retry lands in that gap (most plausibly while awaitNodeRelease() is waiting on a wedged free_node, which the comment at :471 says takes tens of seconds after a failed start), the retry can take the mutex, rebuild from VSS, and bring up an old-seed node while the wipe deletes the directory and clears the keychain around it. The wallet ends up on onboarding with the old seed's node running behind it.
Genuinely LOW: it needs a start that fails into the retry path from a Stopped initial state, a Reset confirmed and cleanupRemote() completed inside ~2s of a failure the UI never surfaces, and either the destroy wedge or a millisecond-exact landing. In the likelier ordering the retry holds the mutex through its rebuild, wipeStorage's stop() waits, and the wipe then succeeds correctly — just slower. And the underlying race is pre-existing: any start() acquiring lifecycleMutex between wipeStorage's stop() and :815 already does this on v2.4.1 via the ON_START path in ContentView. This commit adds a second trigger to an old race rather than creating one.
If you want it closed here, the narrow version is to hold lifecycleMutex across both halves of wipeStorage: split stop() into a stopLocked() and have wipeStorage do lifecycleMutex.withLock { stopLocked().mapCatching { lightningService.wipeStorage(walletIndex); … } }. Both setup() and wipeStorage already gate on awaitNodeRelease(), so nothing else moves. Equally defensible to leave it for the #1256 follow-up, given it predates this PR — your call, and not a blocker either way.
There was a problem hiding this comment.
Closed here in effa097: stop() delegates to a private stopLocked(), and wipeStorage() holds lifecycleMutex across stopLocked() and lightningService.wipeStorage(), so a start cannot take the lock between the stop and the directory removal. Pinned by a new LightningRepoTest.kt case that blocks the wipe inside the service call and asserts a concurrent start() does not reach the node until the wipe releases; it fails without the lock.
jvsena42
left a comment
There was a problem hiding this comment.
ee283d43f closes both LOW observations from my last pass. No blockers.
I verified the wipe fix by reverting the three main-code files to 43449afd6 while keeping the new tests: exactly one failure, invoke should fail before wiping local state when LDK storage wipe fails. So the test is load-bearing rather than decorative, and it asserts verify(keychain, never()).wipe() and verify(db, never()).clearAllTables() rather than just a Result. At head, WipeWalletUseCaseTest 11/11, VssBackupClientTest 6/6, VssBackupClientLdkTest 1/1 all pass. Run in a throwaway worktree outside the repo; nothing committed, working tree untouched.
One consequence of the change I asked for is worth your attention before merge — the abort now lands after cleanupRemote(), so a recoverable "wallet alive, Paykit endpoints unpublished" state is newly reachable. Details and the recovery path are in my reply on the WipeWalletUseCase.kt:88 thread. I'm not asking you to change it; it's a better trade than the leftover-LDK-directory outcome it replaces.
Also checked on the new control flow:
runSuspendCatchingat :59 replacingstopNode().map {}keeps cancellation propagating, andfinallystill clearsisWipingand unlocks. A side benefit: the non-stepcalls inwipeLocal(theresetState()s,resetWalletState(),onSuccess()) now surface as a Result failure and a toast instead of an uncaught exception inviewModelScope.keychain.wipe()is unreachable on the abort path by construction.- A second concurrent wipe still returns
WipeAlreadyInProgress. - The new abort path leaves the user on ResetAndRestore with Back and Reset re-enabled and a toast — it doesn't wedge
isWiping. - Re-walked all seven exits from
ResetAndRestoreScreen(the file didn't change, but the wipe's control flow did): Back while wiping, top-bar back, dialog dismiss, Confirm, drawer navigation mid-wipe, backgrounding, process death before and after the keychain step. All still correct. - No seed-derived material reaches a log or a toast on any new path — the new lines add no
Loggercalls, andNodeStillRunning's message is a fixed string.
On the VSS gate: the capture is right in both clients, and isSetup === gate stops a stale setup clobbering a fresh deferred. I traced the setupWithRetry path specifically for a poisoned-gate regression and there isn't one — an exceptionally-completed deferred reports isCancelled, so the guard doesn't short-circuit and the next setup() gets a fresh gate. Neither client has a test covering that race in either direction; noting it, not asking for one.
jvsena42
left a comment
There was a problem hiding this comment.
ed82850af is correct. No blockers, nothing new to raise as its own thread — one LOW refinement is on the WipeWalletUseCase.kt:88 thread, and it's a consequence of the change I asked for rather than a defect you introduced.
This commit reaches into LightningRepo, which the PR had deliberately left alone, so I reviewed it as a change to shipped lifecycle code rather than as a one-line follow-up. The question that mattered was whether making the early return conditional regresses stop() on the hot path. It doesn't:
- "Stopped with a live node" is not a routine state. The only writer of a non-null node is
setup()understart()'s mutex, and it's reachable only wheninitialLifecycleStatewasStopped— bounded to the 2s retry window, plus a recovery-mode edge.InitializingandErrorStartingalready took the real path before this commit, so a real stop against a set-up-but-unstarted node was already exercised (restartWithElectrumServerfailure →restartWithPreviousConfig). stopDebounced's 5s delay outlives the 2s retry, so a normal background cycle sees no new behaviour. In recovery mode the change is strictly better:start()returns early without touching state, soStopped + live nodeused to persist and make every Recovery-screen wipe abort.- No caller inherits a new failure.
LightningService.stop()can't realistically throw — it'sNonCancellable,node.stop()is insiderunSuspendCatching, andreleaseHandlecatchesdestroy()failures. I walked all tenstop()call sites anyway; the ones that ignore the result (LightningNodeService×2,WakeNodeWorker,onProceedWithoutRestore) get the intended outcome, and the ones that handle failure already did. - The
noderead is safely published:@VolatileatLightningService.kt:171, and both the write at :209 and the read at :618 are underlifecycleMutexregardless. - The fall-through emits
Stopping→Stoppedand replaces the wholeLightningState, which the early return didn't. Every real stop already does this and observers tolerate it; the retry re-derivesisGeoBlocked. No probe-cache leak, since a node that never ran has emitted no events.
Tests are load-bearing: reverting line 618 alone fails exactly stop tears down a node object left alive by a failed start with WantedButNotInvoked: lightningService.stop(). The second case passes in both states, which is right — it pins the no-op branch. 115/115 at head. Throwaway worktree outside the repo, nothing committed, main checkout untouched.
Also confirmed the previous commit's work is byte-identical — the captured VSS setup gate and the fatal LDK-wipe step are untouched, and git diff --stat ee283d43f ed82850af is just these two files.
1a0bce1 to
effa097
Compare
Fixes #1256
Fixes #1257
This PR:
Description
LightningRepo.stop()really stop a node object that a failed start left alive instead of short-circuiting on the Stopped state, so Reset during the start retry window tears the node down before any cleanup rather than failing later at the storage wipe. The storage wipe now holds the lifecycle lock across the stop and the directory removal, so a start cannot rebuild the node in between.Out of Scope
WalletViewModel.start(): a restore-triggered start dropped by a staleisStartingflag (#1257, remaining part).BackupReporestore picker: falling back to the RN backup when the VSS lookup returns null (#1254).Design
ResetAndRestoreScreengains a loading state on the Reset button while wiping. N/A — no design available.Preview
N/A
QA Notes
Journeys
N/A — no backup/restore journey exists yet.
Manual Tests
Backup succeededin logs).The failed-setup path itself could not be reproduced on-device: setup does no network I/O and the app skips node start while offline, so the only real trigger is the wipe race from #1254. That path is proven by the unit tests only.
Automated Checks
VssBackupClientTest.kt, the same recovery inVssBackupClientLdkTest.kt, and observers being skipped while wiping inBackupRepoTest.kt.LightningRepoTest.kt.WipeWalletUseCaseTest.kt.just compile,just test, andjust lintpass.