Skip to content

fix(dash-spv): rescan committed filter ranges for newly derived scripts#866

Open
HashEngineering wants to merge 2 commits into
dashpay:devfrom
HashEngineering:fix/committed-range-rescan-846
Open

fix(dash-spv): rescan committed filter ranges for newly derived scripts#866
HashEngineering wants to merge 2 commits into
dashpay:devfrom
HashEngineering:fix/committed-range-rescan-846

Conversation

@HashEngineering

@HashEngineering HashEngineering commented Jul 10, 2026

Copy link
Copy Markdown
Contributor

What

Fixes #846: scripts derived by gap-limit maintenance mid-sync had zero backward reach across a batch-commit boundary. rescan_batch only reaches active_batches; once a batch commits it is removed, its tracker records are pruned, and the wallet's advanced synced_height filters those heights out of any later match. An output paying a script that didn't exist yet when its block's batch committed stayed permanently invisible — and a fresh resync from genesis hit the same wall deterministically. The concrete population is CoinJoin wallets, whose parallel mixing sessions produce index↔height inversions (the e2e ground truth in #846 shows a real one at index ~1767).

How

BIP-158 filters are address-independent commitments and every height at or below stored_height has its filter persisted — so the committed range can be re-tested from local storage, with no network traffic. This is the structural advantage over the BIP37/bloom approach (bitcoinj/DashJ must discard and re-download blocks on filter exhaustion because a stale client-supplied filter makes peers stop sending relevant txs; a stored BIP-158 filter never goes stale, only the query side does).

rescan_committed_range runs at the same commit-time seam as the #820 forward rescan, whenever a committing batch has newly derived scripts:

  • matches only the new scripts (not the wallets' bare filter elements, which were already watched when the range was first scanned) against stored filters below the committing batch, chunked at BATCH_PROCESSING_SIZE, best-effort per chunk (a chunk the storage can't serve is skipped with a warning rather than failing the commit);
  • routes hits through the existing track_for_new_scripts re-download path, so previously-processed blocks are re-applied against the extended pools;
  • attributes hits to the committing batch, so its pending_blocks accounting holds the commit open and scripts derived from re-processed backward blocks feed the next fixpoint round automatically.

The sweep is bounded below by the earliest wallet birth height and the first stored filter; the latter comes from a new FilterStorage::filter_start_height accessor (the underlying SegmentCache already tracked it).

Testing

Adopts the cross-commit repro from repro/pr3549-rdc (coinjoin_gap_discovery_tests.rs) as the regression gate. One harness change was needed: the original test injected batches without persisting anything, but in production stored_height only advances after filters are written to storage — and stored filters are exactly what the recovery path reads. The test now seeds header/filter storage for the committed range to uphold that invariant. The assertion is unchanged.

Before (dev + tests only) — the seeded harness alone does not make it pass:

test coinjoin_gap_limit_stall_across_committed_batch ... FAILED
test coinjoin_gap_limit_inversion_within_batch_recovers ... ok
test coinjoin_gap_limit_dense_same_batch_recovers ... ok
  left: Some(29)   // highest_used stalls at the initial watch window
 right: Some(51)

After (dev + tests + fix):

test coinjoin_gap_limit_inversion_within_batch_recovers ... ok
test coinjoin_gap_limit_stall_across_committed_batch ... ok
test coinjoin_gap_limit_dense_same_batch_recovers ... ok
test result: ok. 3 passed; 0 failed

Full dash-spv suite: 474 unit + 7 bin tests pass; clippy clean; fmt applied. (dashd_masternode integration tests require DASHD_PATH and were not run.)

Notes for review

  • At the tip (post-sync incremental commits), a new-script derivation now sweeps the whole stored history. Correct, and it only fires when gap maintenance actually derives something, but for a mixing-heavy wallet with deep history there's a perf lever available later (bounded backward window, or a script-set bloom) if it shows up in profiles.
  • A residual sibling hole is out of scope here: new_scripts from a BlockProcessed whose block isn't in the in-flight tracker (sync_manager.rs — tip/mempool-confirmation paths) are still dropped without triggering any rescan.

Fixes #846

🤖 Generated with Claude Code

Summary by CodeRabbit

  • Bug Fixes

    • Improved CoinJoin wallet synchronization when new address activity is found out of order, including commit-time rescan of previously committed filter ranges.
    • Reduced sync stalls by more reliably reprocessing affected blocks when newly derived scripts require backward attribution.
    • Enhanced recovery behavior across dense, inverted, and cross-batch discovery scenarios to ensure wallet usage tracking progresses correctly.
  • Tests

    • Added deterministic regression tests covering dense, inverted, and cross-batch CoinJoin gap-limit discovery behaviors.

