Skip to content

fix(key-wallet): hold back catch-up receives from coin selection - #937

Closed
QuantumExplorer wants to merge 3 commits into
devfrom
fix/spend-scan-frontier
Closed

fix(key-wallet): hold back catch-up receives from coin selection#937
QuantumExplorer wants to merge 3 commits into
devfrom
fix/spend-scan-frontier

Conversation

@QuantumExplorer

@QuantumExplorer QuantumExplorer commented Aug 10, 2026

Copy link
Copy Markdown
Member

Issue being fixed or feature implemented

Coin selection can build a double-spend while a restored wallet is catching up on history.

Blocks are applied in strictly ascending order (BlocksPipeline::take_next_ordered_block stalls if any pending download is lower), so a receive discovered at height H enters the UTXO set and becomes fully selectable long before block H' > H — the one that spends it — has been downloaded or scanned. Nothing in the selection path consulted the scan frontier: Utxo::is_spendable checked only is_locked and coinbase maturity, and the height it was given is last_processed_height, a matched-block cursor rather than synced_height.

The resulting transaction is a double-spend of an outpoint the network settled long ago. Peers drop it at the mempool boundary and relay nothing back — Core has not sent BIP61 reject by default since 0.17 — so it never confirms and never errors.

Observed on testnet: a restore found a 1000 DASH receive in block 758983 and funded an identity top-up asset lock from it three seconds later. The block that had already spent that outpoint, 1510203, was still six minutes of scanning away.

The existing out-of-order guard in update_utxos only covers the mirror case (spend seen before the creating tx), so it never applied here.

What was done?

Track where the scan is going alongside where it has reached:

  • WalletMetadata::scan_target_height — the filter-header tip the scan has committed to covering. Published by the dash-spv filters manager in start_download (so the gate is armed before any block is applied) and handle_new_filter_headers (as the chain grows), via a new defaulted WalletInterface::update_scan_target_height.
  • Utxo::spend_scanned — whether every block that could already have spent this output has been scanned. A receive applied from a block below the target is held back and excluded by Utxo::is_spendable.
  • When the frontier reaches the target, promote_spend_scanned_utxos releases every survivor in one pass: at that point a spend would already have removed it, so what remains is genuinely unspent.
  • WalletInfoInterface::spend_scan_complete() so consumers can distinguish "still syncing" from "insufficient funds".

Two deliberate choices:

Receives at or above the target are never held back. A wallet-level "am I caught up?" gate would block spending for the seconds after every new block while the frontier lags the header tip — roughly 3% of the time on a live chain, surfacing as a confusing insufficient-funds error. Holding back per-UTXO leaves a caught-up wallet completely unaffected, and mempool receives (at the tip by definition) stay spendable even during catch-up.

The gate fails open until a scanner reports a target, and spend_scanned serde-defaults to true. Consumers that never scan, and UTXOs already in persistence, keep the previous behavior exactly. Fail-closed would have made every hand-built wallet and test fixture unspendable.

Balance display is untouched: update_balance reads is_mature/is_locked directly, not is_spendable, so a mid-catch-up wallet still reports a balance. Whether the UI should say "syncing" instead is a consumer-side decision, which spend_scan_complete() now enables.

How Has This Been Tested?

Seven tests in key-wallet/src/tests/spend_scan_frontier_tests.rs, using the incident's actual heights (758983 / 1510203): receive held back below the target, released when the scan reaches it, never selectable when a higher block spent it, immediately selectable at the target, mempool unaffected, gate open with no target reported, and a legacy row missing the serde field deserializing as scanned.

Mutation-checked rather than assumed: removing the spend_scanned clause from is_spendable makes exactly the two incident tests fail and no others.

Full key-wallet, key-wallet-manager and dash-spv suites pass; cargo clippy --all-features --all-targets and cargo fmt --all --check clean.

Breaking Changes

None. New field on Utxo (serde-defaulted) and on WalletMetadata; the new WalletInterface method is defaulted. Behavior is unchanged for any consumer that does not publish a scan target.

Checklist:

  • I have performed a self-review of my own code
  • I have commented my code, particularly in hard-to-understand areas
  • I have added or updated relevant unit/integration/functional/e2e tests
  • I have made corresponding changes to the documentation

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features

    • Wallet synchronization now tracks scan progress across managed wallets.
    • Newly received funds remain temporarily unavailable until scanning verifies them.
    • Funds become spendable automatically when synchronization reaches the scan target.
  • Bug Fixes

    • Prevented unverified or later-spent funds from being selected for transactions.
    • Preserved compatibility with existing wallet data and wallets without a scan target.
  • Tests

    • Added coverage for scan-frontier behavior, synchronization, mempool funds, and legacy wallet data.

