Uh oh!
There was an error while loading. Please reload this page.
fix(dash-spv): reprocess blocks on rescan against newly derived scripts - #820
Conversation
The commit-time batch rescan re-matches scripts derived during block processing against the active batches, but `BlockMatchTracker::track` subtracted wallets that already had a block applied, so a block processed before those scripts existed was silently skipped. Scripts derived from processing a block could therefore never trigger a re-examination of any earlier block in the same batch, and partially recognized blocks stayed partial. Add `track_for_new_scripts`, which keeps the in-flight accounting of `track` but ignores processed records, and use it in `rescan_batch`: every match there is by construction against scripts that did not exist at first processing. Re-application is safe because wallet-side block processing is idempotent (transaction dedup, outpoint-keyed UTXO insertion, balance recomputed from the UTXO set). Termination is preserved since the rescan only queries newly collected scripts, and re-processing a block whose outputs are already recognized derives nothing further. The plain `scan_batch` path keeps the processed-record skip, late-added wallets still get their residual pass through plain `track`.
One transaction pays a run of consecutive fresh addresses reaching past the gap window, mined before the client ever starts. The historical scan must climb the burst via fixpoint re-scanning and credit every output. Adds `DashCoreNode::send_many` so a regtest wallet can pay many addresses in a single transaction.
📝 WalkthroughWalkthroughAdds ChangesGap-window rescan via track_for_new_scripts
Estimated code review effort🎯 3 (Moderate) | ⏱️ ~25 minutes Possibly related PRs
Suggested labels
Suggested reviewers
Poem
🚥 Pre-merge checks | ✅ 5✅ Passed checks (5 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches📝 Generate docstrings
🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
Codecov Report❌ Patch coverage is
Additional details and impacted files@@ Coverage Diff @@## dev #820 +/- ##
==========================================
+ Coverage 72.82% 73.02% +0.20%
==========================================
Files 323 323 Lines 72000 72072 +72 ==========================================
+ Hits 52432 52633 +201 + Misses 19568 19439 -129
|
There was a problem hiding this comment.
Actionable comments posted: 1
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@dash-spv/src/test_utils/node.rs`:
- Around line 322-327: The send_many helper is collapsing duplicate destination
addresses when building the serde_json::Map, which can silently change the
intended outputs. Update send_many to detect repeated address strings before
constructing the RPC object, and either reject them with an explicit error or
aggregate their amounts intentionally. Make the behavior clear in send_many and
the amount-to-map building logic so each `(address, amount)` pair is handled
deterministically.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Path: .coderabbit.yaml
Review profile: CHILL
Plan: Pro
Run ID: e4d9b656-3aa2-423c-a62f-c0a3fcc1e0ff
📒 Files selected for processing (4)
dash-spv/src/sync/filters/block_match_tracker.rsdash-spv/src/sync/filters/manager.rsdash-spv/src/test_utils/node.rsdash-spv/tests/dashd_sync/tests_transaction.rs
Uh oh!
There was an error while loading. Please reload this page.
ZocoLini
left a comment
There was a problem hiding this comment.
lgtm so far, I am concern about re-downloading blocks, is it doing that instead of temporally caching them??
| blocks_needed.insert(key, wallets); | ||
| } | ||
| // Never returned by track_for_new_scripts. | ||
| BlockTrackResult::AlreadyProcessed => {} |
There was a problem hiding this comment.
what about unreachable!() here
xdustinface
commented
Jun 26, 2026
@ZocoLini We don't re-download. Blocks are on disk still. |
Uh oh!
There was an error while loading. Please reload this page.
ZocoLini
commented
Jun 26, 2026
perfect then |
…ts (dashpay#846) Scripts derived by gap-limit maintenance mid-sync (CoinJoin index-height inversions being the concrete case) previously had zero backward reach across a batch-commit boundary: `rescan_batch` only reaches `active_batches`, commit prunes the tracker records, and the wallet's advanced `synced_height` filters those heights out of any later match. Outputs paying such scripts in an already-committed range stayed permanently invisible, and a fresh resync hit the same wall. BIP-158 filters are address-independent commitments and are all persisted, so the committed range can be re-tested from storage with no network traffic. `rescan_committed_range` runs at the same commit-time seam as the dashpay#820 forward rescan: it matches only the newly derived scripts against stored filters below the committing batch (chunked, best-effort per chunk), routes hits through the existing `track_for_new_scripts` re-download path, and attributes them to the committing batch so its pending-blocks accounting holds the commit open until the fixpoint drains — scripts derived from re-processed backward blocks feed the next round automatically. Adds `FilterStorage::filter_start_height` to bound the sweep, and adopts the cross-commit repro from `repro/pr3549-rdc` (with header/filter storage seeded to match the production invariant that heights at or below `stored_height` are persisted) as the regression gate; the two dashpay#820 guards stay green. Fixesdashpay#846 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ts (dashpay#846) Scripts derived by gap-limit maintenance mid-sync (CoinJoin index-height inversions being the concrete case) previously had zero backward reach across a batch-commit boundary: `rescan_batch` only reaches `active_batches`, commit prunes the tracker records, and the wallet's advanced `synced_height` filters those heights out of any later match. Outputs paying such scripts in an already-committed range stayed permanently invisible, and a fresh resync hit the same wall. BIP-158 filters are address-independent commitments and are all persisted, so the committed range can be re-tested from storage with no network traffic. `rescan_committed_range` runs at the same commit-time seam as the dashpay#820 forward rescan: it matches only the newly derived scripts against stored filters below the committing batch (chunked, best-effort per chunk), routes hits through the existing `track_for_new_scripts` re-download path, and attributes them to the committing batch so its pending-blocks accounting holds the commit open until the fixpoint drains — scripts derived from re-processed backward blocks feed the next round automatically. Adds `FilterStorage::filter_start_height` to bound the sweep, and adopts the cross-commit repro from `repro/pr3549-rdc` (with header/filter storage seeded to match the production invariant that heights at or below `stored_height` are persisted) as the regression gate; the two Fixesdashpay#846 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ts (dashpay#846) Scripts derived by gap-limit maintenance mid-sync (CoinJoin index-height inversions being the concrete case) previously had zero backward reach across a batch-commit boundary: `rescan_batch` only reaches `active_batches`, commit prunes the tracker records, and the wallet's advanced `synced_height` filters those heights out of any later match. Outputs paying such scripts in an already-committed range stayed permanently invisible, and a fresh resync hit the same wall. BIP-158 filters are address-independent commitments and are all persisted, so the committed range can be re-tested from storage with no network traffic. `rescan_committed_range` runs at the same commit-time seam as the dashpay#820 forward rescan: it matches only the newly derived scripts against stored filters below the committing batch (chunked, best-effort per chunk), routes hits through the existing `track_for_new_scripts` re-download path, and attributes them to the committing batch so its pending-blocks accounting holds the commit open until the fixpoint drains — scripts derived from re-processed backward blocks feed the next round automatically. Adds `FilterStorage::filter_start_height` to bound the sweep, and adopts the cross-commit repro from `repro/pr3549-rdc` (with header/filter storage seeded to match the production invariant that heights at or below `stored_height` are persisted) as the regression gate; the two Fixesdashpay#846 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ts (#866) * fix(dash-spv): rescan committed filter ranges for newly derived scripts (#846) Scripts derived by gap-limit maintenance mid-sync (CoinJoin index-height inversions being the concrete case) previously had zero backward reach across a batch-commit boundary: `rescan_batch` only reaches `active_batches`, commit prunes the tracker records, and the wallet's advanced `synced_height` filters those heights out of any later match. Outputs paying such scripts in an already-committed range stayed permanently invisible, and a fresh resync hit the same wall. BIP-158 filters are address-independent commitments and are all persisted, so the committed range can be re-tested from storage with no network traffic. `rescan_committed_range` runs at the same commit-time seam as the #820 forward rescan: it matches only the newly derived scripts against stored filters below the committing batch (chunked, best-effort per chunk), routes hits through the existing `track_for_new_scripts` re-download path, and attributes them to the committing batch so its pending-blocks accounting holds the commit open until the fixpoint drains — scripts derived from re-processed backward blocks feed the next round automatically. Adds `FilterStorage::filter_start_height` to bound the sweep, and adopts the cross-commit repro from `repro/pr3549-rdc` (with header/filter storage seeded to match the production invariant that heights at or below `stored_height` are persisted) as the regression gate; the two Fixes#846 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * refactor(dash-spv): defer the backward sweep to forward quiescence; dedupe match attribution Address review feedback on the committed-range rescan: - The backward sweep over stored history is the expensive direction, and the commit-time fixpoint could re-walk it once per derivation round. Scripts now accumulate on the committing batch (`FiltersBatch::backward_scripts`) while the forward fixpoint converges, and a single combined sweep runs only when the forward direction is quiescent. Blocks it finds re-enter through `collected_scripts`, so only genuinely new scripts get a follow-up sweep — each distinct script crosses the committed range exactly once. (A skip-swept-ranges high-water mark would be unsound here: a range swept for round N's scripts is not covered for round N+1's.) - Extract `queue_new_script_matches` as the shared tail of `rescan_batch` and `rescan_committed_range` (track_for_new_scripts routing, pending_blocks accounting, BlocksNeeded emission) so the two paths cannot silently diverge. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * test(dash-spv): make coinjoin gap repro indices gap-relative after #868 The repros funded indices 40..=51 as "beyond the initial watch window", which was true at the gap of 30 they were written against. #868 widened DEFAULT_COINJOIN_GAP_LIMIT to 100, putting those indices inside the initial window and turning the cross-commit repro trivially green with or without the fix. Express every funded range relative to the gap constant (G+10..=G+21 beyond, 0..=29 within) so the tests keep pinning the beyond-window shapes; verified red on dev (highest_used stalls at Some(29) vs expected Some(121)) and green with the fix. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
…es outputs A transaction processed before its beyond-window output's address was derived records that output as Sent (counterparty) with net_amount equal to the full input value. The gap-limit rescan (#820) re-processes the block and update_utxos heals the account's UTXO set — but confirm_transaction only mutated and re-emitted the record when its context changed, so the record and every persistence mirror built from emitted events kept the born-wrong shape forever. A reload from such a mirror is a visible fund loss (kotlin-sdk TXO-store bug, 2026-08-19). update_utxos now reports every output it recognizes as ours (including outputs skipped for insertion because they are already spent on-chain), and confirm_transaction folds that recognition back into the stored record — role flips (Sent -> Received/Change), net_amount and direction recomputed over the completed details — and returns the corrected record so the caller emits it as an updated-record event. Repro: born_wrong_record_is_corrected_by_gap_rescan drives the real filter -> block -> wallet pipeline with a self-send whose second output pays a beyond-window index, and asserts both the in-memory record and the LAST emitted record carry the corrected ownership. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…rescans after restart Scripts derived during block processing carry a rescan obligation (forward over active batches, backward over the committed range — #820/#846). That obligation lived only in memory: a process death between derivation and the cascade's COMMIT orphaned it, and nothing in a restarted session ever looks below the committed boundary again — outputs paying those scripts stay invisible to the engine forever (the interrupted-restore fund loss, 2026-08-19; on Android, LMK kills make interrupted syncs the common case). The pending-sweep set is now mirrored to metadata storage: persisted the moment scripts enter the manager (before any sweep work), re-seeded into the lowest active batch after a restart (the ordinary commit-time cascade then owns it), and cleared per batch COMMIT against the batch's retired-scripts receipt — the only point that proves the whole fixpoint completed. Opt-in via FiltersManager::with_metadata; managers without it keep the previous in-memory behavior. Repro: interrupted_sweep_is_replayed_after_restart — cross-committed-batch shape, session 1 dropped right after the scripts are derived, session 2 over the same storage recovers the committed-range outputs. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…es outputs A transaction processed before its beyond-window output's address was derived records that output as Sent (counterparty) with net_amount equal to the full input value. The gap-limit rescan (#820) re-processes the block and update_utxos heals the account's UTXO set — but confirm_transaction only mutated and re-emitted the record when its context changed, so the record and every persistence mirror built from emitted events kept the born-wrong shape forever. A reload from such a mirror is a visible fund loss (kotlin-sdk TXO-store bug, 2026-08-19). update_utxos now reports every output it recognizes as ours (including outputs skipped for insertion because they are already spent on-chain), and confirm_transaction folds that recognition back into the stored record — role flips (Sent -> Received/Change), net_amount and direction recomputed over the completed details — and returns the corrected record so the caller emits it as an updated-record event. Repro: born_wrong_record_is_corrected_by_gap_rescan drives the real filter -> block -> wallet pipeline with a self-send whose second output pays a beyond-window index, and asserts both the in-memory record and the LAST emitted record carry the corrected ownership. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…rescans after restart Scripts derived during block processing carry a rescan obligation (forward over active batches, backward over the committed range — #820/#846). That obligation lived only in memory: a process death between derivation and the cascade's COMMIT orphaned it, and nothing in a restarted session ever looks below the committed boundary again — outputs paying those scripts stay invisible to the engine forever (the interrupted-restore fund loss, 2026-08-19; on Android, LMK kills make interrupted syncs the common case). The pending-sweep set is now mirrored to metadata storage: persisted the moment scripts enter the manager (before any sweep work), re-seeded into the lowest active batch after a restart (the ordinary commit-time cascade then owns it), and cleared per batch COMMIT against the batch's retired-scripts receipt — the only point that proves the whole fixpoint completed. Opt-in via FiltersManager::with_metadata; managers without it keep the previous in-memory behavior. Repro: interrupted_sweep_is_replayed_after_restart — cross-committed-batch shape, session 1 dropped right after the scripts are derived, session 2 over the same storage recovers the committed-range outputs. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The commit-time batch rescan re-matches scripts derived during block processing against the active batches, but
BlockMatchTracker::tracksubtracted wallets that already had a block applied, so a block processed before those scripts existed was silently skipped. Scripts derived from processing a block could therefore never trigger a re-examination of any earlier block in the same batch, and partially recognized blocks stayed partial.Add
track_for_new_scripts, which keeps the in-flight accounting oftrackbut ignores processed records, and use it inrescan_batch: every match there is by construction against scripts that did not exist at first processing. Re-application is safe because wallet-side block processing is idempotent (transaction dedup, outpoint-keyed UTXO insertion, balance recomputed from the UTXO set). Termination is preserved since the rescan only queries newly collected scripts, and re-processing a block whose outputs are already recognized derives nothing further.The plain
scan_batchpath keeps the processed-record skip, late-added wallets still get their residual pass through plaintrack.Summary by CodeRabbit
New Features
Bug Fixes