Skip to content

fix(dash-spv): don't preload filters below the actually stored range on filter-download start - #893

Merged
xdustinface merged 2 commits into
devfrom
fix/filter-preload-sparse-storage
Jul 15, 2026
Merged

fix(dash-spv): don't preload filters below the actually stored range on filter-download start#893
xdustinface merged 2 commits into
devfrom
fix/filter-preload-sparse-storage

Conversation

@QuantumExplorer

@QuantumExplorer QuantumExplorer commented Jul 14, 2026

Copy link
Copy Markdown
Member

Issue being fixed or feature implemented

Fixes #892. FiltersManager::start_download treated stored_filters_tip (a tip watermark) as proof that filter storage is contiguous from scan_start. When only tip-region filters exist and the wallet's scan floor is far below (birth height « tip), the preload read never-populated segments: SegmentCache::get_items debug-aborts (permanent app crash-loop on relaunch, since auto-start resumes the same scan state) and release builds silently return sentinel filter data zipped against real headers.

What was done?

  • FilterStorage gains filter_start_height() (the segment cache already tracked it, it was just never exposed) and clear_filters().
  • start_download computes stored_covers_scan = stored_filters_tip > 0 && stored_start <= scan_start. When stored filters exist but don't reach down to scan_start, they are discarded (warn-logged) and the download restarts from scan_start — merely skipping the preload isn't enough: resuming from the stored tip would leave a permanent hole below the island that the in-order store loop can never fill, and keeping the island through a backfill risks two disjoint islands plus Segment::insert non-sentinel asserts. Preload and initial-batch mark_verified gate on stored_covers_scan.
  • SegmentCache::get_items now returns a typed InvalidArgument for ranges beginning below the lowest stored height (replacing debug-abort / release-sentinel behavior for that case); SegmentCache::clear() added; parse_segment_id helper also fixes a latent slice-panic on short filenames in load_or_new.

How Has This Been Tested?

  • test_start_download_discards_stored_filters_above_scan_start — the exact dash-spv: filter restart with scan_start far below stored tip loads never-populated segments — debug abort / release sentinel reads #892 shape (filters stored 900..=1000, scan_start 100); verified it fails against the old gate at exactly the bad preload.
  • test_start_download_preloads_when_stored_filters_cover_scan_start — regression guard for the covers case (behavior unchanged).
  • Storage-layer tests: filter_start_height + clear_filters round-trip incl. persist/reopen; get_items below-start typed error; segment-cache clear (persisted + unpersisted).
  • cargo test -p dash-spv: 477 passed, 0 failed (the dashd_masternode binary requires DASHD_PATH, pre-existing environmental). New tests also pass in --release, where the debug assertions are compiled out. fmt/clippy/--all-features --tests/dash-spv-ffi --all-targets clean.

Breaking Changes

None. FilterStorage implementors gain two methods with default-free trait additions (in-repo implementors updated).

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features

    • Added filter storage controls to report the earliest available filter height and clear stored filters.
    • Cleared filter data can be persisted and replaced with a new range starting at an earlier height.
  • Bug Fixes

    • Requests below the available filter range now return a clear validation error.
    • Filter synchronization now discards incomplete stored ranges and restarts from the correct scan height.
    • Existing complete filter ranges continue to preload and scan as expected.

…on filter-download start