A wallet catching up on history applies blocks in ascending order, so a
receive it discovers says nothing about whether some higher, not-yet-scanned
block already spends it. Coin selection only ever checked `is_locked` and
coinbase maturity, against `last_processed_height` — a matched-block cursor,
not the scan frontier — so a restored wallet would fund a transaction from an
outpoint the network had settled as spent long ago. Peers drop that broadcast
silently (Core has not sent BIP61 rejects by default since 0.17), and whatever
it was funding is stranded with no error anywhere.

Observed on testnet: a restore found a 1000 DASH receive in block 758983 and
funded an identity top-up asset lock from it three seconds later; the block
that had already spent that outpoint, 1510203, was still six minutes of
scanning away.

Track the height the scan is working toward (`WalletMetadata::scan_target_height`,
published by the dash-spv filter manager from the filter-header tip) alongside
where it has reached. A receive applied below the target is marked
`Utxo::spend_scanned = false` and excluded by `Utxo::is_spendable`; reaching
the target releases every survivor in one pass, since a spend would already
have removed it. Receives at or above the target — live blocks and mempool —
are never held back, so a caught-up wallet is unaffected.

The gate stays open until a scanner reports a target, and UTXOs deserialized
from persistence written before this field default to scanned, so consumers
that never scan keep the previous behavior.

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

coderabbitai Bot commented Aug 10, 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: 12 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 Plus

Run ID: 52126111-a422-497b-be89-184fb7d6a81d

📥 Commits

Reviewing files that changed from the base of the PR and between 15e0ff1 and 5b1cd3c.

📒 Files selected for processing (2)
  • key-wallet/src/test_utils/wallet.rs
  • key-wallet/src/tests/spend_scan_frontier_tests.rs
📝 Walkthrough

Walkthrough

The wallet now tracks a spend-scan target and holds confirmed UTXOs discovered below that target. The SPV filter manager publishes nonzero filter-header tips through the wallet manager. UTXOs become spendable after synchronization reaches the target.

Changes

Spend-scan frontier gating

