Skip to content

fix(key-wallet): out-of-order UTXO spend recorded nowhere — phantom unspent balance (#649)#909

Open
bfoss765 wants to merge 3 commits into
dashpay:devfrom
bfoss765:fix/wallet-utxo-spend-not-marked-649
Open

fix(key-wallet): out-of-order UTXO spend recorded nowhere — phantom unspent balance (#649)#909
bfoss765 wants to merge 3 commits into
dashpay:devfrom
bfoss765:fix/wallet-utxo-spend-not-marked-649

Conversation

@bfoss765

@bfoss765 bfoss765 commented Jul 21, 2026

Copy link
Copy Markdown
Contributor

Summary

Fixes #649. When a spending transaction is processed before its funding transaction (a normal ordering during compact-filter rescans and mempool-chained activity), the wallet does not yet own the spent input, classifies the spend as irrelevant, and records it nowhere. The funding tx is then inserted later as a fresh, spendable UTXO — a phantom balance that deterministically survives full from-seed rescans.

Basis: PR #851

The core mechanism here is adopted from @lklimek's #851 (a wallet-level observed_spent_outpoints map recording every block-observed spend independent of classification) — that design is correct and this PR builds directly on it. On top of the #851 core, this PR adds what our on-device validation showed was needed:

  • A deterministic failing repro (out_of_order_spend_repro_test.rs): funds an address, then delivers the spend block before the funding block — fails on current dev, passes with the fix. Plus two regression tests through the public WalletManager API (multi-wallet, large-block stress).
  • Late-added-account rewind: accounts added after ranges were committed (e.g. DIP-15 friend chains on DashPay wallets) get a sync-checkpoint rewind so their spends aren't missed.
  • Commit-time scan-contiguity guard in dash-spv.
  • AddressPool.script_pubkey_index cleanup in prune_unused.
  • Bounding: prune_finalized_observed_spends caps the set at the finality boundary.

On-device validation (Android, testnet)

  • Bug reproduced in production use: a day-old wallet with rapid mempool-chained activity developed a deterministic phantom +0.01 DASH (outpoint 2febe5d7e8ad1dd0fb633004a82a24783d9b2e9095883541576e5f1344eb9975:0, spent per dashj, unspent per dash-spv) that survived a full wallet rebuild from seed — matching this root cause exactly.
  • Fix validated twice on device: (1) the affected wallet rebuilt clean — balance parity with dashj restored to the duff; (2) a 1,049-transaction heavy-CoinJoin wallet restored from seed under the fixed engine — final balance exact to the duff against an independently-synced dashj baseline (12.58712954 DASH), ~8-minute scan.

Tests

  • New: 1 repro + 2 regression tests (red→green).
  • Full suites: key-wallet 549 pass / 0 fail, key-wallet-manager all pass, dash-spv lib 482 pass / 0 fail (incl. 3 new contiguity-guard tests).

Relationship to #851

This PR supersedes #851 with the same core design plus the tests and hardening above; recommending #851 be closed in its favor (comment posted there). Design credit to @lklimek.

Summary by CodeRabbit

  • Bug Fixes

    • Prevented spent wallet outputs from reappearing during out-of-order rescans or noisy block processing.
    • Improved transaction and balance consistency when spends are detected before their funding transactions.
    • Ensured observed spend data remains isolated between wallets.
    • Prevented wallet sync checkpoints from advancing across gaps after accounts are added.
    • Cleaned up stale address data after address pruning.
  • Tests

    • Added coverage for multi-wallet spending, out-of-order blocks, large blocks, and checkpoint handling.

bfoss765 and others added 2 commits July 20, 2026 18:00
…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>
…ashpay#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 dashpay#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: dashpay#649
Adapted-from: dashpay#851

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

coderabbitai Bot commented Jul 21, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

📝 Walkthrough

Walkthrough

The PR tracks observed spent outpoints through wallet and account transaction handling, prevents spent UTXO resurrection during out-of-order processing, rewinds checkpoints for new accounts, and guards per-wallet sync advancement during batch commits.

Changes

Observed spend protection

Layer / File(s) Summary
Observed-spend state and compensation
key-wallet/src/wallet/managed_wallet_info/mod.rs, key-wallet/src/managed_account/transaction_record.rs, key-wallet/src/managed_account/managed_core_funds_account.rs
Wallets persist bounded observed spends, transaction records compensate spent outputs, and account spend state can be rebuilt.
Account spend propagation
key-wallet/src/managed_account/managed_account_ref.rs, key-wallet/src/managed_account/managed_core_funds_account.rs
Observed spends flow through account APIs; already-spent UTXOs are skipped and reservations can be released.
Wallet transaction flow
key-wallet/src/transaction_checking/wallet_checker.rs, key-wallet/src/wallet/managed_wallet_info/*
Block spends are recorded, spent outputs are removed, transaction records receive observed-spend data, and finalized entries are pruned.
Observed-spend regression coverage
key-wallet-manager/tests/*, key-wallet/src/managed_account/transaction_record.rs, key-wallet/src/transaction_checking/wallet_checker.rs
Tests cover out-of-order spends, multi-wallet isolation, large noisy blocks, compensation behavior, and updated APIs.

Sync checkpoint consistency

Layer / File(s) Summary
Account-add checkpoint invalidation
key-wallet/src/wallet/managed_wallet_info/managed_accounts.rs
All managed account creation variants rewind the wallet sync checkpoint after insertion.
Contiguous wallet batch commits
dash-spv/src/sync/filters/manager.rs
Batch commits advance each wallet only when its checkpoint is contiguous through the batch start, with normal and mid-flight account-add tests.

Address pool index cleanup

Layer / File(s) Summary
Pruned address index cleanup
key-wallet/src/managed_account/address_pool.rs
Pruning removes stale script_pubkey_index entries alongside address mappings.

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

Sequence Diagram(s)

sequenceDiagram
  participant WalletTransactionChecker
  participant ManagedWalletInfo
  participant ManagedCoreFundsAccount
  participant TransactionRecord
  WalletTransactionChecker->>ManagedWalletInfo: record block spends
  WalletTransactionChecker->>ManagedCoreFundsAccount: process transaction with observed spends
  ManagedCoreFundsAccount->>ManagedCoreFundsAccount: skip spent UTXO outputs
  ManagedCoreFundsAccount->>TransactionRecord: compensate spent outputs
  ManagedWalletInfo->>ManagedCoreFundsAccount: remove spent account outputs
Loading

Possibly related issues

Possibly related PRs

Suggested reviewers: quantumexplorer, 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 clearly describes the main fix for out-of-order UTXO spend handling and references issue #649.
Linked Issues check ✅ Passed The changes add observed-spent tracking and reconciliation so funding outputs are not resurrected when spend blocks arrive first, matching #649.
Out of Scope Changes check ✅ Passed The added helpers, tests, checkpoint rewinding, pruning, and address-pool cleanup are all aligned with the PR's stated fix scope.
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.

🧹 Nitpick comments (1)
key-wallet/src/transaction_checking/wallet_checker.rs (1)

63-93: 🚀 Performance & Scalability | 🔵 Trivial

Scalability note: per-transaction recording/removal is un-gated over the whole matched block.

Every transaction in a matched block (including thousands of wallet-irrelevant ones) runs record_observed_spends (one insert per input) and, on this path, remove_spent_from_accounts, which allocates an all_funding_accounts_mut() Vec per call and scans accounts×inputs. During a large cold rescan synced_height stays low, so prune_finalized_observed_spends defers, letting observed_spent_outpoints grow with total block inputs across all matched blocks (bounded only by the 10M deser cap). Consider short-circuiting when there are no funding accounts/UTXOs to touch, or hoisting the account-handle fetch out of the per-tx loop, to keep busy-block/rescan cost bounded. Correctness is fine; this is about steady-state cost and memory during heavy rescans.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@key-wallet/src/transaction_checking/wallet_checker.rs` around lines 63 - 93,
Bound the per-transaction work in the block-checking flow by skipping
record_observed_spends and remove_spent_from_accounts when the wallet has no
funding accounts or UTXOs that can be affected. Reuse or hoist the
funding-account handles instead of allocating all_funding_accounts_mut() for
every transaction, while preserving spend tracking and account removal whenever
relevant funding exists.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Nitpick comments:
In `@key-wallet/src/transaction_checking/wallet_checker.rs`:
- Around line 63-93: Bound the per-transaction work in the block-checking flow
by skipping record_observed_spends and remove_spent_from_accounts when the
wallet has no funding accounts or UTXOs that can be affected. Reuse or hoist the
funding-account handles instead of allocating all_funding_accounts_mut() for
every transaction, while preserving spend tracking and account removal whenever
relevant funding exists.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 274499ff-97d6-4dbb-b715-d929b750a8ff

📥 Commits

Reviewing files that changed from the base of the PR and between 19690d3 and ebcd40a.

📒 Files selected for processing (13)
  • dash-spv/src/sync/filters/manager.rs
  • key-wallet-manager/tests/common/mod.rs
  • key-wallet-manager/tests/observed_spent_large_block_stress_test.rs
  • key-wallet-manager/tests/observed_spent_multi_wallet_test.rs
  • key-wallet-manager/tests/out_of_order_spend_repro_test.rs
  • key-wallet/src/managed_account/address_pool.rs
  • key-wallet/src/managed_account/managed_account_ref.rs
  • key-wallet/src/managed_account/managed_core_funds_account.rs
  • key-wallet/src/managed_account/transaction_record.rs
  • key-wallet/src/transaction_checking/wallet_checker.rs
  • key-wallet/src/wallet/managed_wallet_info/managed_accounts.rs
  • key-wallet/src/wallet/managed_wallet_info/mod.rs
  • key-wallet/src/wallet/managed_wallet_info/wallet_info_interface.rs

coderabbitai[bot]
coderabbitai Bot previously approved these changes Jul 21, 2026
@codecov

codecov Bot commented Jul 21, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 96.55172% with 11 lines in your changes missing coverage. Please review.
✅ Project coverage is 74.68%. Comparing base (19690d3) to head (31b9dac).

Files with missing lines Patch % Lines
key-wallet/src/wallet/managed_wallet_info/mod.rs 92.39% 7 Missing ⚠️
...src/wallet/managed_wallet_info/managed_accounts.rs 33.33% 4 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##              dev     #909      +/-   ##
==========================================
+ Coverage   74.54%   74.68%   +0.13%     
==========================================
  Files         327      327              
  Lines       75032    75332     +300     
==========================================
+ Hits        55936    56261     +325     
+ Misses      19096    19071      -25     
Flag Coverage Δ
core 77.29% <ø> (ø)
ffi 51.12% <ø> (+0.17%) ⬆️
rpc 20.00% <ø> (ø)
spv 91.16% <100.00%> (-0.05%) ⬇️
wallet 74.77% <95.02%> (+0.36%) ⬆️
Files with missing lines Coverage Δ
dash-spv/src/sync/filters/manager.rs 97.85% <100.00%> (+0.11%) ⬆️
key-wallet/src/managed_account/address_pool.rs 69.39% <100.00%> (+2.63%) ⬆️
...-wallet/src/managed_account/managed_account_ref.rs 55.72% <100.00%> (+0.46%) ⬆️
.../src/managed_account/managed_core_funds_account.rs 79.03% <100.00%> (+1.17%) ⬆️
...y-wallet/src/managed_account/transaction_record.rs 100.00% <100.00%> (ø)
...-wallet/src/transaction_checking/wallet_checker.rs 99.24% <100.00%> (+<0.01%) ⬆️
...allet/managed_wallet_info/wallet_info_interface.rs 77.48% <100.00%> (+0.17%) ⬆️
...src/wallet/managed_wallet_info/managed_accounts.rs 35.34% <33.33%> (+6.64%) ⬆️
key-wallet/src/wallet/managed_wallet_info/mod.rs 77.72% <92.39%> (+11.33%) ⬆️

... and 19 files with indirect coverage changes

…patch coverage

Codecov flagged the dashpay#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: dashpay#649

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

Copy link
Copy Markdown
Contributor Author

Preempting the "block processing is order-enforced, so this shouldn't be possible" objection

This came up on #649 back in April — @xdustinface noted that block processing is order-enforced and concluded the defect shouldn't be reachable, and @lklimek initially agreed — before @lklimek's 2026-07-07 sub-second deterministic repro on #649 reversed that read. It's worth addressing head-on, because the objection targets the wrong layer: the coin is lost during wallet-layer transaction classification, not during chain/header sequencing, and in-order block delivery does not close that gap.

The root cause is classification, not ordering. The "already spent" guard in managed_core_funds_account.rs::update_utxos keys off the account-local spent_outpoints set, which is populated only when the account itself processes the spending transaction. A pure spend of a coin the wallet has not yet funded matches none of the account's owned inputs, so it is classified irrelevant, update_utxos never runs for it, and nothing anywhere records that the outpoint was consumed; when the funding tx is processed later, the output is re-inserted as fresh spendable value. Ordered block delivery does not prevent this, because the drop fires under several ordering-compatible paths:

  1. Cross-block out-of-order application. Height-ordered header sync does not imply height-ordered block download/apply during a cold rescan or parallel fetch. Consensus topological ordering only guarantees a funding→spend pair is correctly ordered within a single block — it says nothing about a spend whose funding coin sits in an earlier block that has not yet been applied. That is exactly what the repro constructs: funding at height 100, spend at height 200, height 200 applied first.
  2. Block re-processing / replay. A rescan re-delivers already-seen blocks; to the wallet layer a re-processed funding-after-spend sequence is indistinguishable from reordering — which is precisely what @lklimek's July 7 repro surfaced deterministically in under a second.
  3. Compact-filter scan gap. A pure-spend tx pays no wallet script, so it matches no BIP158 filter, and the block carrying it may never be fetched or processed on the spend side at all. No amount of ordering among the blocks the node does fetch can help with a block it never fetches.

This is not theoretical here. The PR's out_of_order_spend_repro_test delivers the blocks through the real WalletManager path (spend block at height 200, then funding block at height 100) and still leaves the funding outpoint permanently tracked on unpatched dev; and on testnet the phantom +0.01 on output 2febe5d7e8ad1dd0fb633004a82a24783d9b2e9095883541576e5f1344eb9975:0 survives a full from-seed re-derivation + rescan, which isolates the miss to the scan/processing path rather than live mempool ingestion. The fix records every block-observed spend into a wallet-level, classification-independent observed_spent_outpoints map, so whichever order the two blocks arrive in the funding-side insert is reconciled away.

On coverage: the previously-uncovered branches of this machinery that Codecov flagged (the observed_spent_outpoints serde adapter, finality-boundary pruning, the funding-first removal guard, the account-add sync rewind, and the AddressPool::prune_unused script_pubkey_index fix) are now exercised by targeted white-box tests added in 31b9dac; the key-wallet, key-wallet-manager, and dash-spv lib suites are green.

@bfoss765

Copy link
Copy Markdown
Contributor Author

Re the wallet_checker.rs:63-93 scalability note: leaving this as-is; the cost is already bounded and the correctness-critical half can't be applied safely. record_observed_spends must stay un-gated: a spend seen while the wallet has no matching funding account (or before one is added) is exactly the #649 case, and the set is designed to self-repopulate on replay, so skipping it when there are no funding accounts/UTXOs would reopen the bug. The account-handle fetch in remove_spent_from_accounts is already hoisted to once per call; the remaining per-tx all_funding_accounts_mut() is a non-allocating empty Vec when there are no funding accounts. Growth during a low-synced-height rescan is intentional (event-driven eviction, never age/LRU — evicting there is what reopens #649), and observed_spent_large_block_stress_test (5,000-tx block) asserts bounded/linear cost. On this safety-critical path I'd rather not trade a proven-bounded, stress-tested guard for a micro-optimization that risks the invariant.

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.

bug: out-of-order block processing causes SPV wallet to miss UTXO spends

1 participant