FiltersManager::start_download treated stored_filters_tip — a tip
watermark — as proof that filter storage is contiguous from scan_start.
When only tip-region filters were ever stored (e.g. headers previously
synced from a checkpoint above the wallet's birth height), the preload
called load_filters over never-populated segments: a debug-assertion
abort in SegmentCache::get_items (crash-loop on every launch, since the
restart resumes the same scan state) and silent sentinel filter data
zipped against real headers in release builds.

- FilterStorage gains filter_start_height() (lowest stored height,
  backed by the SegmentCache start it already tracks) and
  clear_filters() (drop all filters; files removed on next persist).
- start_download only treats the stored range as usable when it reaches
  down to scan_start. Otherwise the unreachable tip-region filters are
  discarded and the download restarts from scan_start exactly as if no
  filters were stored — resuming from their tip would leave a permanent
  hole below them that the in-order store loop can never fill. Preload
  and the initial batch's mark_verified are gated on the same check,
  and progress.stored_height is reset so batch scan gating no longer
  trusts the stale watermark.
- SegmentCache::get_items now returns a typed InvalidArgument error for
  ranges that begin below the lowest stored height, replacing the
  debug-only panic / release sentinel reads for that case.

Fixes #892

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

coderabbitai Bot commented Jul 14, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Warning

Review limit reached

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

Next review available in: 46 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: 40c44c23-6921-4e85-a2ce-4afdd6a1e688

📥 Commits

Reviewing files that changed from the base of the PR and between 23225a8 and 828f707.

📒 Files selected for processing (3)
  • dash-spv/src/storage/filters.rs
  • dash-spv/src/storage/segments.rs
  • dash-spv/src/sync/filters/manager.rs
📝 Walkthrough

Walkthrough

Filter storage now reports its lowest stored height and supports clearing. Segment reads reject ranges below that height. Filter synchronization clears sparse, incompatible stored ranges and restarts downloads from scan_start, while preserving preload behavior for contiguous coverage.

Changes

Filter restart safety

Layer / File(s) Summary
Segment cache validation and clearing
dash-spv/src/storage/segments.rs
Segment filenames use centralized parsing, lower-bound reads return StorageError::InvalidArgument, and clear removes persisted and in-memory segments after persistence.
Filter storage clearing API
dash-spv/src/storage/filters.rs, dash-spv/src/storage/mod.rs
Filter storage exposes its lowest height and clearing operation, with implementations delegated through PersistentFilterStorage and DiskStorageManager; tests cover persistence and rebuilding from a lower range.
Scan-start coverage and restart
dash-spv/src/sync/filters/manager.rs
start_download clears stored filters that do not cover scan_start, resets stored progress, and only preloads or verifies covered ranges; regression tests cover sparse and dense storage.

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

Sequence Diagram(s)

sequenceDiagram
  participant FiltersManager
  participant DiskStorageManager
  participant PersistentFilterStorage
  participant SegmentCache
  participant FilterPeer
  FiltersManager->>DiskStorageManager: read stored tip and start
  DiskStorageManager->>PersistentFilterStorage: delegate storage query
  PersistentFilterStorage->>SegmentCache: return stored range
  FiltersManager->>DiskStorageManager: clear filters when scan_start is uncovered
  DiskStorageManager->>PersistentFilterStorage: delegate clear
  FiltersManager->>FilterPeer: request filters from scan_start
  FilterPeer-->>FiltersManager: return filter batch
Loading

Possibly related PRs

Suggested labels: ready-for-review

Suggested reviewers: xdustinface, zocolini

🚥 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 is concise and accurately describes the main fix: avoiding preload below the actual stored filter range.
Linked Issues check ✅ Passed The changes address #892 by checking actual stored range, clearing sparse filters, and preventing invalid reads during download restart.
Out of Scope Changes check ✅ Passed The extra storage helpers and parsing updates support the filter-range fix and do not appear unrelated to the PR goals.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/filter-preload-sparse-storage

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: 3

🤖 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/storage/segments.rs`:
- Around line 137-149: Update parse_segment_id to validate the complete filename
rather than parsing a fixed four-byte slice: strip the exact segment prefix,
separator, and data-file extension, then parse the entire remaining ID component
as u32. Reject names with extra characters, missing separators, or malformed IDs
while preserving None for invalid filenames.
- Around line 458-480: Update Storage::clear to return StorageResult<()>,
propagate both read_dir failures and individual directory-entry errors, and
complete the entire scan before clearing or queuing any cache state. Then update
clear_filters and its callers to propagate the clear result while preserving the
existing reset behavior on success.

In `@dash-spv/src/sync/filters/manager.rs`:
- Around line 218-219: Update the stored-range check near stored_covers_scan to
use filter_start_height presence, rather than stored_filters_tip > 0, so a
stored range of 0..=0 is treated as non-empty and preloads height zero. Add a
restart unit test covering storage containing only filter height zero.
🪄 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: 12e47b48-9f16-4cd1-b92d-4a56ad7eab77

📥 Commits

Reviewing files that changed from the base of the PR and between 38c689d and 23225a8.

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

Comment thread dash-spv/src/storage/segments.rs Outdated
Comment thread dash-spv/src/storage/segments.rs Outdated
Comment thread dash-spv/src/sync/filters/manager.rs Outdated
@codecov

codecov Bot commented Jul 14, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 97.73585% with 6 lines in your changes missing coverage. Please review.
✅ Project coverage is 74.44%. Comparing base (38c689d) to head (828f707).

Files with missing lines Patch % Lines
dash-spv/src/storage/mod.rs 0.00% 4 Missing ⚠️
dash-spv/src/storage/segments.rs 98.27% 2 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##              dev     #893      +/-   ##
==========================================
+ Coverage   74.39%   74.44%   +0.05%     
==========================================
  Files         327      327              
  Lines       74779    75032     +253     
==========================================
+ Hits        55629    55855     +226     
- Misses      19150    19177      +27     
Flag Coverage Δ
core 77.29% <ø> (ø)
ffi 50.31% <ø> (ø)
rpc 20.00% <ø> (ø)
spv 91.11% <97.73%> (-0.03%) ⬇️
wallet 74.41% <ø> (ø)
Files with missing lines Coverage Δ
dash-spv/src/storage/filters.rs 100.00% <100.00%> (ø)
dash-spv/src/sync/filters/manager.rs 97.74% <100.00%> (+0.13%) ⬆️
dash-spv/src/storage/segments.rs 96.15% <98.27%> (+0.34%) ⬆️
dash-spv/src/storage/mod.rs 86.15% <0.00%> (-1.08%) ⬇️

... and 4 files with indirect coverage changes

Three follow-ups to the #892 fix:

- parse_segment_id: parse the entire component between the `{prefix}_`
  and `.{ext}` fixtures via strip_prefix/strip_suffix instead of a
  fixed 4-byte slice. Trailing junk (`segment_0000junk.dat`) is now
  rejected and ids wider than the zero-padding (`segment_100000.dat`)
  are accepted rather than silently truncated. Adds a dedicated
  parse_segment_id test covering round-trips, junk tails, 5+ digit ids,
  and malformed names.

- SegmentCache::clear now returns StorageResult<()>: the directory
  scan runs to completion before any cache state is mutated, io
  NotFound on read_dir is treated as empty-ok, and every other error
  is propagated. A failed scan no longer leaves the cache reporting
  empty while persisted segment files survive to resurrect the
  sparse-storage bug after restart. The Result is threaded through
  clear_filters and its callers.

- start_download gates stored_covers_scan on the stored start height
  alone (`stored_filters_start.is_some_and(|s| s <= scan_start)`).
  filter_tip_height collapses "empty" and "only height 0 stored" to 0,
  so the previous `stored_filters_tip > 0` guard misread a genesis-only
  store as empty and re-downloaded height 0 instead of preloading it.
  The discard branch is made consistent via `is_some()`. Adds a
  genesis-only restart test asserting the lone filter is preloaded with
  no clear and no re-request.

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

Copy link
Copy Markdown
Member Author

Follow-up with the full root-cause timeline from the device where this reproduced (per-session dash_spv logs, testnet) — including why a clean CLI sync + restarts can't reproduce it, and what the discard actually does in practice.

How the sparse island forms

The state is manufactured by a mid-life client re-initialization from a checkpoint, not by plain sync + restart:

session A  18:45  Starting filter header sync from 0 …
           18:46  Starting filter download (scan_start=0, download_start=0, stored_filters_tip=0, …)
           → filters stored densely from 0 upward; wallet scan progresses past 1.1M

session B  19:08  🚀 Starting sync from checkpoint at height 200000 instead of genesis (requested start height: 227121)
           19:08  Starting filter header sync from 200000 to 208000
           19:14  Starting filter download (scan_start=1514654, download_start=1514654, stored_filters_tip=0, …)
           → the re-init RESET filter storage (tip=0), but the re-download resumed from the wallet's
             current synced position (1514654), NOT the new 200000 floor
           → result: filters stored 1514654..=tip, while the client's floor is the 200000 checkpoint

sessions C+  scan_start=200000 (checkpoint floor), stored range 1514654..=2504884
             → pre-fix: preload of 200000..204999 reads never-populated segments → SIGABRT crash-loop

So the mysterious scan_start=200000 is the checkpoint height the client re-initialized from (requested start height: 227121 resolved down to the 200000 checkpoint). A fresh CLI sync never re-initializes from a checkpoint mid-life, which is why restart testing after a clean sync doesn't hit it.

Repro recipe: sync with filters stored from a low height → restart the client with a start-height request that initializes from a checkpoint below the wallet's current synced height → the filter re-download resumes from the wallet position, leaving the gap → restart once more → pre-fix crash (debug) / sentinel filter reads (release).

What the discard does in practice (convergence)

With this PR on the same corrupted device state:

21:32  WARN Stored filters Some(1514654)..=2504884 do not reach scan start 200000; discarding them and re-downloading
21:32  Starting filter download (scan_start=200000, download_start=200000, stored_filters_tip=0, …)
21:54  Starting filter download (scan_start=200000, download_start=203000, stored_filters_tip=202999, …)   ← dense from the floor, restart resumes
00:35  Starting filter download (scan_start=2504990, download_start=2504990, stored_filters_tip=2504989, …) ← scan completed to tip

One discard total. After it, storage is dense from the scan floor, every later restart resumes normally, and the wallet completed the backfill scan to tip. It is a one-time self-heal that restores the dense-storage invariant, not a recurring reset.

The remaining latent bug (separate issue)

The island-manufacturer itself — the checkpoint re-init resetting filter storage but resuming the re-download from the wallet's synced height instead of the new floor — is a distinct bug; filing it separately. This PR makes the client self-heal from any such island regardless of origin (that session's mid-scan kill shapes can produce the same state), which is worth having even once the manufacturer is fixed.

🤖 Generated with Claude Code

@QuantumExplorer

Copy link
Copy Markdown
Member Author

Good challenge on the timeline — the T1/T2 distinction is correct, my earlier comment conflated the checkpoint floor with the scan floor. Here are the exact lines that were asked for, plus the T2 evidence. All from the same device, same logs.

T1 session (island forms), post-re-init and session end:

19:14:31  Starting filter download (scan_start=1514654, download_start=1514654, stored_filters_tip=0, target=1516000)
…filters download and scan to tip…
19:30:04  Committed batch 2504884-2504884, committed_height now 2504884

Session ends cleanly at committed 2,504,884 — consistent state, exactly as predicted: T1 alone gives stored 1514654..=tip with a scan floor ~1.51M, which would never crash.

T3 (first crashing session, 70 seconds later):

19:31:15  Starting filter download (scan_start=200000, download_start=2504885, stored_filters_tip=2504884, target=2504884)

So T2 is real and sits between those two lines: the wallet's committed height (2,504,884) did not survive the restart. With committed effectively 0 and birth 0, scan_start = max(birth, committed+1).max(header_start_height) collapses to the 200000 checkpoint floor. The persisted wallet row today carries syncedHeight = 2504884 — the value existed to persist — so the reversion is about when/whether it was durably written and re-applied, not about the number being unknown.

T2 is conditional, and the condition looks like abrupt termination. Post-fix restarts on the same device:

21:54:44  Starting filter download (scan_start=200000, download_start=203000, stored_filters_tip=202999, …)   ← restart after SIGABRT-era terminations: floor collapsed again
02:48:19  Starting filter download (scan_start=2505047, download_start=2505047, stored_filters_tip=2505046, …) ← restart after a long clean session: resumed correctly

Every floor-collapse restart in the log history followed an abrupt kill (the pre-fix crash-loop itself, or forced terminations); the one restart that followed a healthy long-running session resumed at committed+1. That points at the sync-height persistence cadence in the embedding app/platform-wallet layer (not dash-spv) — likely only stamped on some pulse or graceful path that abrupt kills miss. Nasty interaction: the pre-fix crash-loop guaranteed abrupt terminations, so T2 re-armed itself on every relaunch.

Refinement of #894: the island needs BOTH halves — T1 (checkpoint re-init resets filter storage, refill resumes from the wallet position instead of the floor) AND T2 (committed height regressing across an abrupt restart). I'll note this on #894; the T2 half belongs to the embedding-app layer and I'll chase it there.

On discard cost — conceded. This island spanned ~990k filters; the discard re-fetched ~2.3M, so ~40% of prior work was thrown away rather than kept. "Cheap" was wrong for this shape. The claims that stand: it's one-time per island (convergence shown above), it's correctness-first (the release-mode alternative is silently wrong filter matches over the gap), and a backfill-below-the-island variant would be a legitimate future optimization on top of the same detection.

🤖 Generated with Claude Code

@QuantumExplorer

Copy link
Copy Markdown
Member Author

Bullet-point reproduction recipes, as requested. Two levels: the minimal one reproduces the crash in the CLI today; the second is the exact path our device took.

Minimal repro (CLI, no app needed — pre-#893 debug build)

The crash needs only one condition: stored filter range starting above the wallet scan floor. Any way of creating it works:

  1. Start the client with a wallet whose birth/start height is near the tip (or start from a checkpoint) → filter sync stores filters from that height upward: stored = [HIGH..tip].
  2. Stop the client.
  3. Restart the same client/storage with the scan floor below HIGH — the natural way: import/add a wallet with a lower birth height (the thing multiwallet + feat(dash-spv): force resync to re-anchor a running client at a lower checkpoint #856 make a first-class flow), or lower the configured start height.
  4. On restart, start_download sees scan_start = LOW, stored_filters_tip = HIGH-tip → preloads LOW..debug SIGABRT in SegmentCache::get_items; release silently returns sentinel filter data for the gap and match results over that range are wrong.

So "importing a wallet at a lower height" — the scenario suspected early in this thread — is a real trigger of the same crash, independent of anything our app did. That's why I'd argue against merging-then-reverting: the guard protects a legitimate user flow that's about to ship, and the release-mode half (silent wrong matches) exists for every variant of the state.

The path our device actually took (T1 + T2, both filed)

  1. Wallet birth 0, filter sync stores 0..~1.5M, all healthy.
  2. T1 (#894): mid-life client re-init from checkpoint 200000 → filter storage reset (tip=0) → refill resumed from the wallet's synced position (1,514,654) instead of the floor → island 1514654..tip. Session ends cleanly at committed 2,504,884 — still consistent, no crash.
  3. T2 (dashpay/platform#4130 — agreed, it's a platform-wallet/swift-sdk issue): across an abrupt termination the wallet's committed height regresses (persisted syncedHeight=2504884 exists but isn't reapplied/stamped on the abrupt path) → next launch computes scan_start = max(birth 0, checkpoint 200000) = 200000 → below the island → crash-loop (each crash is itself an abrupt kill, re-arming T2).

On "hiding the underlying issue"

The discard path logs WARN Stored filters {start}..={tip} do not reach scan start {scan_start}; discarding them and re-downloading with the exact ranges — every future occurrence stays loudly visible in logs while both root causes (#894 upstream, platform#4130) are tracked and fixed. Without #893, the same state in a release build doesn't crash OR warn — it silently feeds wrong filter data to the matcher, which is the truly hidden failure mode.

🤖 Generated with Claude Code

@xdustinface
xdustinface merged commit 19690d3 into dev Jul 15, 2026
38 of 43 checks passed
@xdustinface
xdustinface deleted the fix/filter-preload-sparse-storage branch July 15, 2026 04:37
bfoss765 added a commit to bfoss765/rust-dashcore that referenced this pull request Aug 5, 2026
…efore-funding (dashpay#649)

A spend processed BEFORE the transaction that funded the UTXO it spends
(out-of-order block delivery during a cold rescan) leaves that UTXO
permanently in the wallet's tracked set, producing phantom spendable
balance.

Device evidence (testnet): output
2febe5d7e8ad1dd0fb633004a82a24783d9b2e9095883541576e5f1344eb9975:0
(1,000,000 duffs) is counted unspent by the SDK while dashj has it spent,
and the phantom +0.01 survives a full wallet rebuild from seed (fresh
re-derivation + rescan reproduces it deterministically), proving the miss
lives in the scan/processing path, not just live mempool ingestion.

This test models that scenario at the WalletManager level: fund 1,000,000
duffs to a wallet address, then deliver the spending block (height 200)
BEFORE the funding block (height 100). It asserts the funding outpoint is
NOT still tracked afterward. FAILS on the current pin (which already
contains dashpay#837/dashpay#864/dashpay#891/dashpay#893) — those do not address this defect.

Refs: dashpay#649

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
QuantumExplorer added a commit that referenced this pull request Aug 6, 2026
…nspent balance (#649) (#909)

* test(key-wallet-manager): deterministic repro of out-of-order spend-before-funding (#649)

A spend processed BEFORE the transaction that funded the UTXO it spends
(out-of-order block delivery during a cold rescan) leaves that UTXO
permanently in the wallet's tracked set, producing phantom spendable
balance.

Device evidence (testnet): output
2febe5d7e8ad1dd0fb633004a82a24783d9b2e9095883541576e5f1344eb9975:0
(1,000,000 duffs) is counted unspent by the SDK while dashj has it spent,
and the phantom +0.01 survives a full wallet rebuild from seed (fresh
re-derivation + rescan reproduces it deterministically), proving the miss
lives in the scan/processing path, not just live mempool ingestion.

This test models that scenario at the WalletManager level: fund 1,000,000
duffs to a wallet address, then deliver the spending block (height 200)
BEFORE the funding block (height 100). It asserts the funding outpoint is
NOT still tracked afterward. FAILS on the current pin (which already
contains #837/#864/#891/#893) — those do not address this defect.

Refs: #649

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

* fix(key-wallet): track wallet-level observed_spent_outpoints to fix #649

Root cause: the "already spent" guard in
managed_core_funds_account.rs::update_utxos keys off the ACCOUNT-LOCAL
`spent_outpoints` set, which is only populated when the account itself
processes the spending transaction. When a spend is delivered before its
funding tx (out-of-order rescan), the wallet does not yet own the input,
so the spend is classified as irrelevant, update_utxos never runs for it,
and nothing records the spend. When the funding tx is processed later, the
output is (re-)inserted as a fresh, spendable UTXO -> phantom balance that
survives a full from-seed rescan.

Fix (adapted from #851): record every spend observed
in a block into a new wallet-level `observed_spent_outpoints` map
(ManagedWalletInfo), independent of the spending tx's classification or
account attribution. update_utxos and record_transaction consult this map:

  - update_utxos skips any output already observed spent (spend-first
    ordering: funding arrives after the spend).
  - remove_spent_from_accounts drops a coin the matched-account path
    missed (funding-first ordering: spend routed to another account).
  - TransactionRecord::compensate_for_observed_spends keeps net_amount /
    output_details consistent with the observed spend (declarative, so
    it is idempotent across rescan replays).

The set is bounded-permanent: entries are evicted by
prune_finalized_observed_spends once the spend height is provably final
(<= min(chainlock height, synced_height)); add-account rewinds the sync
checkpoint so a late account gets filter coverage before pruning can run.
A dash-spv commit-time contiguity guard keeps a mid-flight account-add
rescan from being silently clobbered forward.

Also fixes AddressPool::prune_unused to clear script_pubkey_index
alongside address_index.

Adds manager-level regression tests (multi-wallet, large-block stress)
that exercise the fix through the public WalletManager API.

The repro test from the previous commit now passes; key-wallet (549),
key-wallet-manager (all) and dash-spv lib (482) suites are green. The
pre-existing masternode-network integration failures
(test_utils/masternode_network.rs:106) are unrelated and fail identically
on the clean pin.

Refs: #649
Adapted-from: #851

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

* test(key-wallet): cover observed_spent guard branches for #649 patch coverage

Codecov flagged the #649 fix's previously-uncovered branches: the wallet-level
`observed_spent_outpoints` serde adapter, finality-boundary pruning, the
funding-first removal guard, the account-add sync rewind, and the AddressPool
`script_pubkey_index` prune fix. The manager-level integration tests only drive
the spend-first ordering end-to-end, leaving these reachable only from the
crate-internal `pub(crate)` surface.

Add `key-wallet/src/tests/observed_spent_outpoints_tests.rs` (the sibling file
already referenced by observed_spent_large_block_stress_test.rs) with five
white-box tests, plus one AddressPool prune test:

  - observed_spent_outpoints_survive_serde_round_trip: exercises the
    (OutPoint, height) sequence serde adapter (serialize + deserialize visitor)
    and the empty-map / `#[serde(default)]` path, isolated on an account-less
    wallet so the populated-account `script_pubkey_index` JSON-key blocker does
    not apply.
  - prune_finalized_observed_spends_respects_finality_boundary: no-op without a
    chainlock; otherwise evicts exactly entries at/below
    min(chainlock height, synced_height), keeping the rescan case (chainlock
    above sync checkpoint) from over-pruning.
  - funding_first_guard_removes_held_coin_and_compensates_record: the un-gated
    remove_spent_from_accounts / finalize_guard_removed_utxo path — coin dropped,
    reservation released, funding record compensated to net 0; idempotent;
    coinbase skipped.
  - wallet_level_set_outlives_account_local_reload: the account-local
    spent_outpoints derived set (rebuilt from recorded txs via
    simulate_reload_rebuild_spent_outpoints) forgets an unrecorded spend, but the
    persisted wallet-level set still prevents resurrection on funding re-delivery.
  - adding_account_from_xpub_rewinds_sync_checkpoint: standalone-account add
    collapses synced_height to birth_height - 1; a still-behind checkpoint is
    left untouched.
  - prune_unused_clears_script_pubkey_index (address_pool_tests.rs): regression
    guard for the missing script_pubkey_index.remove in AddressPool::prune_unused.

key-wallet lib (554) and key-wallet-manager (all) suites green.

Refs: #649

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

* review fixes: gate net_amount recompute, surface observed-spend state modification, tighten deser cap

- compensate_for_observed_spends: only replace the match-derived net_amount
  when the compensation actually dropped an output detail, keeping the
  no-observed-spend path byte-identical to pre-#649 behavior; pinned by a
  new unit test.
- record_observed_spends: report whether the persisted observed-spent map
  actually changed, and surface that as state_modified in
  check_core_transaction — a consumer persisting only on reported
  modifications must not lose a recorded spend across a restart; pinned by
  a new regression test (new spend reports, unchanged redelivery and
  mempool spends do not).
- Replace a comment reference to a nonexistent test with the inline
  rationale for why input_details and account_match.sent populate together.
- Tighten MAX_OBSERVED_SPENT_OUTPOINTS 10M -> 1M (load-time allocation cap
  from a few hundred MB to a few tens of MB), still far above any
  legitimate size.

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

* review fixes round 2: generation guard for mid-flight account adds, surface guard-rewritten records, restore public account API

Addresses three external review findings on top of PR #909:

1. [P1] The commit-time contiguity guard could still certify unscanned
   coverage for a newly added account: a rewind landing INSIDE a scanned
   batch's range passed the height check, and an account add that moved
   no heights (checkpoint already at the birth floor) was undetectable
   by any height comparison. ManagedWalletInfo now carries an in-memory
   account_generation counter bumped on every account add (even
   height-invisible ones); filter scan snapshots it per wallet and
   commit refuses to advance a wallet whose generation changed since
   scan. Pinned by three new dash-spv tests including the mid-batch
   rewind repro (9000 -> scan [5000..9999] -> rewind 7499 -> commit
   keeps 7499) and the unmoved-checkpoint case.

2. [P2] The funding-first guard rewrote funding records without ever
   surfacing them: remove_spent_from_accounts now returns
   post-compensation clones of every rewritten record, the checker adds
   them to updated_records on both the relevant and irrelevant paths,
   and the manager propagates updated_records independent of
   is_relevant, so consumers persisting per-record updates see the
   rewrite.

3. [P2] ManagedAccountRefMut::record_transaction/confirm_transaction had
   silently gone pub -> pub(crate) with changed signatures. The public
   methods are restored with their original signatures (recording with
   no observed-spend context, the pre-#649 behavior); the checker uses
   new pub(crate) *_with_observed_spends variants.

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

* test(key-wallet): pin born-fully-spent recovered transactions stay in history (#649/#846)

HashEngineering reported (against #851/#866) that a funding transaction
recovered after its spend was already observed -- every wallet-relevant
output already in observed_spent_outpoints before the funding is applied --
was dropped from history entirely: the spend-first path made it come out
not-relevant, so no TransactionRecord and no detection event were produced,
while balance and UTXO set stayed exact. That record-loss "belongs in #851";
#851 is superseded by #909.

Investigation of #909 shows its core commit (ebcd40a) already implements the
suggested remedy, so no behavior change is needed:

  - relevance in check_transaction_for_match is address-membership based and
    is never gated on spent-status, so a fully-spent funding tx is still
    classified relevant;
  - ManagedCoreFundsAccount::record_transaction unconditionally inserts the
    record after TransactionRecord::compensate_for_observed_spends zeroes the
    already-spent outputs (net 0, no UTXO);
  - the "never insert already-spent value" guard lives in update_utxos (UTXO
    insertion only), not in recording.

The QuantumExplorer "surface updated records independent of relevance" review
fix covers the separate funding-first UPDATE case (remove_spent_from_accounts
rewriting an existing funding record); the born-fully-spent NEW-record
insertion is covered independently by the record_transaction compensate path.

The existing observed-spent tests assert only UTXO/balance, leaving the
history-record guarantee uncovered. This adds that coverage: two tests pin
that a born-fully-spent recovered tx -- a plain funding tx, and a CoinJoin-
style intermediate hop that spends a live coin -- is surfaced as a new record
and recorded in the account's transaction history, while balance and UTXOs
stay at zero. Verified across InBlock and InChainLockedBlock (chainlocked
recovery) contexts and the WalletManager block path during investigation.

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

* docs(key-wallet): fix private intra-doc links so the Documentation CI job passes

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

* test(key-wallet-manager): parameterize network in the spend_tx test helper

CodeRabbit: the shared `spend_tx` helper hardcoded `Address::dummy(Network::Testnet, ..)`,
forcing every observed-spend test onto Testnet (coding guideline: never hardcode
network parameters in a shared helper). Add a `network: Network` parameter and
thread it into `Address::dummy`; each caller passes the network its manager uses.

All key-wallet-manager tests pass.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* refactor(key-wallet): reduce #649 fix to the two-piece observed-spend mechanism

Applies ZocoLini's requested simplification (PR #909 review): the #649
out-of-order-spend fix is the two-piece mechanism only —

  (a) record every input seen in a block-context tx into
      `observed_spent_outpoints`, independent of classification
      (`wallet_checker.rs`), and
  (b) in `update_utxos`, skip inserting an output whose outpoint is already
      in that map (`managed_core_funds_account.rs`).

Removes the separate unattributable-spend compensation machinery that was
bundled in, which also made transaction history order-dependent (a receive
delivered before its spend was rewritten to a 0-value entry, erasing it from
history — a spend delivered first leaves it intact):

- `TransactionRecord::compensate_for_observed_spends` and its unit tests
- `ManagedWalletInfo::remove_spent_from_accounts` (both call sites in
  `check_core_transaction`) and its `finalize_guard_removed_utxo` helper
- the now-dead account-local helpers `mark_outpoint_spent`,
  `release_reservation_for`, and the test-only
  `simulate_reload_rebuild_spent_outpoints`
- the `updated_records`-independent-of-relevance change in
  `WalletManager` (its only source was the removed funding-first guard)

The `record_transaction_with_observed_spends` / `confirm_transaction_with_observed_spends`
pair is kept: it is the plumbing that delivers the wallet-level observed map to
`update_utxos`, i.e. piece (b) itself.

A born-fully-spent funding tx is still recorded in history; its already-spent
output is simply never (re-)tracked as a UTXO (balance/UTXO correctness comes
from piece (b), not from rewriting the record). Tests updated to pin that the
receive is preserved in history rather than erased.

Test-helper cleanup (`key-wallet-manager/tests/common/mod.rs`): drop the
superfluous `Network` parameter from `spend_tx` (every caller passed Testnet and
the payee's network is irrelevant to observed-spend logic) and build the external
payee script directly, so the helper needs no network at all.

All three observed-spend integration tests (incl. the deterministic repro) and
`cargo test -p key-wallet --lib` pass; workspace clippy (`-D warnings`, debug +
release) and rustfmt are clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* test(key-wallet-manager): name the out-of-order repro after the invariant it pins

The test asserted `!still_tracked` — the funding UTXO must NOT remain in the
tracked set once its spend was observed first — but was named
`..._leaves_utxo_permanently_tracked`, i.e. after the #649 bug rather than
after the pinned behaviour. A failure therefore read as the expected outcome.
Rename to `..._does_not_leave_utxo_tracked`; assertions and scenario are
unchanged.

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Co-authored-by: Quantum Explorer <quantum@dash.org>
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: filter restart with scan_start far below stored tip loads never-populated segments — debug abort / release sentinel reads

2 participants