Layer / File(s) Summary
Scan state and UTXO contract
key-wallet/src/wallet/metadata.rs, key-wallet/src/utxo.rs, key-wallet/src/wallet/managed_wallet_info/wallet_info_interface.rs
Wallet metadata stores the scan target. UTXOs store spend_scanned, with legacy-compatible defaults. Spendability rejects UTXOs that have not completed spend scanning. Managed wallet state promotes eligible UTXOs when the target or synced height changes.
Wallet frontier enforcement
key-wallet/src/wallet/managed_wallet_info/mod.rs, key-wallet/src/transaction_checking/wallet_checker.rs
Wallet processing marks outputs below a nonzero target as not spend-scanned and promotes them after synchronization reaches the target.
Scan-target propagation
dash-spv/src/sync/filters/manager.rs, key-wallet-manager/src/process_block.rs, key-wallet-manager/src/wallet_interface.rs
Filter-header tips are published through the wallet manager to each wallet. Height zero is ignored.
Frontier behavior validation
key-wallet/src/tests/*, key-wallet/src/wallet/managed_wallet_info/asset_lock_builder.rs, key-wallet/src/wallet/managed_wallet_info/transaction_building.rs
Tests cover catch-up gating, later spends, target-block and mempool receives, compatibility defaults, and serialized UTXO behavior. Test fixtures mark known spend-scanned UTXOs explicitly.

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

Sequence Diagram(s)

sequenceDiagram
  participant FilterManager
  participant WalletManager
  participant ManagedWalletInfo
  participant CoinSelection
  FilterManager->>WalletManager: Publish nonzero filter-header tip
  WalletManager->>ManagedWalletInfo: Update scan target height
  ManagedWalletInfo->>ManagedWalletInfo: Hold UTXOs below target
  ManagedWalletInfo->>CoinSelection: Exclude unscanned UTXOs
  WalletManager->>ManagedWalletInfo: Update synced height
  ManagedWalletInfo->>CoinSelection: Expose promoted UTXOs
Loading

Suggested labels: ready-for-review

Suggested reviewers: zocolini, xdustinface

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly and concisely describes the main change: preventing catch-up receives from being selected for spending.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.
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/spend-scan-frontier

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

@QuantumExplorer

Copy link
Copy Markdown
Member Author

Companion on the Platform side: dashpay/platform#4356 makes a lock that was already built this way fail with a typed terminal error so the app can offer to discard it. This PR is the prevention; that one is the cleanup. Platform's pin bump to pick this up is blocked on this merging.

@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 `@key-wallet/src/tests/spend_scan_frontier_tests.rs`:
- Around line 67-78: Update restored_wallet_mid_catch_up and the additional
wallet setup sites to use the deterministic fixed-seed TestWalletContext fixture
instead of TestWalletContext::new_random(), preserving the existing wallet state
and transaction behavior.
🪄 Autofix

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 Plus

Run ID: 51f7a7a8-9b6d-48cc-a01d-3ab93703d8df

📥 Commits

Reviewing files that changed from the base of the PR and between b056d07 and feae343.

📒 Files selected for processing (12)
  • dash-spv/src/sync/filters/manager.rs
  • key-wallet-manager/src/process_block.rs
  • key-wallet-manager/src/wallet_interface.rs
  • key-wallet/src/tests/mod.rs
  • key-wallet/src/tests/spend_scan_frontier_tests.rs
  • key-wallet/src/transaction_checking/wallet_checker.rs
  • key-wallet/src/utxo.rs
  • key-wallet/src/wallet/managed_wallet_info/asset_lock_builder.rs
  • key-wallet/src/wallet/managed_wallet_info/mod.rs
  • key-wallet/src/wallet/managed_wallet_info/transaction_building.rs
  • key-wallet/src/wallet/managed_wallet_info/wallet_info_interface.rs
  • key-wallet/src/wallet/metadata.rs

Comment thread key-wallet/src/tests/spend_scan_frontier_tests.rs
@codecov

codecov Bot commented Aug 10, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 85.07463% with 10 lines in your changes missing coverage. Please review.
✅ Project coverage is 75.21%. Comparing base (b056d07) to head (5b1cd3c).
⚠️ Report is 1 commits behind head on dev.

Files with missing lines Patch % Lines
key-wallet-manager/src/process_block.rs 0.00% 5 Missing ⚠️
...allet/managed_wallet_info/wallet_info_interface.rs 75.00% 4 Missing ⚠️
key-wallet-manager/src/wallet_interface.rs 0.00% 1 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##              dev     #937      +/-   ##
==========================================
+ Coverage   75.18%   75.21%   +0.03%     
==========================================
  Files         328      328              
  Lines       78194    78261      +67     
==========================================
+ Hits        58792    58867      +75     
+ Misses      19402    19394       -8     
Flag Coverage Δ
core 77.29% <ø> (ø)
ffi 48.58% <ø> (ø)
rpc 20.00% <ø> (ø)
spv 91.39% <100.00%> (+0.03%) ⬆️
wallet 76.96% <83.05%> (+0.07%) ⬆️
Files with missing lines Coverage Δ
dash-spv/src/sync/filters/manager.rs 98.04% <100.00%> (+<0.01%) ⬆️
...-wallet/src/transaction_checking/wallet_checker.rs 99.24% <ø> (ø)
key-wallet/src/utxo.rs 92.20% <100.00%> (+0.42%) ⬆️
...c/wallet/managed_wallet_info/asset_lock_builder.rs 89.65% <100.00%> (+0.03%) ⬆️
key-wallet/src/wallet/managed_wallet_info/mod.rs 77.57% <100.00%> (+3.35%) ⬆️
...wallet/managed_wallet_info/transaction_building.rs 93.54% <100.00%> (+0.01%) ⬆️
key-wallet-manager/src/wallet_interface.rs 10.00% <0.00%> (-0.35%) ⬇️
...allet/managed_wallet_info/wallet_info_interface.rs 79.16% <75.00%> (+1.96%) ⬆️
key-wallet-manager/src/process_block.rs 89.59% <0.00%> (-1.03%) ⬇️

... and 7 files with indirect coverage changes

QuantumExplorer and others added 2 commits August 10, 2026 16:28
- unnecessary_get_then_check in the already-spent-coin test
- qualify the WalletMetadata::scan_target_height intra-doc link, which
  is not in scope from the interface module

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Review asked for reproducible fixtures: a failure now replays with the
same keys and addresses every run. Adds TestWalletContext::new_with_seed
for any test that wants determinism.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@github-actions github-actions Bot added the ready-for-review CodeRabbit has approved this PR label Aug 10, 2026
@QuantumExplorer

Copy link
Copy Markdown
Member Author

Decided this is wrong

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

Development

Successfully merging this pull request may close these issues.

1 participant