Skip to content

fix(dash-spv): verify filter batches against the full script set before commit - #958

Closed
PastaPastaPasta wants to merge 2 commits into
devfrom
fix/filter-rescan-full-set-commit
Closed

fix(dash-spv): verify filter batches against the full script set before commit#958
PastaPastaPasta wants to merge 2 commits into
devfrom
fix/filter-rescan-full-set-commit

Conversation

@PastaPastaPasta

@PastaPastaPasta PastaPastaPasta commented Aug 13, 2026

Copy link
Copy Markdown
Member

The bug

During an SPV restore of a wallet with many sequentially used addresses, discovery can permanently lose transactions and silently flatline while sync runs happily to tip.

Observed in production on a mainnet wallet with 345,261 used addresses: discovery froze at address index 2,400 after the first ~500 blocks of activity and missed 880k+ transactions. The wallet reported itself fully synced.

Why it happens

Filter sync processes compact filters in 5,000-block batches. When a matched block is processed and extends the wallet's address pools (gap-limit maintenance), the newly derived scripts are collected and re-matched against the batch's filters at commit time — the "gap-limit chase." The chase loops: rescan → download newly matched blocks → process → collect newly derived scripts → rescan again.

The loop's termination signal is "the latest wave derived no new scripts." That signal is wrong, twice:

  1. Backfill waves derive nothing. Payments to sequentially derived addresses are mined in mempool order, not derivation order. A wave whose newly visible transactions all pay already-derived indices doesn't move highest_used, so no scripts are derived — even though the batch still holds blocks paying indices past the window. The chase ends, the batch commits.
  2. Cross-batch derivations never enter this batch's collected set. Scripts derived from blocks owned by other batches (or blocks whose batch is already gone — previously those scripts were silently dropped) are never matched against this batch before it commits.

And commits are one-way: a committed batch is removed from the active set and synced_height advances past it, so the missed blocks are never looked at again. Worse, the loss cascades: all later activity pays indices above the frozen window, so later filters match nothing, no blocks download, the pool never extends again, and discovery goes permanently silent.

The fix

Track a monotone script-derivation generation on the manager, bumped whenever block processing derives new scripts (including for blocks whose owning batch is gone). Each batch records the generation it was last matched against the wallets' full script sets (its initial scan, or a verification rescan).

At commit time, a batch whose recorded generation is stale gets a verification rescan against the full current script sets, and may not complete its rescan while that verification still finds unprocessed blocks. The loop reaches a true fixpoint: a batch commits only when its filters genuinely contain nothing more for everything the wallet currently knows.

Quiet syncs pay nothing: with no derivations since the batch's scan, the generations match and verification is skipped entirely.

Proof via tests

run_dust_restore drives the real FiltersManager + WalletManager<ManagedWalletInfo> over a synthetic 5,120-block chain: 3,000 dust payments to external addresses 0–2,999, mined 25 per block across a BATCH_PROCESSING_SIZE boundary (vs gap limit 30), with simulated block-delivery latency.

  • dust_restore_discovers_all_txs_when_mined_in_index_order — control: in derivation order the chase keeps up block by block; passes with and without the fix.
  • dust_restore_discovers_all_txs_when_mined_out_of_order — regression: payments shuffled deterministically (fixed-seed LCG, no rand dependency) the way mempool waves actually land. Without the fix it loses 36 of 3,000 transactions (batch committed on a backfill-only wave); with the fix discovery completes.

At production scale the same mechanism loses far more — a 200-block / 8,000-tx variant of this harness lost 44% of transactions before the fix and finds 100% after.

Validation

  • cargo test -p dash-spv --all-features — 550 unit tests green (incl. the 2 new ones)
  • cargo test -p dash-spv --test dashd_sync against regtest dashd — 30/30 green
  • cargo clippy --all-targets --all-features, cargo fmt — clean

