Skip to content

fix(dash-spv): re-open committed ranges for gap-limit scripts derived after commit (#846)#873

Open
bfoss765 wants to merge 3 commits into
dashpay:devfrom
bfoss765:fix/846-gap-scripts-committed-range
Open

fix(dash-spv): re-open committed ranges for gap-limit scripts derived after commit (#846)#873
bfoss765 wants to merge 3 commits into
dashpay:devfrom
bfoss765:fix/846-gap-scripts-committed-range

Conversation

@bfoss765

@bfoss765 bfoss765 commented Jul 12, 2026

Copy link
Copy Markdown
Contributor

Summary

Gap-limit scripts derived after a filter batch commits were never re-tested against the committed range, so outputs paying gap-window addresses in an already-committed block stayed permanently invisible — a silent fund-integrity failure. PR #820 fixed the in-flight (active-batch) half; this PR closes the remaining cross-commit-boundary half.

Fixes #846.

Root cause

Discovery suppression was keyed by scan progress (which heights a wallet had already committed), not by which scripts a block was actually tested against:

  • rescan_batch bails for any batch not in active_batches (only in-flight batches are reachable).
  • Commit removes the batch (active_batches.remove) and prunes the BlockMatchTracker at/below the committed height.
  • A wallet is recorded done on BlockProcessed and its synced_height advances at commit.

So a forward index↔height inversion — a low external index funded in a later block, whose gap-limit window extension covers a higher index paid in an earlier, already-committed block — leaves the earlier block's outputs unrecoverable. check_transaction_for_match only ever sees already-generated addresses, so the SPV-side re-test is the only recovery path, and it never reached the committed range.

This bites CoinJoin wallets hardest (dense, slightly-out-of-order index usage).

Fix

Give the new-script re-test backward reach across the commit boundary, keyed by the script set rather than by scan progress:

  1. Retain committed filters. On commit, a batch's filters are moved (not cloned) into a bounded sliding window committed_filters keyed by height. The common no-new-scripts path is untouched — this only changes whether a just-committed batch's filters are freed or parked.
  2. Queue derived scripts with a ceiling. Every BlockProcessed that carries gap-limit-derived scripts enqueues them together with the committed_height snapshot at derivation time. Heights above that ceiling were still in active batches at derivation and were tested against these scripts by the normal per-batch scan, so only the range at/below the ceiling is owed a re-test.
  3. Drain to a fixpoint. A new phase in try_process_batch re-tests the fresh scripts against the retained committed filters ≤ ceiling and re-queues any matched committed block through the existing track_for_new_scripts path. A re-opened block that itself derives further scripts feeds the same queue, cascading to quiescence.
  4. Dedup. Per-wallet committed_tested_scripts ensures each script pays exactly one pass over the committed range; a range that commits later already saw the script via the normal scan, so no re-test is owed for it.
  5. Completion gating. FiltersSyncComplete is held off while a re-opened below-frontier block is still in flight (it has no owning active batch to account for it); retained filters are released on full sync.

Trade-offs

  • Performance. Zero overhead on the common no-new-scripts path (queue empty → early return). Extra work is confined to the derive-mid-sync (CoinJoin) path and is bounded by the retained window MAX_RETAINED_COMMITTED_FILTERS (20 000 heights ≈ 4 batches). Filters are matched in memory — no disk I/O.
  • Reach vs. memory are one knob. The retained window bounds both peak memory and how far back the re-test reaches. An inversion spanning more than the window is not recovered by the in-memory path; a birth-height rescan remains the backstop. The window comfortably exceeds the local span of a CoinJoin mixing session, and is kept modest for mobile hosts.
  • Restart / persistence. The queue, dedup set, and retained filters are in-memory only. The re-open is driven by a BlockProcessed that derives scripts, which happens whenever a funding block is (re)processed during a sync pass — so a wallet that syncs (or re-syncs) with this code present self-heals within that pass (this is the real-world fresh-sync recovery case). A wallet that had already completed its sync and persisted the stall before this fix does not re-derive scripts on restart (nothing below its persisted synced_height is reprocessed) and is not retroactively healed; such a wallet still needs a rescan from its birth height. New syncs never enter the stalled state. Persisting a per-wallet "scripts-tested-through" watermark to also retroactively heal pre-existing persisted stalls is a larger, schema-touching change left as follow-up.

Real-world confirmation

Android testnet integration (dashj→Kotlin-SDK migration): a real long-lived wallet — 12.087 DASH, 1,047 txs, 4,517 used CoinJoin keys, birth 0 — restored byte-exact under dashj 22.0.3 but landed synced with 0 txs / 0 duffs under a fresh SDK wallet even at gap limit 100, no fault raised. Recovery required delete+recreate (which re-registers the full script set before the first commit). This is exactly Found-034 viewed from the other side: it is the re-scan gate keying, not the window size. See #846 (comment).

Test evidence (red → green)

Ported the deterministic crate-level repro (real FiltersManager + BlockMatchTracker + WalletManager with a CoinJoin account, synthetic blocks, real BIP-158 filters, no network) from repro/pr3549-rdc:

test before after
coinjoin_gap_limit_dense_same_batch_recovers (#820 guard)
coinjoin_gap_limit_inversion_within_batch_recovers (#820)
coinjoin_gap_limit_stall_across_committed_batch highest_used=29 highest_used=51
  • cargo test -p dash-spv --lib → 474 passed
  • cargo test -p key-wallet-manager → all passed
  • cargo fmt --all --check clean; cargo clippy -p dash-spv --all-targets --all-features -- -D warnings clean

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features

    • Improved filter synchronization to re-check previously committed block ranges when newly discovered wallet scripts become available.
    • Sync completion now waits until all follow-up block checks have finished.
  • Bug Fixes

    • Improved recovery of CoinJoin address gaps across active filter batches.
    • Added safeguards to prevent missed wallet activity during committed-range processing.
  • Tests

    • Added end-to-end coverage for dense, inverted, and cross-batch CoinJoin gap discovery scenarios.

bfoss765 and others added 2 commits July 12, 2026 14:54
Port the dash-spv portion of the platform#3549 crate-level bug-pin repro
onto the current `dev` architecture (post-dashpay#820: FiltersManager +
BlockMatchTracker). Three tests drive the real filter -> block -> wallet
pipeline with a deterministic seed, synthetic blocks and real BIP-158
filters, no network:

- coinjoin_gap_limit_dense_same_batch_recovers   — GREEN (dashpay#820 guard)
- coinjoin_gap_limit_inversion_within_batch_recovers — GREEN (dashpay#820)
- coinjoin_gap_limit_stall_across_committed_batch — RED: gap-window
  outputs in an already-committed batch are never recovered because the
  new-script rescan only reaches `active_batches`.

Ported from dashpay/rust-dashcore@c24912c1 (repro/pr3549-rdc); the
key-wallet dashpay#763/dashpay#764 repros in that commit are out of scope here.

Co-Authored-By: Lukasz Klimek <842586+lklimek@users.noreply.github.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…y#846)

Gap-limit scripts derived after a filter batch commits were never tested
against the committed range, so outputs paying gap-window addresses in an
already-committed block stayed permanently invisible. PR dashpay#820 fixed the
in-flight (active-batch) half via the commit-time fixpoint rescan; this
closes the cross-commit-boundary half.

Root cause: discovery suppression was keyed by scan progress (which
heights a wallet had committed), not by which scripts a block was tested
against. `rescan_batch` only reaches `active_batches`, and commit removes
the batch and prunes the tracker at/below the committed height, so a
forward index<->height inversion — a low external index funded in a later
block whose gap-window extension covers a higher index paid in an earlier,
already-committed block — left the earlier block's outputs unrecoverable.

Fix: retain committed batches' filters in a bounded sliding window
(`committed_filters`, moved out of the batch on commit — no clone, so the
common path is untouched). On every `BlockProcessed` that derives new
scripts, queue them with the committed_height snapshot at derivation time;
`try_process_batch` drains the queue, re-tests the fresh scripts against
the retained committed filters at/below that ceiling, and re-queues any
matched committed block through the existing `track_for_new_scripts` path
to a fixpoint. Per-wallet `committed_tested_scripts` dedups so each script
pays one pass; heights that commit later already saw the script via the
normal per-batch scan. `FiltersSyncComplete` is held off while a
re-opened below-frontier block is in flight, and the retained filters are
released on full sync.

Trade-offs:
- Zero overhead on the common no-new-scripts path (queue empty, early
  return). Extra work is bounded to the CoinJoin-style derive-mid-sync
  path and to the retained window (`MAX_RETAINED_COMMITTED_FILTERS`).
- Reach/memory are the same knob: inversions spanning more than the
  window are not recovered by the in-memory path; a birth-height rescan
  remains the backstop.
- In-memory only. A wallet that syncs (or re-syncs) with this code
  self-heals within the pass; a wallet that already completed its sync
  and persisted the stall before this fix is not retroactively healed and
  still needs a rescan from birth.

Makes `coinjoin_gap_limit_stall_across_committed_batch` green while
keeping the two dashpay#820 guards green.

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

coderabbitai Bot commented Jul 12, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Warning

Review limit reached

@bfoss765, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 48 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: eb937a7b-74a5-4ae2-81e3-89c6ed7cdd8d

📥 Commits

Reviewing files that changed from the base of the PR and between 0c29e5e and 18351f2.

📒 Files selected for processing (1)
  • dash-spv/src/sync/filters/coinjoin_gap_discovery_tests.rs
📝 Walkthrough

Walkthrough

FiltersManager now retains committed filters and asynchronously re-tests newly derived wallet scripts against them. Completion waits for rescans to finish, and deterministic CoinJoin tests cover same-batch, within-batch, and committed-batch scenarios.

Changes

Committed-range filter rescans

Layer / File(s) Summary
Retained committed filter state
dash-spv/src/sync/filters/batch.rs, dash-spv/src/sync/filters/manager.rs
Batch filters can be moved into bounded committed storage, with initialization and reset handling for deferred rescan state.
Deferred rescan processing
dash-spv/src/sync/filters/block_match_tracker.rs, dash-spv/src/sync/filters/manager.rs, dash-spv/src/sync/filters/sync_manager.rs
New wallet scripts are queued and deduplicated for committed-range matching; reopened blocks emit BlocksNeeded, and synchronization completion waits for pending rescans.
CoinJoin pipeline validation
dash-spv/src/sync/filters/coinjoin_gap_discovery_tests.rs, dash-spv/src/sync/filters/manager.rs
Deterministic async tests exercise wallet/filter processing and assert same-batch recovery, within-batch inversion recovery, and committed-batch behavior.

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

Sequence Diagram(s)

sequenceDiagram
  participant WalletManager
  participant FiltersManager
  participant BlockMatchTracker
  participant CommittedFilters
  WalletManager->>FiltersManager: BlockProcessed with new_scripts
  FiltersManager->>CommittedFilters: Match scripts against retained filters
  FiltersManager->>BlockMatchTracker: Track reopened blocks
  FiltersManager-->>WalletManager: BlocksNeeded
  WalletManager->>FiltersManager: BlockProcessed for reopened blocks
  FiltersManager-->>WalletManager: FiltersSyncComplete after rescans drain
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 accurately summarizes the main fix for re-opening committed ranges for gap-limit scripts derived after commit.
Linked Issues check ✅ Passed The changes implement the #846 fix by retaining committed filters, re-testing newly derived scripts, and gating sync completion until rescans drain.
Out of Scope Changes check ✅ Passed The added tests and manager changes all support the committed-range gap-limit recovery objective, with no clear unrelated changes.
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.

@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

🤖 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/coinjoin_gap_discovery_tests.rs`:
- Around line 313-376: Update the documentation around
coinjoin_gap_limit_stall_across_committed_batch and the related module-level
description to identify this as a GREEN regression guard for `#846`, removing
claims that recovery remains impossible or RED-by-design. Rewrite the
highest_used assertion failure message to describe the expected recovery of
indices 40..=51 and the regression where committed-range rescan fails, while
preserving the existing Some(51) expectation.
🪄 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: 4ae9ed3e-a32a-47b2-91f7-235fc0a71b08

📥 Commits

Reviewing files that changed from the base of the PR and between 234e3c4 and 0c29e5e.

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

Comment thread dash-spv/src/sync/filters/coinjoin_gap_discovery_tests.rs Outdated
… case is now a GREEN dashpay#846 regression guard

Addresses the CodeRabbit review note on dashpay#873: the module header, doc
comment, and assertion message still described the cross-commit stall as
an open RED defect; they now document the recovered behavior and read as
a regression guard.

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

codecov Bot commented Jul 12, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 89.90826% with 11 lines in your changes missing coverage. Please review.
✅ Project coverage is 73.74%. Comparing base (234e3c4) to head (18351f2).

Files with missing lines Patch % Lines
dash-spv/src/sync/filters/manager.rs 89.32% 11 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##              dev     #873      +/-   ##
==========================================
+ Coverage   73.71%   73.74%   +0.02%     
==========================================
  Files         325      325              
  Lines       73799    73907     +108     
==========================================
+ Hits        54404    54502      +98     
- Misses      19395    19405      +10     
Flag Coverage Δ
core 77.08% <ø> (ø)
ffi 47.37% <ø> (ø)
rpc 20.00% <ø> (ø)
spv 90.92% <89.90%> (-0.01%) ⬇️
wallet 73.33% <ø> (ø)
Files with missing lines Coverage Δ
dash-spv/src/sync/filters/batch.rs 97.65% <100.00%> (+0.05%) ⬆️
dash-spv/src/sync/filters/block_match_tracker.rs 99.37% <100.00%> (+0.01%) ⬆️
dash-spv/src/sync/filters/sync_manager.rs 100.00% <ø> (ø)
dash-spv/src/sync/filters/manager.rs 97.15% <89.32%> (-0.47%) ⬇️

... and 7 files with indirect coverage changes

@bfoss765

Copy link
Copy Markdown
Contributor Author

Gap-limit sizing evidence from on-device testing (testnet, Android) — same reference wallet as the earlier confirmation (12.08713251 DASH, 1,049 txs, 4,517 used CoinJoin keys), same device, freshly-created SDK wallet each run, identical code except DEFAULT_COINJOIN_GAP_LIMIT:

  • Gap 100 (current default after feat(key-wallet): widen DEFAULT_COINJOIN_GAP_LIMIT 30 -> 100 for dashj parity (proposal) #868): first scan reached byte-exact parity with the dashj oracle in ~6 minutes — balance, UTXO set, and full 1,049-tx history. Reproduced multiple times, including a full cold reset+restore benchmark (dashj on the same run: ~60 minutes).
  • Gap 30 (with this PR's committed-range re-scan fix included): first scan failed — the native engine accumulated an approximately-full (slightly inflated, +400,004 duffs) balance but persisted zero transaction records, the committed-range re-scan never engaged (0 re-opens logged), and the wallet sat in that state with no self-recovery for 75+ minutes before we ended the run.

Interpretation, honestly bounded: the gap-30 failure is a record-persistence stall in the initial scan pipeline, not a counter-example to this PR — the scan stalls at a layer before the committed-range machinery would be exercised, so the fix never got the chance to act. This PR's deterministic red→green repro remains the correctness proof for the late-derived-scripts defect it fixes. But the practical conclusion for sizing is clear: at 30 the initial-scan pipeline is not currently viable for heavy CoinJoin wallets; at 100 it is fast and exact. We've reverted our local experiment and are staying on gap 100 (#868).

@HashEngineering

Copy link
Copy Markdown
Contributor

Comparison with #866, with a field measurement bearing on the 20k window

Author of #866 here, so read with that disclosure — but the point of this comment is an empirical measurement that discriminates between the two designs, taken from a real testnet CoinJoin wallet of the same class as the Android wallet cited in this PR's description (1,047 txs, heavy mixing history; full run details in the #866 thread).

Both PRs converge on the same mechanism — script-keyed backward re-test routed through track_for_new_scripts — and both go red→green on the same repro/pr3549-rdc acceptance test. The difference is the filter source: this PR retains the last MAX_RETAINED_COMMITTED_FILTERS = 20_000 heights in memory; #866 re-matches against persisted filters from storage, with unbounded reach.

The measurement

In #866's field run on that wallet, the deepest backward recoveries reached ~18,200 blocks below the committed frontier at recovery time (a cluster at heights 1484723–1484813, recovered when committed_height was 1503000). That is 91% of this PR's retention window. A mixing pause of roughly three days of block time between the funded blocks and the discovery that derives their scripts would cross 20,000 — and per this PR's own trade-offs section, such an inversion is then unrecoverable except by a manual birth-height rescan, with no fault raised. That residual failure mode is shaped exactly like #846 itself: silent, deterministic, invisible funds.

Cost comparison, measured rather than estimated

The window's motivation is avoiding disk I/O and bounding memory, but on the same field run #866's full-history disk pass (~662k stored filters) completed in ~1 second, batched to one sweep per commit-with-new-scripts by deferring to forward-fixpoint quiescence. And on peak memory the comparison inverts: #866's transient working set is one 5,000-filter chunk, while the retained window holds 20,000 filters for the life of the sync. So the bounded window pays a correctness cliff to save an I/O cost that measures as negligible, while using more memory than the unbounded approach.

Where this PR has the better ideas

The derivation-time committed_height ceiling (only re-testing heights at/below the snapshot) is a tighter bound than #866's below-the-committing-batch range, and the restart/persistence analysis here is more explicit than #866's — both worth carrying forward whichever PR lands. The Android wallet report is also the strongest real-world confirmation of #846 to date.

Suggested resolution for the maintainers: pick the storage-backed re-test for its unbounded reach, and fold in this PR's ceiling optimization. Happy to do that folding on #866 if that's the preferred direction.

🤖 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)

2 participants