Skip to content

fix: add lsp refund address - #1235

Open
ovitrif wants to merge 5 commits into
masterfrom
fix/1229-blocktank-refund-address
Open

fix: add lsp refund address#1235
ovitrif wants to merge 5 commits into
masterfrom
fix/1229-blocktank-refund-address

Conversation

@ovitrif

@ovitrif ovitrif commented Sep 8, 2026

Copy link
Copy Markdown
Collaborator

Fixes #1229

Counterpart: synonymdev/bitkit-ios#732

This PR:

  1. Supplies Blocktank orders with a reusable native SegWit refund address from the active Bitkit wallet, rotating it after local wallet activity records a payment to that address.
  2. Persists the address and external receive index in metadata backups, then validates ownership and reveals the derivation through the active LDK node before reuse.
  3. Keeps native SegWit monitored through address-type changes, startup, metadata restore, and post-restore pruning so Blocktank refunds remain detectable.

Description

The refund address is allocated once through LDK, stored only after node persistence succeeds, and reused across unpaid or failed order attempts. Estimates do not allocate addresses. Concurrent order requests share the same unused destination, while lookup, reveal, ownership, cache-write, and cancellation failures stop before Blocktank submission.

Preview

QA Notes

Manual Tests

  • 1. Dedicated dev wallet → switch the primary receive type to Taproot → Settings → Advanced → Address Type → disable Native SegWit monitoring: the warning explains the Blocktank refund requirement and Native SegWit remains enabled.
  • 2a. Standard wallet → Move to Spending → create and fund a standard LSP order: the order opened on staging with the persisted wallet-owned refund pointer.
    • Order ae58765a-f228-4452-ac42-b960b34c855a; pointer index 2.
  • 2b. Standard wallet → Move to Spending → Advanced → create an unpaid LSP order: retries and ten live standard/advanced order requests reused pointer index 2 across an app restart.
    • Final advanced order faa81e71-a9ca-4de9-860a-fabde5b4ec26.
  • 2c. Trezor emulator → fund 100,000 sat → Move 20,000 sat to Spending: the signed order opened on staging. The captured POST /channels used the internal Bitkit wallet pointer at index 1, distinct from the external Trezor address.
    • Order 55b1d296-82b9-4545-bf91-981a22d1f6b2; orderExpiresAt=2026-09-10T16:38:44.238Z.
  • 3. Restore the dedicated wallet and metadata from VSS before the incoming payment → confirm post-restore pruning retains Native SegWit and the refund pointer → pay the restored address on regtest: Core records the exact address and txid in activity.db; the next order rotates the pointer exactly once.
    • Payment tx 68875bfdbf1822b13e2452b3d097ff51015fe50bb4d19794361449954e2d137d; rotated order 9c5a0405-a65b-4dba-974b-696ba82d0089.
  • 4. Staging paid order → leave the channel unclaimed until expiry → verify the backend refund record targets the supplied address → approve the refund through an authorized backend path → verify Bitkit records the payout.
    • Staging orders expire after about 48 hours. The public staging API has no expire or refund-approval endpoint. Verified backend source registers the refund record at expiry and requires a separate authorized approval before broadcast, so this acceptance step needs backend access and coordination.

Automated Checks

  • BlocktankRepoTest.kt: covers one persisted allocation, unpaid and failed reuse across restarts, payment-driven rotation, concurrency, allocation-free estimates, ownership validation, reveal, persistence, failure, and cancellation gates.
  • AppCacheDataTest.kt and BackupRepoTest.kt: cover the optional cross-platform JSON shape and metadata backup/restore pointer.
  • LightningRepoTest.kt, SettingsDataTest.kt, and AddressTypePreferenceViewModelTest.kt: cover native SegWit normalization, disable protection, post-restore retention, and localized feedback.
  • Pre-review head: the full DevDebug unit suite passed 2,360 tests across 157 files with zero failures, errors, or skips; Kotlin compile, detekt, and the merged DevDebug E2E build also passed.
  • Pre-review live request capture: four fee estimates allocated no refund pointer; the first order allocated one pointer and sent it in the single POST /channels request.
  • Review and CI-fix validation were not rerun locally per the requested implementation-only constraint.

@ovitrif ovitrif changed the title fix: add automatic lsp refund addresses fix: add lsp refund addresses Sep 8, 2026
@ovitrif
ovitrif marked this pull request as ready for review September 8, 2026 17:14
@ovitrif
ovitrif requested a review from jvsena42 September 8, 2026 17:14
@greptile-apps

This comment has been minimized.

Comment thread app/src/main/java/to/bitkit/repositories/BackupRepo.kt Outdated
@ovitrif ovitrif self-assigned this Sep 8, 2026

