Skip to content

fix(dash-spv): collect the scripts derived by every application of a block, not only the tracked one - #1008

Open
llbartekll wants to merge 1 commit into
devfrom
fix/dash-spv-collect-scripts-from-every-block-application
Open

fix(dash-spv): collect the scripts derived by every application of a block, not only the tracked one#1008
llbartekll wants to merge 1 commit into
devfrom
fix/dash-spv-collect-scripts-from-every-block-application

Conversation

@llbartekll

@llbartekll llbartekll commented Sep 9, 2026

Copy link
Copy Markdown
Contributor

Fixes #1006. Standalone on dev; the same change is also stacked on #979 as #1007 (which this supersedes — #979's durable pending-sweep set additionally persists the collected scripts, and rebasing #979 over this is a one-line conflict in the same handler).

Issue being fixed

A block is applied to the wallet more than once during a scan: re-applied by a batch rescan for scripts derived after its first application, or delivered again for an in-flight re-request. Each application can recognise outputs the previous one could not (the pools grew in between) and derive further scripts through gap widening. The BlockProcessed handler collected those scripts only when the tracker still held the block's in-flight entry — which the first delivery consumes — so the scripts derived by every later application were dropped on the floor: never collected for the batch rescan and the committed-range sweep.

Measured on a CoinJoin-heavy mainnet wallet (fresh import, release build, iOS Simulator, with counters added temporarily to the handler): 3 645 scripts were derived during the scan; 3 348 reached the sweep and 297 — derived by 14 blocks that were applied 2–4 times each while their batch still had other blocks outstanding — were lost. The blocks that spend the coins paid to the lost addresses match only on the coins' prevout scripts (their outputs are not ours), so they were never found, requested or applied; the wallet ended the scan with 11 spent coins credited (0.11 DASH), and nothing in the engine could ever remove them: no spender record to attribute to, observed_spent_outpoints never saw the spend, spent_outpoints is only fed by matched transactions. The same coins came back after every restart because the persister mirrors the engine (dashpay/platform#4575, dashpay/platform#4638).

What was done

  • sync_manager.rs: settle the in-flight entry and the batch's pending count from the tracked delivery only, but collect new scripts from every delivery — into the tracked batch while it is active, else the batch covering the block, else the lowest active batch, whose commit rescans it, every later batch and the committed prefix. With no active batch the scripts are only warned about; fix(key-wallet, dash-spv): re-emit late knowledge — record corrections, durable rescans, address-pool repair #979's durable pending-sweep set is the right home for them and will persist them once it lands.
  • The Rescan filters and Rescan committed filters log lines now carry the script count, which is what made the loss visible.
  • Regression test scripts_derived_by_a_redelivered_block_enter_the_cascade in coinjoin_gap_discovery_tests.rs: a second BlockProcessed for an already-settled block must leave its scripts in the batch's collected scripts. Red before the fix, green after.

How has this been tested

  • cargo test -p dash-spv --lib sync::filters: 115 passed (114 existing + the new one); cargo fmt --check and cargo clippy --all-targets clean for dash-spv.
  • Same wallet, same simulator, release platform-wallet FFI with this change applied on top of fix(key-wallet, dash-spv): re-emit late knowledge — record corrections, durable rescans, address-pool repair #979's compat rebase (the engine the field build uses):
    • before: sweep ran with 3 348 scripts, found 10 265 blocks, 12 480 blocks applied, 11 coins credited after the scan;
    • after: sweep ran with 3 645 scripts, found 11 169 blocks, 14 754 applied, utxo_count=0 for the CoinJoin account, the SwiftData store holds 0 unspent rows, a restart restores nothing. Repeated by hand through the app's own delete + import flow with the same result. This matches the only earlier run of the same wallet that had converged, by timing luck.

Not addressed here

🤖 Generated with Claude Code

Summary by CodeRabbit

  • Bug Fixes
    • Improved wallet synchronization when blocks are rescanned or delivered more than once.
    • Newly discovered scripts from later block processing are now retained and included in the appropriate rescan or committed-range sweep.
    • Prevents transactions associated with late-discovered scripts from being missed during synchronization.
  • Tests
    • Added coverage for repeated block delivery and late-derived script discovery.

…block, not only the tracked one

A block is applied more than once during a scan: re-applied by a batch
rescan for scripts derived after its first application, or delivered again
for an in-flight re-request. Each application can recognise outputs the
previous one could not, and derive further scripts through gap widening.
The BlockProcessed handler collected new scripts only when the tracker still
held the block's in-flight entry — which the first delivery consumes — so
the scripts derived by every later application were dropped: never
collected for the batch rescan and the committed-range sweep.

