fix: keep transfer unit tests out of the app's UserDefaults - #756
Conversation
|
jvsena42
left a comment
There was a problem hiding this comment.
No HIGH/MEDIUM. One LOW observation inline, single-pass (not independently verified).
Checked and clean:
- Production:
TransferStoragekeeps thetransferskey, the JSON encoding and the.standarddefault. The oldprivate init(suiteName:)was only ever reached viashared, so nothing moves on upgrade. Every production user still binds to.shared:AppScene's singleTransferService,HwWalletManager,BackupService,MigrationsServiceandChannelDetailsViewModel. TheTransferViewModelconvenience inits that now buildTransferStorage(defaults:)are only reached from#Previewblocks and tests. TransferStoragehas no in-memory cache (getAll()reads defaults every call), so a second instance could not hold a divergent view of pending transfers anyway.guardAppDefaultsis not vacuous. It compares viaisEqualacross nil/value/other, is registered before the body, and runs before the isolated-suite removal (LIFO).- The write path in the HW suite (
hwSignTask→fundPaidOrder→createTransfer→storage.insert) finishes before theisSigningdefer that the tests poll on. - All
TransferViewModelTestsandTransferServiceActivityTestsconstruction sites use the isolated suite.
The initializer took a `suiteName` but was `private`, so `shared` was the only instance that could ever exist and the parameter was unreachable. That left `TransferService(storage:)` with no usable seam. Replace it with the injected-defaults initializer used elsewhere in the app (QuickPaySpendStore, BlocktankRefundAddressProvider, WatchOnlyAccountService). `shared` still binds to `.standard`, so no shipped behaviour changes. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AcCTBgiMafXxw71MWBGB2T
Both convenience initializers hardcoded `TransferService(...)` with `storage:` omitted, so every caller — tests included — wrote through `TransferStorage.shared` to `UserDefaults.standard`. Take `transferDefaults` and forward it into `TransferService(storage:)`. It is a `UserDefaults` rather than a `TransferStorage` for the same reason the hardware initializer builds the service internally: `TransferStorage.swift` is compiled into BitkitTests as well, so passing the type across the module boundary would need `Bitkit.`-qualification at every call site. Defaults to `.standard`, so production call sites are unchanged. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AcCTBgiMafXxw71MWBGB2T
BitkitTests is hosted in the Bitkit app, so `UserDefaults.standard` is the app's own preferences and anything a test writes there lands in the developer's wallet. The repo already solves this three different ways in a dozen files; collect the best of each into one place: - `makeIsolatedDefaults()` — a per-test UUID suite, emptied before and removed after, from the pattern in PublicPaykitServiceTests. - `snapshotAppDefaults(_:)` — restore-or-remove, handling the absent case, from PubkyAuthURLSchemeTests (the only correct version in the target). - `guardAppDefaults(_:)` — fails a test that leaves a key changed, so a regression of this kind goes red instead of corrupting the simulator. The guard reports only the key name. These keys hold large encoded blobs and asserting on equality dumps both of them as hex, burying the one line that says what to do about it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AcCTBgiMafXxw71MWBGB2T
Closes #733. TransferViewModelHwTests built a real TransferService, so any test driving a successful mock broadcast reached `fundPaidOrder` and persisted a transfer record into the app's own preferences. Built from the fixtures, those records name `order123`, which is not a real Blocktank order — `resolveChannelId` fails on every sync, `isSettled` never flips, and the wallet is left showing a permanent "TRANSFER IN PROGRESS" banner with a balance inflated by the phantom amount. Point the three transfer suites at an isolated defaults suite and guard the `transfers` key so they cannot drift back. Verified on a simulator that was carrying two of these phantom records: 63 tests pass and `transfers` is byte-identical afterwards. Removing the forwarding again fails exactly the two tests that reach `fundPaidOrder`, which is the guard doing its job. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AcCTBgiMafXxw71MWBGB2T
Two sites still built a view model against the standard defaults: the no-capabilities error test and the funding budget test. Neither writes today — the first returns at the signer guard and the second only reads availability — so this is not a fix for a live leak, but the class comment says every view model in the suite uses the isolated store, and now they do. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AcCTBgiMafXxw71MWBGB2T
f135b41 to
de059da
Compare
The convenience initialisers built `TransferStorage(defaults: .standard)` when no suite was passed. That reads and writes the same key as `TransferStorage.shared` but is a second instance with its own change subject, and `BackupService` only listens to `.shared` — so a transfer saved through a default-constructed view model would never mark the wallet backup as required. Nothing shipped goes through these initialisers today (the app uses the designated one with an explicit service; the rest are previews and tests), so no user is affected. But the default should behave as it did before the storage seam was added: `transferDefaults` is now optional, nil means `.shared`, and only a test suite gets its own instance. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AcCTBgiMafXxw71MWBGB2T
|
QA reviewed on a721ea0. Reviewed the TransferStorage UserDefaults seam, TransferViewModel convenience inits (nil → TransferStorage.shared), AppStateIsolation helpers, and the three transfer suites; no device run this round, test-only / no UI. No findings. Checked and clean
QA LGTM |
There was a problem hiding this comment.
Verdict: ✅ Approve
Reaudit: diff 1 file.
Findings:
N/A
Audit:
Skipped - only storage touched (score 2, threshold 3).
QA: sim-1 iPhone 17 Pro simulator on iOS 26.5, Debug build
-
✅ passed: 100 000 sats in Savings before and after the three transfer suites, no transfer banner across a 96 s poll, and the app's
transferskey still absent.
Tip
Test 1 worth a journey:
- Create a new wallet: accept the terms, skip the intro, tap New Wallet
- Tap Receive and copy the on-chain address
- Fund the address with 100 000 sats on regtest and mine 6 blocks
- Close the Receive sheet and note the home balance and that no transfer banner shows
- Read the app's transfers preference key and note it is absent
- Run only the three transfer test suites against the same simulator
- Terminate the app and launch it again
- Watch the home screen for at least 30 seconds: balance unchanged and no transfer banner
- Read the transfers preference key again and compare it with the before value
Coverage:
QA: 1 of 1 manual tests passed
Reviewed by claude-opus-5-xhigh via gh-pr-review-loop skill
Commands: @ovi-reviewer test · retest · audit (author or owner)


