fix: add lsp refund address - #732
Conversation
jvsena42
left a comment
There was a problem hiding this comment.
One LOW inline, dev/QA-facing only. Not blocking.
No gating here — BlocktankViewModel.createOrder is the live Transfer-to-Spending path, so every release user who buys a channel now goes through BlocktankRefundAddressProvider.addressForOrder(). I reviewed it accordingly, and the backup changes as a shipped-feature concern rather than under the Paykit migration exemption.
Checked and clean:
- Derivation and ownership.
allocate->newAddressInfoForType(.nativeSegwit)-> ldk-nodenew_address_info_for_typeonOnchainWalletAccount::account_zero— the seed wallet's BIP84 account 0 external keychain, never a watch-only or Trezor account. The reuse path re-derives at the cached index and requires both address and index equality, so a cached value from another seed, passphrase or network can never be handed to Blocktank.validate_derivation_indexplus the<= Int32.maxguard exclude hardened indexes. On the hardware-funded path the refund goes to the internal hot wallet, which the PR description states — funds stay user-controlled. - Gap limit and scanning. Every reuse calls
revealReceiveAddresses(to: index, .nativeSegwit)beforeisUsed, and allocation reveals too; ldk-node persists BDK changesets to VSS, so the revealed index survives restore. Even if that upload were lost, a stop-gap of 20 covers a single-index refund. nativeSegwit is force-monitored at node build, on settings restore, after post-restore prune, insyncMonitoredTypesFromNode, andsetMonitoringrefuses to disable it — and sinceset_primary_address_typedemotes the previous primary into the monitored set, switching primary to taproot can't drop it either. Users who hadaddressTypesToMonitor = "taproot"under the old rule are handled by the static normaliser at node setup, which triggers a full scan for the newly added type. - Address-type preference after an order. The refund address is persisted as
{address, index}with an explicit.nativeSegwitderivation and never re-derived fromselectedAddressType, so changing the preference later alters neither the stored value nor what is monitored. - Ordering and partial failure.
save(generated)— which re-reads and verifies — runs beforeorderClient.submit, withTask.checkCancellation()bracketing resolution. A crash between save and submit leaves an unused cached address the next order reuses. A crash after submit leaves a remote order whose refund address is already revealed in BDK and persisted to VSS, so a refund is still detected even with no local order record. Concurrent callers coalesce onto one in-flight task. - Backup encoding, traced both directions. Newer backup decoded by an older build: the pre-PR
AppCacheData.init(from:)uses an explicitCodingKeysenum withdecodeIfPresentthroughout, andJSONDecoderignores keys absent fromCodingKeys— so the new field is silently skipped and no other field is disturbed. The old build's next upload rewrites the envelope without it; after re-upgrade that decodes to nil,clear()runs, and a fresh address is allocated. The previously supplied address stays owned, revealed and monitored, so a late refund still lands — only reuse is lost. Older backup on a newer build isdecodeIfPresent-> nil ->clear(), pinned bytestOlderAppCacheWithoutRefundAddressDecodesAsNil. No version bump orBackupFieldMigrationentry needed; the envelope staysversion: 1with an optional field, same asquickPayLedgerandhwWalletNamesbefore it. - The new
throwsongetAppCacheData()/restoreAppCacheData()can only fire on a corrupt localDatablob or a failed UserDefaults read-back. Nothing writes that key except the store itself, so the abort paths inBackupServicearen't reachable from any shipped input. - Key material. Only a public bech32 address and a 32-bit derivation index are added — no xpub, no private material — through the unchanged seed-derived VSS encryption path.
- Trust boundaries. The refund address flows client -> server only; no new Blocktank-supplied value is rendered or used as a path or URL. Good catch removing the
options: \(options)debug log that dumped the signedCreateOrderOptions. - Live integration test and CI.
integration-tests.ymladds the class to-only-testing,unit-tests.ymlto-skip-testing.Env.networkis hard-wired to.regtestunderisUnitTest, the testXCTSkips off-regtest, andregtestDepositFunds/regtestMineBlocksthrow unless regtest. No secrets introduced, endpoints are the existing staging ones, and the test only creates unpaid staging orders and takes a 10k-sat regtest deposit — it never signs or broadcasts a spend.wipeEntireKeychain()insetUpmatches the existingAddressTypeIntegrationTestspattern and touches only the test-host keychain.
Cross-repo parity with synonymdev/bitkit-android#1235: I'd initially assumed Android had no backup coverage for this — that's wrong, and worth recording. Android's AppCacheData is the metadata envelope (BackupRepo builds it from cacheStore.data.first()), so the one-line field addition carries it with no payload edit; you needed explicit plumbing only because iOS assembles that struct field-by-field from UserDefaults. Wire format matches ({address, index}), and Android's index: Long vs your UInt32 is compatible since both clamp to 0...Int32.max before saving. Both decoders tolerate the field being absent or unknown, so metadata restores decode in either direction.
Derivation, ownership check, reveal, rotate-on-used, persist-before-submit, and the forced nativeSegwit monitoring invariant are all present and same-shaped on both platforms. Concurrency differs in shape only — Android serialises on a Mutex, you coalesce onto one Task.
jvsena42
left a comment
There was a problem hiding this comment.
Re-reviewed after the network-namespacing commit (9b6478de). Everything I raised earlier holds up at head — derivation/ownership fails closed, reveal-before-isUsed, persist-before-submit, forced nativeSegwit monitoring across node build / restore / prune / setMonitoring, the additive decodeIfPresent backup field, and the per-network metadata store. No LSP-supplied value is rendered or used in a path.
One real bug in the new migration code, inline. It's LOW for funds — the predicate fails closed, so a wrong-network address is never handed to Blocktank and the ownership check still guards reuse — but it's why Run Tests is red on this head, so it blocks merge either way.
Also checked and clean: the try inserted into the metadata-restore closure (BackupService.swift:258) can't realistically strand the rest of the restore, since save() only throws on a UserDefaults read-back mismatch and the invalidCache path needs a legacy value matching no HRP, which newAddressInfoForType(.nativeSegwit) cannot produce. addressTypeStateFromUserDefaults writing to UserDefaults (LightningService.swift:1306-1309) is fine for all three callers — _addressTypesToMonitor is @AppStorage so it observes the write. BlocktankRefundAddressStore.key is in appStateKeys and KVO-observed, so a save marks .metadata backup required. createOrder cancellation is safe: Task.checkCancellation() brackets addressForOrder() and the coalesced inner task is unstructured, so a cancelled caller can't leave a half-saved cache.
Doesn't apply to the Android twin (synonymdev/bitkit-android#1235) — each network is a separate application id there and the diff has no legacy-key migration.
jvsena42
left a comment
There was a problem hiding this comment.
The network-classification finding is closed, and Run Tests is green again.
matchingNetworks(for:) is now the explicit HRP classifier at BlocktankRefundAddressProvider.swift:118-130 — bc1q → [.bitcoin], bcrt1q → [.regtest], tb1q → [.testnet, .signet], else [] — and the provider no longer references the shared matchesAddressFormat, so PrivatePaykitAddressReservationStore and SettingsViewModel keep their loose semantics. Both consumers work now: load():65 sees [.regtest] == [.regtest] and migrates, saving the scoped key at :67 before removing the legacy one at :68; clear():113 removes a bcrt1q legacy value on a regtest store and leaves it on a bitcoin store. testnet/signet correctly never migrate.
It wasn't papered over: testLegacyCacheMigratesOnlyWhenPrefixIdentifiesNetwork asserts the stronger case for both .bitcoin and .regtest, and the three new tests pin the remaining branches. One assertion was dropped in the rewrite — a bcrt1q legacy value loaded on a .bitcoin store returns nil and writes no scoped key — but that path is trivially correct by reading, so it's a nit at most.
On the failing e2e-tests-staging - multi_address_2_regtest: most likely unrelated to this PR, though I can't prove it from the artifacts.
Attempt 1 was Appium failing to create a session (infra). Attempts 2 and 3 were identical: all four address types funded, order created, confirm screen reached, GRAB swiped, then transferSavingsToSpending waited ~6.5 min / 11 mined blocks and ~TransferSuccess never appeared. Both screenshots show SettingUpView parked on "Processing Payment" — updateOrder returning 0 because order.state2 == .created, i.e. the LSP never registered the payment.
Why I lean unrelated:
- Run
34378599511on this same branch atb4776962passed this shard, swipe →TransferSuccesswith no wait loop.git diff b4776962..9ecf5787is formatting only — brace placement andtryplacement in tests — so functionally identical code completed the whole refund-address → pay → channel → close flow. - The only PR code on this path is
addressForOrder()insidecreateOrder, which had already succeeded; the fee screen was displayed and the swipe happened.payOrder→lightningService.sendis untouched, and a throw there would have shown an error rather than navigating toSettingUpView. - The shard's baseline is poor: 12
multi_address_2_regtestfailures in the last 120 runs, the passing run above itself needed 3 attempts, andcodex/713-paykit-interopfailed the same shard 35 minutes later. Staging regtest looked unhealthy in that window.
The honest caveat: this exact TransferSuccess signature appears in no other multi_address_2 failure in that 120-run sample, and simulator.log doesn't capture the app's Logger, so I couldn't confirm the funding tx was broadcast. Suggest a re-run — and if it recurs at this step on this branch, the evidence needed is the app's own log file (order id + LN setup step lines), not more e2e retries.
Parity: both twins now carry the skip-used loop with the same shape, so nothing to mirror.
|
Staging E2E reds on transfer-to-spending are Blocktank staging (payment confirmed, order stays |
Fixes #728
Description
This PR:
Linked Issues/Tasks
Preview
QA Notes
Manual Tests
9cac70da-71fc-4c63-8da4-ddeb2d6518f6with its native SegWit refund address, the paired Trezor signed funding transaction66127374a905d1437eb761101c567c27bba4282094cc482363e09de10e2a7fd1, and the app reached Transfer Successful.Automated Checks
BitkitTests/BlocktankRefundAddressProviderTests.swift: covers reusable allocation, rotation after recorded payment, concurrency, cancellation, ownership/index validation, failure blocking, and cross-platform metadata compatibility.BitkitTests/AddressTypeSettingsTests.swift: covers native SegWit startup and restore normalization.BitkitTests/BlocktankRefundAddressLiveIntegrationTests.swift: passed on staging/regtest. Five estimates allocated no address; real created, rejected, restarted, and restored orders reused index 0; a real 10,000 sat deposit was recorded in Core's local activity database; the next order rotated once to index 1 and the following order reused it.5d1d1329-4f21-4cf3-9606-6d16728bc378,be83c116-cfe6-4439-92e1-853d9695ac87,8a968b2f-55da-4a71-9101-90743a9a67b5,9c67b9ab-e3a6-4825-bca7-3ee71a5f54bc,8c443cee-9cba-4c08-b801-3cbc390123df,074033dc-c081-4343-bb9c-f1eb27a1451b.99581b96ecfa768de10fc8518e162cb3867907a8fd0a295f81477c72a9c584f9.9cac70da-71fc-4c63-8da4-ddeb2d6518f6reachedexecuted/open; its internal refund destination was native SegWit index 2, independent of the Trezor funding account.Bitkit-PR728-Refundsimulator.node scripts/validate-translations.js: passed.