@coderabbitai

coderabbitai Bot commented Jul 10, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 5f784ac5-c38a-42e4-9353-b686dfbce903

📥 Commits

Reviewing files that changed from the base of the PR and between 064b392 and 381a4fe.

📒 Files selected for processing (5)
  • dash-spv/src/storage/filters.rs
  • dash-spv/src/storage/mod.rs
  • dash-spv/src/sync/filters/batch.rs
  • dash-spv/src/sync/filters/coinjoin_gap_discovery_tests.rs
  • dash-spv/src/sync/filters/manager.rs
🚧 Files skipped from review as they are similar to previous changes (4)
  • dash-spv/src/storage/filters.rs
  • dash-spv/src/storage/mod.rs
  • dash-spv/src/sync/filters/manager.rs
  • dash-spv/src/sync/filters/coinjoin_gap_discovery_tests.rs

📝 Walkthrough

Walkthrough

FiltersManager now rescans stored filters below a committing batch when newly derived scripts require backward attribution. Filter storage exposes the earliest stored filter height, and deterministic CoinJoin tests cover same-batch recovery, within-batch inversion, and committed-batch behavior.

Changes

CoinJoin gap-limit recovery

Layer / File(s) Summary
Filter storage start-height access
dash-spv/src/storage/filters.rs, dash-spv/src/storage/mod.rs
Storage traits and implementations expose the lowest stored filter height asynchronously.
Committed-range script rescan
dash-spv/src/sync/filters/batch.rs, dash-spv/src/sync/filters/manager.rs
Batches defer newly derived scripts, and commit processing rescans persisted filters below the batch start, tracks new matches, and emits required block requests.
CoinJoin pipeline regression coverage
dash-spv/src/sync/filters/coinjoin_gap_discovery_tests.rs, dash-spv/src/sync/filters/manager.rs
Deterministic tests exercise dense recovery, within-batch inversion, and committed-batch behavior through the production sync pipeline. Test-only wiring includes the new regression module.

Estimated code review effort: 4 (Complex) | ~45 minutes

Sequence Diagram(s)

sequenceDiagram
  participant FiltersManager
  participant DiskStorageManager
  participant BlockMatchTracker
  participant WalletManager
  FiltersManager->>DiskStorageManager: load committed filter chunks
  DiskStorageManager-->>FiltersManager: return persisted filters
  FiltersManager->>BlockMatchTracker: match newly derived scripts
  BlockMatchTracker-->>FiltersManager: emit BlocksNeeded
  FiltersManager->>WalletManager: process requested blocks
  WalletManager-->>FiltersManager: emit BlockProcessed
Loading

Possibly related PRs

Suggested labels: ready-for-review

Suggested reviewers: ZocoLini, xdustinface

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly states the main change: rescanning committed filter ranges for newly derived scripts.
Linked Issues check ✅ Passed The PR adds committed-range rescanning for newly derived scripts and preserves the active-batch fixpoint with regression tests for the cross-commit gap.
Out of Scope Changes check ✅ Passed The changes are tightly focused on the committed-range rescan fix, related storage access, and regression tests.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

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.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@codecov

codecov Bot commented Jul 10, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 89.15663% with 9 lines in your changes missing coverage. Please review.
✅ Project coverage is 73.97%. Comparing base (1ee1c94) to head (381a4fe).

Files with missing lines Patch % Lines
dash-spv/src/sync/filters/manager.rs 89.55% 7 Missing ⚠️
dash-spv/src/storage/mod.rs 0.00% 2 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##              dev     #866      +/-   ##
==========================================
+ Coverage   73.69%   73.97%   +0.27%     
==========================================
  Files         324      324              
  Lines       73434    73513      +79     
==========================================
+ Hits        54117    54381     +264     
+ Misses      19317    19132     -185     
Flag Coverage Δ
core 77.08% <ø> (ø)
ffi 48.04% <ø> (+1.60%) ⬆️
rpc 20.00% <ø> (ø)
spv 91.14% <89.15%> (+0.23%) ⬆️
wallet 73.25% <ø> (ø)
Files with missing lines Coverage Δ
dash-spv/src/storage/filters.rs 100.00% <100.00%> (ø)
dash-spv/src/sync/filters/batch.rs 97.81% <100.00%> (+0.21%) ⬆️
dash-spv/src/storage/mod.rs 86.68% <0.00%> (-0.55%) ⬇️
dash-spv/src/sync/filters/manager.rs 97.30% <89.55%> (-0.19%) ⬇️

