TransferViewModelHwTests builds a real TransferService, so running the unit suite writes mock transfer records into the app's own UserDefaults. They can never settle, and the wallet is left showing a permanent "TRANSFER IN PROGRESS" banner with an inflated balance.
How it happens
makeViewModel in BitkitTests/TransferViewModelHwTests.swift uses the hardware convenience initializer:
TransferViewModel(hwFunding: funding, hwConnecting: connecting, hwFeeRateProvider: { feeRate }, hwTimeouts: timeouts)
That initializer constructs a real TransferService (Bitkit/ViewModels/TransferViewModel.swift:248), which defaults to TransferStorage.shared → UserDefaults.standard. The test bundle is hosted in the Bitkit app, so .standard is the app's preferences.
Any test that drives a successful mock broadcast reaches fundPaidOrder → transferService.createTransfer(...) and persists a record built from the fixtures: IBtOrder.mock() supplies id: "order123" and clientBalanceSat: 85967 (Bitkit/Extensions/IBtOrder+Mock.swift:12), MockHwFunding.broadcastTxId supplies "txid" (BitkitTests/HwTransferMocks.swift:26).
Why it never clears
order123 is not a real Blocktank order, so resolveChannelId fails on every sync and isSettled stays false forever:
DEBUG: Order order123 not found in Blocktank response - TransferService
DEBUG: Transfer 914A2E05-… has no resolvable channelId
DEBUG: Active transfers: 2
DEBUG: Balances in state: onchain=100000 lightning=41370 toSavings=0 toSpending=171934
ActivityLatest.shouldShowBanner is balanceInTransferToSpending > 0, so the banner pins on. The header total also overstates by the phantom amount — 313 304 shown against 141 370 actually held (2 × 85 967 = 171 934).
Reproduction
- Note the wallet's balance and that no transfer banner is showing.
xcodebuild test -project Bitkit.xcodeproj -scheme Bitkit -destination "platform=iOS Simulator,id=<udid>" ONLY_ACTIVE_ARCH=YES -only-testing:BitkitTests/TransferViewModelHwTests
- Launch the app. The banner is showing and the balance is inflated.
The persisted records, from Library/Preferences/to.bitkit.plist (key transfers):
{"fundingTxId": "txid", "amountSats": 85967, "lspOrderId": "order123", "isSettled": false, …}
{"fundingTxId": "txid", "amountSats": 85967, "lspOrderId": "order123", "isSettled": false, …}
Suggested fix
Most of the seam already exists — TransferService.init takes storage: TransferStorage = .shared and TransferStorage.init(suiteName:) already supports an isolated suite. What is missing is a way to reach it from the hardware convenience initializer, which hardcodes the default. Letting the tests pass in a suite-scoped TransferStorage (or an injected TransferService) would keep them off UserDefaults.standard.
Worth checking whether other suites hosted in the app write through singletons the same way.
Workaround
Remove the entries with lspOrderId == "order123" from the transfers key in the app container's to.bitkit.plist while the app is stopped. Note that xcrun simctl spawn <udid> defaults write to.bitkit … does not work — it resolves to a domain outside the app container; the file has to be edited in Containers/Data/Application/<id>/Library/Preferences/.
Notes
Not a regression from any current branch — the convenience initializer and the affected tests are on master. Found while verifying #686, where a test run polluted the wallet this way.
TransferViewModelHwTestsbuilds a realTransferService, so running the unit suite writes mock transfer records into the app's ownUserDefaults. They can never settle, and the wallet is left showing a permanent "TRANSFER IN PROGRESS" banner with an inflated balance.How it happens
makeViewModelinBitkitTests/TransferViewModelHwTests.swiftuses the hardware convenience initializer:That initializer constructs a real
TransferService(Bitkit/ViewModels/TransferViewModel.swift:248), which defaults toTransferStorage.shared→UserDefaults.standard. The test bundle is hosted in the Bitkit app, so.standardis the app's preferences.Any test that drives a successful mock broadcast reaches
fundPaidOrder→transferService.createTransfer(...)and persists a record built from the fixtures:IBtOrder.mock()suppliesid: "order123"andclientBalanceSat: 85967(Bitkit/Extensions/IBtOrder+Mock.swift:12),MockHwFunding.broadcastTxIdsupplies"txid"(BitkitTests/HwTransferMocks.swift:26).Why it never clears
order123is not a real Blocktank order, soresolveChannelIdfails on every sync andisSettledstaysfalseforever:ActivityLatest.shouldShowBannerisbalanceInTransferToSpending > 0, so the banner pins on. The header total also overstates by the phantom amount — 313 304 shown against 141 370 actually held (2 × 85 967 = 171 934).Reproduction
xcodebuild test -project Bitkit.xcodeproj -scheme Bitkit -destination "platform=iOS Simulator,id=<udid>" ONLY_ACTIVE_ARCH=YES -only-testing:BitkitTests/TransferViewModelHwTestsThe persisted records, from
Library/Preferences/to.bitkit.plist(keytransfers):{"fundingTxId": "txid", "amountSats": 85967, "lspOrderId": "order123", "isSettled": false, …} {"fundingTxId": "txid", "amountSats": 85967, "lspOrderId": "order123", "isSettled": false, …}Suggested fix
Most of the seam already exists —
TransferService.inittakesstorage: TransferStorage = .sharedandTransferStorage.init(suiteName:)already supports an isolated suite. What is missing is a way to reach it from the hardware convenience initializer, which hardcodes the default. Letting the tests pass in a suite-scopedTransferStorage(or an injectedTransferService) would keep them offUserDefaults.standard.Worth checking whether other suites hosted in the app write through singletons the same way.
Workaround
Remove the entries with
lspOrderId == "order123"from thetransferskey in the app container'sto.bitkit.plistwhile the app is stopped. Note thatxcrun simctl spawn <udid> defaults write to.bitkit …does not work — it resolves to a domain outside the app container; the file has to be edited inContainers/Data/Application/<id>/Library/Preferences/.Notes
Not a regression from any current branch — the convenience initializer and the affected tests are on
master. Found while verifying #686, where a test run polluted the wallet this way.