Notes / follow-ups (intentionally out of scope)

  • During an active chase, each verification wave re-downloads the batch's matched blocks (track_for_new_scripts deliberately re-processes). Correctness first; a delta-tracking optimization can bound the re-downloads later.
  • A frontier that extends after a correctly-committed batch (payer paid far beyond the gap limit, confirmed long before the wallet's own usage reached that index) is still invisible — that needs a synced-height rewind driven by pool extension, and is left for a separate change.
  • The external/internal gap limit of 30 (vs dashj's 100) makes restores of dense-usage wallets slower than they need to be; widening it is a separate discussion.

🤖 Generated with Claude Code

Summary by CodeRabbit

  • Bug Fixes
    • Improved wallet transaction discovery when new address derivations occur during block synchronization.
    • Prevented transactions from being missed across batch boundaries, including out-of-order mining scenarios.
    • Added verification rescans to ensure newly derived wallet scripts are checked before results are committed.

…re commit

A filter batch used to commit once the gap-limit chase's latest wave derived no new scripts. That signal is wrong twice over: scripts derived from blocks owned by other batches never enter this batch's collected set, and a wave whose visible transactions all pay already-derived indices (backfill - inevitable when dust waves are mined out of derivation order) derives nothing even though the batch still holds blocks paying indices past the window. Committing on it ends the chase early, and committed batches are never rescanned, so every transaction above the window at commit time is permanently lost and discovery flatlines: later activity pays underived addresses, filters stop matching, and the pool never extends again.

Observed in production on a mainnet wallet with 345,261 sequentially used addresses: discovery froze at address index 2,400 after ~500 blocks of activity and silently missed 880k+ transactions while sync ran to tip.

The manager now keeps a monotone script-derivation generation, bumped whenever block processing derives new scripts - including for blocks whose owning batch is already gone, whose scripts were previously dropped without ever being matched. Each batch records the generation it was last matched against the wallets' full script sets (initial scan or verification). At commit time, a batch whose recorded generation is stale is re-matched against the full current sets and may not complete its rescan while that verification still finds blocks. Quiet syncs never pay for this: with no derivations the generations match and the verification is skipped.

The regression test drives the real FiltersManager + WalletManager over a synthetic dust restore whose payments land out of derivation order across a batch boundary; without this fix it loses 36 of 3,000 transactions, with it discovery completes (as does the in-order control).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@coderabbitai

coderabbitai Bot commented Aug 13, 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 Plus

Run ID: 4656d96b-0794-4f19-bec5-ac8e9432ba33

📥 Commits

Reviewing files that changed from the base of the PR and between 08c7e2d and 7b4d22e.

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

📝 Walkthrough

Walkthrough

Filters synchronization now tracks wallet script generations. Batch commits wait for pending blocks and rescan when later derivations occurred. A dust-restore harness tests ordered and deterministic out-of-order mining across a batch boundary.

Changes

Wallet script generation tracking

Layer / File(s) Summary
Generation state contracts
dash-spv/src/sync/filters/batch.rs, dash-spv/src/sync/filters/manager.rs
FiltersBatch records its last full-match generation. FiltersManager initializes the current script generation.
Generation update and batch matching
dash-spv/src/sync/filters/sync_manager.rs, dash-spv/src/sync/filters/manager.rs
Wallet script derivations increment the manager generation. Newly scanned batches record the current generation.
Commit-time verification and regression coverage
dash-spv/src/sync/filters/manager.rs
Batch commits wait for pending blocks and rescan stale matches. Integration tests cover ordered and deterministic out-of-order dust restoration.

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

Mergeability Score: 🔵 Low · up to 7b4d2

The change prevents transactions from being missed during dense wallet restores, but commit-time verification can repeatedly re-download and re-match batch blocks, increasing restore CPU, network usage, and latency. This is a bounded performance risk that should remain with explicit owner awareness or follow-up optimization.

Possibly related issues

Possibly related PRs

Suggested labels: ready-for-review

Sequence Diagram(s)

sequenceDiagram
  participant SyncManager
  participant FiltersManager
  participant FiltersBatch
  participant WalletScripts
  SyncManager->>FiltersManager: Process BlockProcessed scripts
  FiltersManager->>FiltersManager: Increment script_generation
  FiltersManager->>FiltersBatch: Record full-match generation
  FiltersManager->>WalletScripts: Rescan with current full script set
  WalletScripts-->>FiltersManager: Return newly matched blocks
  FiltersManager->>FiltersBatch: Defer commit when matches or pending blocks remain
Loading
🚥 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 and concisely describes the primary change: verifying filter batches against the full script set before commit.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/filter-rescan-full-set-commit

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

@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.

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

596-643: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Add a focused in-module test for the generation gate.

run_dust_restore covers the gate end-to-end only. A direct test would pin the contract and would fail fast if the gate regresses. The existing tests already build a FiltersBatch by hand and call try_commit_batches, so the setup is small:

  • Insert a scanned batch with set_full_match_generation(0) and matching filters, set manager.script_generation = 1, then assert the batch does not commit while the rescan finds blocks.
  • Set the batch generation equal to script_generation and assert the batch commits without any rescan.

As per path instructions for dash-spv/**/{src,tests}/**/*.rs: "Implement comprehensive unit tests in-module for individual components using #[cfg(test)]".

🤖 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 596 - 643, Add a focused
in-module #[cfg(test)] test covering the generation gate in try_commit_batches:
construct a scanned FiltersBatch with matching filters and
full_match_generation(0), set manager.script_generation to 1, and assert it
remains uncommitted when rescan_batch finds blocks; then set the batch
generation to the current script generation and assert it commits without
rescanning. Reuse the existing test setup and batch-building helpers.

Source: Path instructions


610-643: 🚀 Performance & Scalability | 🔵 Trivial | ⚖️ Poor tradeoff

Consider bounding the cost of the verification rescan.

The gate is correct and it terminates: each pass records generation_now, so a pass that finds no blocks leaves the generations equal and the next pass commits.

The cost is the concern. Every pass rebuilds the full scan script set for each scanned wallet and re-matches every filter in the batch. During a dense restore, one pass runs per derivation wave. For a wallet with a large scan set this is repeated O(waves × filters × scripts) work on the sync path.

Two options that keep the same guarantee:

  • Pass only the scripts derived since the batch's recorded generation, instead of the full set. That needs a generation-keyed script log, so it is the larger change.
  • Cache the per-wallet full set for the duration of one try_commit_batches call, so several batches in the same drain do not each rebuild it.

Neither is required for correctness. Track the rescan duration before choosing.

🤖 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 610 - 643, The
verification rescan in try_commit_batches is correct but can repeatedly rebuild
wallet script sets and rematch filters; add duration tracking around the
needs_verification/rescan_batch work so the cost is observable before selecting
an optimization. Preserve the existing generation tracking and rescan behavior,
and use the duration to distinguish full-set construction from rescan matching
where practical.
🤖 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.

Nitpick comments:
In `@dash-spv/src/sync/filters/manager.rs`:
- Around line 596-643: Add a focused in-module #[cfg(test)] test covering the
generation gate in try_commit_batches: construct a scanned FiltersBatch with
matching filters and full_match_generation(0), set manager.script_generation to
1, and assert it remains uncommitted when rescan_batch finds blocks; then set
the batch generation to the current script generation and assert it commits
without rescanning. Reuse the existing test setup and batch-building helpers.
- Around line 610-643: The verification rescan in try_commit_batches is correct
but can repeatedly rebuild wallet script sets and rematch filters; add duration
tracking around the needs_verification/rescan_batch work so the cost is
observable before selecting an optimization. Preserve the existing generation
tracking and rescan behavior, and use the duration to distinguish full-set
construction from rescan matching where practical.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 390e1ccb-5507-4dac-82bb-55cb8fed7b1b

📥 Commits

Reviewing files that changed from the base of the PR and between 173ffac and 08c7e2d.

📒 Files selected for processing (3)
  • dash-spv/src/sync/filters/batch.rs
  • dash-spv/src/sync/filters/manager.rs
  • dash-spv/src/sync/filters/sync_manager.rs

coderabbitai[bot]
coderabbitai Bot previously approved these changes Aug 13, 2026
…n flight

A block in flight for a later batch can still derive new scripts when it lands, and those scripts can match an earlier batch's filters. Sealing the earlier batch during that window raced the delivery - the same knowledge-behind-the-watermark loss the verification rescan prevents, through a narrower window. Commits now wait for global block quiescence, so a seal implies no undelivered derivations exist anywhere and the verification fixpoint argument has no gaps.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@PastaPastaPasta

Copy link
Copy Markdown
Member Author

Validation update: an end-to-end mainnet resync of the production wallet (dash-spv binary from this branch, start height 2,440,000) reproduced the original outcome — 8,076 transactions discovered, discovery stopping at the same address frontier — despite this fix.

The harness regression this PR fixes is real (the out-of-order test loses 36/3,000 on dev and passes here), but it is evidently not the (only) mechanism behind the production stall. An instrumented rerun is in progress to identify what actually terminates the chase on the real chain; holding this PR as draft-quality until that lands — the fix stands on its own merits, but the production claim in the description is overstated until the real mechanism is found.


🤖 Posted autonomously by Claude on behalf of pasta.

@codecov

codecov Bot commented Aug 13, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 99.04306% with 2 lines in your changes missing coverage. Please review.
✅ Project coverage is 76.52%. Comparing base (173ffac) to head (7b4d22e).

Files with missing lines Patch % Lines
dash-spv/src/sync/filters/manager.rs 99.00% 2 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##              dev     #958      +/-   ##
==========================================
+ Coverage   76.47%   76.52%   +0.04%     
==========================================
  Files         329      329              
  Lines       80353    80562     +209     
==========================================
+ Hits        61453    61648     +195     
- Misses      18900    18914      +14     
Flag Coverage Δ
core 78.25% <ø> (ø)
ffi 52.19% <ø> (ø)
rpc 20.00% <ø> (ø)
spv 91.87% <99.04%> (+0.01%) ⬆️
wallet 77.57% <ø> (ø)
Files with missing lines Coverage Δ
dash-spv/src/sync/filters/batch.rs 97.72% <100.00%> (+0.12%) ⬆️
dash-spv/src/sync/filters/sync_manager.rs 100.00% <100.00%> (ø)
dash-spv/src/sync/filters/manager.rs 97.87% <99.00%> (-0.08%) ⬇️

... and 7 files with indirect coverage changes

@PastaPastaPasta

Copy link
Copy Markdown
Member Author

Closing: end-to-end validation against the production wallet showed this change does not alter the real-wallet outcome (discovery still stops at the same frontier). The commit-termination issue the tests capture is real but is downstream of a more fundamental defect in the block re-application path that the harness masked; a corrected fix addressing the actual mechanism will supersede this PR.


🤖 Posted autonomously by Claude on behalf of pasta.

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.

1 participant