@jvsena42 jvsena42 left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No findings. Clean at the HIGH/MEDIUM bar.

Reviewed at head f9b1f6b against full files, the ldk-node fork (crates/bdk-wallet-aggregate), and the iOS twin. No gating on the core path — BlocktankRepo.createOrder is reached from Transfer -> Spending in release builds, so I treated this as live user-facing code and kept upgrade/restore paths in scope.

Checked and clean:

  • Derivation and ownership. newAddressInfoForType(P2WPKH) -> node.onchainPayment().newAddressInfoForType(NATIVE_SEGWIT) -> fork get_new_address_info_for_account(OnchainWalletAccount::account_zero(P2WPKH)) -> reveal_next_address(External) + wallet.persist(persister). So it's account-0 external P2WPKH of the node's own seed, and the reveal is persisted to the VSS-backed KV store before the address is returned. Not a watch-only or Trezor account — those go through account_index != 0.
  • Reuse check. The re-derive at :370-372 uses peek_address on the same account-0 key and requires both index and address to match, so a foreign pointer can't pass. The subsequent re-reveal through the index means BDK includes the script in sync even if the cursor were somehow lower.
  • The P2WPKH wallet is guaranteed loaded when these calls run: it's forced into settings before setAddressTypesToMonitor, set_primary_address_type demotes the old primary to monitored, validateDisableMonitoring rejects P2WPKH, syncMonitoredTypesFromNode re-adds it, and the post-restore prune skips it. createOrder also gates on nodeId != null before touching the mutex.
  • Address-type preference after an order. The pointer is persisted as (address, index) and always re-derived against P2WPKH regardless of selectedAddressType; switching primary to Taproot keeps the P2WPKH account loaded, and rollback also passes through SettingsStore.update, which re-applies the invariant.
  • Reuse and privacy. The receive flow and the refund allocation both advance the same persisted BDK external cursor, so the refund address is never handed out as a receive address. Paykit private reservations use the same cursor. Reuse across orders is by design and rotates on isAddressUsed.
  • Network correctness. Flavors have distinct application ids (separate app_cache.json), vssStoreIdPrefix scopes backups per network, and the derive-and-compare would reject a tb1/bc1 mismatch anyway. Worth noting this is exactly why the cross-network cache issue I filed on the iOS twin can't happen here.
  • Persistence ordering. getBlocktankRefundAddress() -> cacheStore.update (DataStore updateData completes the disk write before returning) -> only then coreService.blocktank.newOrder. A crash or cancellation before submission leaves a persisted unused pointer that's reused next time; a failed newOrder reuses it too, which the repeated-orders test pins. ensureActive() plus runSuspendCatching preserve cancellation, and concurrent calls serialise on refundAddressMutex. Estimates never allocate.
  • Backup and restore. The metadata envelope embeds the whole cache, so adding blocktankRefundAddress to AppCacheData carries it in every metadata backup with no payload edit — which is why removing the duplicate collector was right. Restore's resetBip21() clears only bip21/bolt11/onchainAddress, so the pointer survives. Even without the pointer, the refund lands on a revealed, persisted account-0 P2WPKH index that a restored node syncs, with P2WPKH monitoring forced on at build — I found no un-sweepable path.
  • Trust boundaries and key material. No Blocktank-supplied value is consumed by the new code; the address flows client -> server only. The order log line no longer dumps the full options (which included the signature). Only a public address and an index are persisted and backed up.

Cross-repo parity with synonymdev/bitkit-ios#732 — the backup coverage is present here, just differently shaped, which corrects my initial assumption that Android had none. iOS needed explicit BackupPayloads / SettingsBackupConfig / BackupService edits because its cache keys live in UserDefaults behind an allow-list; yours falls out of the whole-cache envelope. JSON shape is identical on both (blocktankRefundAddress: {address, index}), Android tolerates unknown keys and range-checks the index, and settings backups from either platform pass through withRequiredNativeSegwitMonitoring() — so restores decode in both directions. Ownership-mismatch handling matches iOS exactly.

One residual I couldn't turn into a finding, recorded in case it's useful: if the BDK reveal cursor were ever lost while the metadata pointer survived, newAddress() could hand out index N before the next order re-reveals it. The cursor lives in the VSS-backed node KV store and I couldn't construct a restore path where that happens, so there's nothing to fix — noting it only because it's the one shape that would break the no-reuse property.

@ovitrif ovitrif changed the title fix: add lsp refund addresses fix: add lsp refund address Sep 8, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

fix: pass refundOnchainAddress to createOrder

2 participants