Fixes #733
This PR stops the hardware transfer unit tests writing mock transfer records into the app's own preferences, where they show up as a permanent "TRANSFER IN PROGRESS" banner and an inflated balance.
The tests build a real transfer service, so any test that drives a successful mock broadcast persists a transfer record built from the fixtures. Those records name an order that does not exist at Blocktank, so the channel id never resolves, the transfer never settles, and the banner never clears. The test bundle is hosted in the app, so the preferences it writes to are the app's own.
Most of the seam already existed. The transfer service takes a storage object, but the storage initialiser was private, so the shared instance was the only one that could ever exist and the parameter was unreachable. This replaces it with the injected-defaults initialiser used elsewhere in the app, and lets the view model's convenience initialisers pass one through. Both keep defaulting to the standard store, so nothing shipped changes behaviour.
It also adds a small set of shared helpers for keeping a suite off the app's state, since the repo already solves this three different ways across a dozen files: an isolated defaults suite, a snapshot-and-restore that handles the absent case, and a guard that fails a test which leaves a key changed. The three transfer suites adopt the isolated suite and the guard.
The guard matters more than it first looks. A suite that quietly writes to the app's preferences still passes; the only thing that catches it is diffing real state afterwards. The guard turns that into a red test.
This is the first of three PRs. The second stops the wider test suite destroying the wallet on the simulator, and the third isolates the remaining suites.
No changelog fragment: nothing shipped changes for a user. The shared storage still binds to the standard defaults and every production call site is untouched.
Linked Issues/Tasks
Fixes #733
Design
N/A — no UI changes.
QA Notes
Manual Tests
Automated Checks
BitkitTests/TransferViewModelHwTests.swift,BitkitTests/TransferViewModelTests.swiftandBitkitTests/TransferServiceActivityTests.swiftnow take an isolated defaults suite and assert thetransferskey is untouched.BitkitTests/AppStateIsolation.swiftcollects the isolation patterns already used across the target.transferskey is byte-identical afterwards. Removing the forwarding again fails exactly the two tests that reach the funding path, which is the guard working.