... and 18 files with indirect coverage changes

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🧹 Nitpick comments (1)
dash-spv/src/sync/filters/manager.rs (1)

1002-1058: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Duplicate tracking/event-building logic vs. rescan_batch.

The block_to_walletstracker.track_for_new_scriptsblocks_needed/new_blocks_countpending_blocks accounting block (lines 1002-1058) closely mirrors the equivalent section in rescan_batch (lines 705-747). Extracting a shared helper (taking the match map, batch_start, and mutable access to tracker/progress/active_batches) would reduce the risk of the two paths silently diverging on future changes.

♻️ Sketch of a shared helper
fn attribute_matches_for_new_scripts(
    &mut self,
    batch_start: u32,
    block_to_wallets: BTreeMap<FilterMatchKey, BTreeSet<WalletId>>,
) -> Vec<SyncEvent> {
    // shared body: track_for_new_scripts loop, pending_blocks bump, BlocksNeeded push
}
🤖 Prompt for 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.

In `@dash-spv/src/sync/filters/manager.rs` around lines 1002 - 1058, Extract the
duplicated match-attribution and event-building logic from rescan_batch and the
committed-range rescan into a shared helper, such as
attribute_matches_for_new_scripts, on the containing type. Have it accept
batch_start and the block_to_wallets map, perform tracker.track_for_new_scripts,
update progress/pending_blocks/active_batches consistently, and produce the
required blocks_needed, new_blocks_count, and SyncEvent results; replace both
inline implementations with this helper.
🤖 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/sync/filters/manager.rs`:
- Around line 966-1030: Optimize rescan_committed_range to avoid rescanning the
same committed history for each fixpoint round. Add per-wallet high-water marks
tracking the earliest range already swept without matches, and use them to set
each wallet’s scan start so only previously unchecked history is loaded and
matched; update the marks after each successful sweep while preserving rescans
of ranges where matches require processing.

---

Nitpick comments:
In `@dash-spv/src/sync/filters/manager.rs`:
- Around line 1002-1058: Extract the duplicated match-attribution and
event-building logic from rescan_batch and the committed-range rescan into a
shared helper, such as attribute_matches_for_new_scripts, on the containing
type. Have it accept batch_start and the block_to_wallets map, perform
tracker.track_for_new_scripts, update progress/pending_blocks/active_batches
consistently, and produce the required blocks_needed, new_blocks_count, and
SyncEvent results; replace both inline implementations with this helper.
🪄 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: 7f2fbebe-28a8-4938-b90c-ac5eeab927c7

📥 Commits

Reviewing files that changed from the base of the PR and between 845ee9e and 064b392.

📒 Files selected for processing (4)
  • dash-spv/src/storage/filters.rs
  • dash-spv/src/storage/mod.rs
  • dash-spv/src/sync/filters/coinjoin_gap_discovery_tests.rs
  • dash-spv/src/sync/filters/manager.rs

Comment thread dash-spv/src/sync/filters/manager.rs
@HashEngineering

Copy link
Copy Markdown
Contributor Author

Review feedback addressed in 5cef7c5:

  • Backward sweep cost (major): deferred to forward-fixpoint quiescence with per-batch script accumulation — one combined sweep per commit instead of one per derivation round. See the inline reply for why the suggested skip-swept-ranges high-water mark would be unsound.
  • Duplicated attribution tail (nitpick): extracted queue_new_script_matches as the shared tail of rescan_batch and rescan_committed_range.

All 3 acceptance tests and the full dash-spv suite (474) still pass; clippy/fmt clean.

HashEngineering and others added 2 commits July 11, 2026 12:42
…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.

Fixes dashpay#846

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…edupe 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>
@HashEngineering HashEngineering force-pushed the fix/committed-range-rescan-846 branch from 5cef7c5 to 381a4fe Compare July 11, 2026 19:43
@HashEngineering

Copy link
Copy Markdown
Contributor Author

Field validation: real CoinJoin wallet on testnet, dev vs. this PR vs. DashJ

Tested with the dash-spv CLI against a real testnet wallet with heavy CoinJoin history (the Found-034 wallet), synced from the checkpoint anchor at scan_start=850000 to tip (~1512865). Reference ground truth: the same wallet in DashJ with a gap limit of 500 (1047 transactions: 61 unspent + 986 spent).

run unique txs found vs. DashJ (1047)
dev (845ee9e) 1037 10 missing
this PR (gap limit 30 + committed-range rescan) 1047 exact txid parity, zero diff either way

The 10 transactions dev misses — and why

All ten sit in the final ~280 blocks of batch [1480000–1484999] (batches commit at 850000 + 5000k), immediately below the 1485000 commit boundary:

height 1484723  016ceb91410c1abb007453ad9a18f41198e01a73eb5a5a6a2f4e8179a04a43d9  net  0        (mixing round)
height 1484724  953970c9938086d314f36764728bd32d131a16348b7402a87bf76f546e70556c  net  0
height 1484727  fee6d4c1138f691f36742b2388462451b8bb966fb69e6e8fd791ad04497b4ccf  net  0
height 1484728  acc7564df579fe8c72354837dfed4f5e01376115ae6a66382e791b394a143dcd  net  0
height 1484743  9fa6445f763c4d6fa2269afaa09a45876724907db222fa5270a7c113bb2cc456  net -0.200002
height 1484745  9f721d591079b3357999f13594367d040c84486e9b0e0672111029220c4cd714  net  0
height 1484803  19e40aa1549cfb9fbeaeaf35b40ec28b5beb7622dd7d857eb3602b83e3f26e9b  net  0
height 1484806  65e76fc97bb560bbb5577a2d6e0d11ae76bc51b44296040259b6debf0a7fff65  net  0
height 1484812  10e860d5e760e3ac8826d59f4f35650fa1ec8df6d5a80458aeebe34b7e0f2ba0  net  0
height 1484813  41406dfe036d3e423cb0f2f9e6ec26776b3af71748ae15ce8a890edfada0517b  net  0

Their addresses were derived from transactions discovered in blocks at or above 1485000 — after batch [1480000–1484999] had committed — so on dev nothing ever re-tests those filters: the #846 mechanism, observed in the wild. All 10 are present in DashJ's set. Both runs used the same data dir (shared chain storage), and the wallet rebuilds from the mnemonic each run, so the miss is deterministic — a resync does not recover them on dev.

Note the nine net-zero mixing rounds understate the damage: each spends a denominated input the wallet owns, so missing them leaves those denoms falsely unspent and silently corrupts the coins' mixing history/round counts.

The recovery path, visible in the log

The fix run shows the committed-range sweep firing at successive batch commits as the discovery frontier climbs:

19:59:15 Committed-range rescan found 2 additional blocks
19:59:16 Committed-range rescan found 7 additional blocks
19:59:18 Committed-range rescan found 260 ...
19:59:21 ... 112 / 203 / 221 / 18 / 81 / 361 / 109 / 278 ...
19:59:41 Committed-range rescan found 449 additional blocks

The 10 transactions' detection timestamps land inside that sequence. Each full-history re-match completed in ~1s wall-clock (stored filters + GCS matching), supporting the deferred-sweep design being sufficient without further optimization.

Known residual (separate issue)

The fix run's final balance is 16.08717251 vs DashJ's 12.08713251 — exactly 4 × 1.00001 denominated coins held as falsely unspent. Identical tx sets with an inflated balance points at #649 (out-of-order UTXO spend), fixed by the open #851: the backward sweep re-applies old funding blocks after their spends were already processed, which without #851 re-inserts spent outputs as spendable. The two PRs are complementary — this one fixes which transactions are found, #851 fixes applying them out of order — and a combined-branch run is expected to reproduce DashJ's balance exactly.

🤖 Generated with Claude Code

@HashEngineering

Copy link
Copy Markdown
Contributor Author

Follow-up: combined run with #851 closes the balance residual

Re-ran the same wallet/data-dir with this PR merged together with #851 (plus a local CLI patch passing --start-height as the wallet birth height, needed to avoid a restart-loop interaction reported on #851): final balance 12.08713251 — exact match with DashJ, confirming the earlier residual was #649/#851 as suspected, not this PR.

One nuance surfaced for completeness: the combined run records 1039 of the 1047 transactions — the 8 gap are out-of-order funding txs that #851's spend-first path currently skips from history (balance unaffected; blocks were downloaded and processed, so discovery by this PR worked). Details reported on #851.

🤖 Generated with Claude Code

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.

dash-spv: gap-limit scripts derived after a batch commits never re-open the committed range — wallet funds stay invisible (Found-034)

1 participant