On a CoinJoin-heavy mainnet wallet (fresh import, release build) 297 of the
3 645 gap-widened scripts were derived by re-applied blocks and lost. The
blocks that spend the coins paid to those addresses match only on the
coins' prevout scripts, so they were never found; the wallet ended the
scan with 11 spent coins credited (#1006).

Settle the in-flight entry and the pending count from the tracked delivery
only, but collect new scripts from every delivery: into the tracked batch
while it is active, else the batch covering the block, else the lowest
active batch — whose commit rescans it, every later batch and the committed
prefix. The rescan and sweep log lines now carry the script count.

Regression test: a second BlockProcessed for an already-settled block must
leave its scripts in the batch's collected scripts.

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

coderabbitai Bot commented Sep 9, 2026

Copy link
Copy Markdown
Contributor

Review Change StackReview 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: Advanced

Run ID: 2fa23650-744d-4e18-9bd9-993a1947da5e

📥 Commits

Reviewing files that changed from the base of the PR and between 0e56657 and 33ad4e2.

📒 Files selected for processing (3)
  • 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

Included review availability: Your plan provides up to 1 included review per hour; 0 remain after this review.


📝 Walkthrough

Walkthrough

The synchronization manager now collects scripts from repeated block applications, assigns them to active batches, and triggers processing when scripts arrive without a tracked delivery. A regression test covers late scripts from redelivered blocks. Rescan logs now include script counts.

Changes

CoinJoin script cascade

Layer / File(s) Summary
Block processing and regression coverage
dash-spv/src/sync/filters/sync_manager.rs, dash-spv/src/sync/filters/coinjoin_gap_discovery_tests.rs
BlockProcessed now separates tracked-delivery settlement from script collection, assigns scripts through active batch fallbacks, and processes new scripts from re-applied blocks. The regression test verifies that a late script from a redelivered block enters the batch rescan.
Rescan script-count logging
dash-spv/src/sync/filters/manager.rs
Batch rescan and committed-range sweep logs now include the total number of new scripts.

Priority: ➖ Normal

Estimated code review effort: 3 (Moderate) | ~20 minutes

Severity of issue fixed: Medium

Merge Risk: ⚪ Minimal · up to 33ad4

Late wallet scripts discovered from redelivered blocks now enter the rescan cascade, preventing affected historical spends from remaining credited. Regression coverage is included and no merge-blocking risk remains.

Sequence Diagram(s)

sequenceDiagram
  participant BlockDelivery
  participant SyncManager
  participant ActiveBatch
  participant RescanManager
  BlockDelivery->>SyncManager: deliver block application
  SyncManager->>ActiveBatch: collect and assign newly discovered scripts
  SyncManager->>RescanManager: trigger processing for delivery or scripts
  RescanManager-->>SyncManager: report rescan script counts
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 fix: collecting scripts from every block application instead of only the tracked delivery.
Linked Issues check ✅ Passed The changes address issue #1006 by collecting scripts discovered during repeated block applications and routing them into active batch rescans and committed-range sweeps. The regression test covers th…
Out of Scope Changes check ✅ Passed The changes are within scope. They modify script collection, add related logging, and provide a regression test for the linked synchronization bug.
Docstring Coverage ✅ Passed Docstring coverage is 80.00% which is sufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff. Analyzed 5 functions across 3 files.
✨ 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/dash-spv-collect-scripts-from-every-block-application

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 Sep 9, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 86.66667% with 2 lines in your changes missing coverage. Please review.
✅ Project coverage is 77.55%. Comparing base (0e56657) to head (33ad4e2).

Files with missing lines Patch % Lines
dash-spv/src/sync/filters/sync_manager.rs 84.61% 2 Missing ⚠️
Additional details and impacted files
@@           Coverage Diff           @@
##              dev    #1008   +/-   ##
=======================================
  Coverage   77.55%   77.55%           
=======================================
  Files         329      329           
  Lines       83624    83639   +15     
=======================================
+ Hits        64851    64869   +18     
+ Misses      18773    18770    -3     
Flag Coverage Δ
core 78.25% <ø> (ø)
ffi 54.46% <ø> (+<0.01%) ⬆️
rpc 20.00% <ø> (ø)
spv 92.01% <86.66%> (+0.01%) ⬆️
wallet 79.65% <ø> (ø)
Files with missing lines Coverage Δ
dash-spv/src/sync/filters/manager.rs 97.94% <100.00%> (+<0.01%) ⬆️
dash-spv/src/sync/filters/sync_manager.rs 95.74% <84.61%> (-4.26%) ⬇️

... and 6 files with indirect coverage changes

@llbartekll
llbartekll marked this pull request as ready for review September 9, 2026 17:55
@github-actions github-actions Bot added the ready-for-review CodeRabbit has approved this PR label Sep 9, 2026

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

Reviewed at head 33ad4e26. The core fix is sound, and I traced the parts it rests on rather than taking them on trust: active_batches is a BTreeMap, so .keys().next() really is the lowest batch; the three-step target fallback (tracked → covering → lowest) keeps full coverage in every path I followed, because a script-carrying commit rescans its own range, every later scanned batch, and — deferred to drain — the whole committed prefix with min_synced = 0; collected_scripts is a HashSet, so a re-delivered block dedupes; and the new fixpoint terminates, since new_scripts comes from check_result.new_addresses and is empty on an identical re-application.

One inline comment: the None arm is the same silent-loss the PR fixes, moved to the last batch.

Non-blocking recommendations:

  • sync_manager.rs:252if tracked_batch.is_some() || has_new_scripts now calls try_process_batch() on an untracked delivery even when active_batches is empty, a state this handler never used to reach. There is nothing to commit, scan or look ahead there, but it does fall into the completion branch at manager.rs:564 and re-emits FiltersSyncCompletecommitted_height >= filter_header_tip >= target all still hold after the final commit. tick gates on has_pending_work in Synced precisely to avoid that. Downstream, BlocksManager re-sets filters_sync_complete and can flip itself to Synced if its pipeline is momentarily empty, and MempoolManager retries activate_all_peers. tracked_batch.is_some() || (has_new_scripts && !self.active_batches.is_empty()) is strictly safe.
  • coinjoin_gap_discovery_tests.rs:656let late_script = addresses[35].script_pubkey(); is a hardcoded index in a file whose header comment (lines 65-70) explains that every funded index is expressed relative to G = DEFAULT_COINJOIN_GAP_LIMIT because the 30→100 move in #868 "would otherwise have silently turned the cross-commit repro into a trivially-green test". 35 is the worst available choice: the block pays 0-5, so keep_until = 5 + G, and under the old G = 30 that is exactly 35 — the first, tracked delivery would have derived it itself and the test would pass with this fix reverted. It only isolates the second delivery because G is 100 today. Express it relative to G, or make it self-guarding by asserting the first delivery's result.new_scripts does not already contain late_script.
  • sync_manager.rs:224 — the "re-applied outside its tracked delivery" debug line is gated on tracked_batch.is_none(), so it stays silent in the other re-homing case this PR introduces: tracked_batch is Some(b), b has already left active_batches, the .filter(...) at line 214 drops it, and the scripts are charged to a different batch than the tracker named. Since the PR's own account says the added logging is what made the loss measurable, "target differs from the tracked batch" is the more useful condition. Related nit: the match target.and_then(...) at line 237 is loop-invariant but sits inside the per-wallet loop, so the drop warning fires once per wallet per delivery rather than once per delivery.

🤖 Reviewed with Claude Code

Some(batch) => {
batch.add_scripts_for_wallet(*wallet_id, scripts.iter().cloned())
}
None => tracing::warn!(

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.

This arm still drops the scripts, and its message is wrong about what recovers them — which reproduces the exact loss this PR fixes, at the tail of the scan.

The warning says the scripts "will only be re-tested by a later rescan". In the state that reaches this arm there is no later rescan:

  • rescan_committed_range runs only from a batch commit, and active_batches is empty here by definition.
  • tick returns early in Synced when has_pending_work == false (sync_manager.rs:303-311).
  • A restart does not help either: the committing batch already advanced wallet_synced_height to the tip, so neither tick's staleness check nor start_sync will arm a rescan.

And this is reachable precisely where the PR's failure mode is most likely. The last batch commits once pending_blocks() == 0, but queue_new_script_matches re-emits BlocksNeeded for a block in the InFlight state without incrementing pending_blocks — so a duplicate delivery of an already-settled block can land after the final commit, with active_batches empty. The scripts derived from it are then lost the same way the tracked-batch-only collection lost them, just one batch later.

At minimum the message should stop naming a recovery that cannot happen. The real fix is to park these in backward_scripts so the next commit sweeps them — which is the direction #979 already takes, so this may be a matter of pulling that part forward rather than new design.

@ZocoLini ZocoLini left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Which wallet are you using?? I am not able to replicate the issue in mainnet using job flower, do we have a test wallet with heavier ConJoin operations??

@ZocoLini

ZocoLini commented Sep 9, 2026

Copy link
Copy Markdown
Collaborator

I was reading the PR and it looked similar to something I wrote before, and the issue attached was something I was working on, doesn't #989 already cover this? Same root cause — finish_in_flight is consumed by the first delivery, so later deliveries' scripts reach no batch — and the same measurement: two syncs of the same wallet 2,000,020 sat apart, about half the time each.

It goes further in three ways this leaves open: it routes scripts by height to the batch covering the block, falls back to backward_scripts instead of the warn-and-drop arm (what the review comment above asks for), and reconcile_untested_scripts re-tests from wallet state rather than resting on a notification arriving — which matters given 23.6% of blocks are applied out of order.

I reproduced it on the bench today: four paired mainnet runs, dev gave 14,114,383 twice and 16,114,403 twice, this PR 14,114,383 four out of four. Same 2,000,020 sat.

Looks like a subset of #989 — worth checking whether merging that one makes this unnecessary.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

ready-for-review CodeRabbit has approved this PR

Projects

None yet

3 participants