Skip to content

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

Merged
QuantumExplorer merged 10 commits into
dashpay:devfrom
bfoss765:fix/wallet-utxo-spend-not-marked-649
Aug 6, 2026
Merged

fix(key-wallet): out-of-order UTXO spend recorded nowhere — phantom unspent balance (#649)#909
QuantumExplorer merged 10 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

    • Improved wallet transaction reconciliation when spending transactions arrive before their corresponding funding transactions.
    • Prevented spent outputs from being incorrectly restored as available funds.
    • Isolated spend tracking between wallets.
    • Improved synchronization safety when accounts are added or wallet state changes during scanning.
    • Corrected cleanup of unused wallet addresses and related indexing data.
  • Reliability

    • Added safeguards for large, noisy blocks and complex transaction ordering scenarios.

@coderabbitai

coderabbitai Bot commented Jul 21, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: b5a92f89-c708-4138-a28f-311fc4aad5db

📥 Commits

Reviewing files that changed from the base of the PR and between e8dd66f and c25cdd4.

📒 Files selected for processing (7)
  • key-wallet-manager/tests/common/mod.rs
  • key-wallet-manager/tests/observed_spent_large_block_stress_test.rs
  • key-wallet-manager/tests/out_of_order_spend_repro_test.rs
  • key-wallet/src/managed_account/managed_core_funds_account.rs
  • key-wallet/src/tests/observed_spent_outpoints_tests.rs
  • key-wallet/src/transaction_checking/wallet_checker.rs
  • key-wallet/src/wallet/managed_wallet_info/mod.rs
💤 Files with no reviewable changes (1)
  • key-wallet/src/transaction_checking/wallet_checker.rs
🚧 Files skipped from review as they are similar to previous changes (3)
  • key-wallet-manager/tests/observed_spent_large_block_stress_test.rs
  • key-wallet/src/wallet/managed_wallet_info/mod.rs
  • key-wallet-manager/tests/common/mod.rs

📝 Walkthrough

Walkthrough

The wallet now records block-observed spends and prevents out-of-order processing from recreating spent UTXOs. Account additions rewind sync state. Filter batch commits validate account generations and contiguous coverage per wallet.

Changes

Observed spend protection

Layer / File(s) Summary
Observed-spend state and compensation
key-wallet/src/wallet/managed_wallet_info/*, key-wallet/src/managed_account/managed_core_funds_account.rs
ManagedWalletInfo persists and prunes observed spends. Funds accounts skip outputs already known to be spent and rebuild spend data during deserialization.
Account and transaction propagation
key-wallet/src/managed_account/managed_account_ref.rs, key-wallet/src/transaction_checking/wallet_checker.rs
Observed-spend maps flow through recording, confirmation, and InstantSend backfill paths. Block transactions record observed inputs independently of wallet attribution.
Regression and stress coverage
key-wallet/src/tests/*, key-wallet-manager/tests/*
Tests cover serialization, pruning, account rewinds, out-of-order spends, wallet isolation, large noisy blocks, state changes, and address-index cleanup.

Sync checkpoint consistency

Layer / File(s) Summary
Account-add checkpoint invalidation
key-wallet/src/wallet/managed_wallet_info/*, key-wallet-manager/src/*
Managed account creation rewinds sync checkpoints. Wallet interfaces expose per-wallet account generations, including mock support.
Generation-aware batch commits
dash-spv/src/sync/filters/*
Scanned wallets store generation snapshots. Checkpoint advancement requires an unchanged generation and contiguous batch coverage. Tests cover rewinds, generation changes, wallet isolation, and valid commits.

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

Possibly related issues

  • dashpay/rust-dashcore issue 649 — Directly addressed by observed-spend tracking for out-of-order block processing.
  • dashpay/rust-dashcore issue 899 — Related to bounded and pruned observed_spent_outpoints state.
  • dashpay/dash-evo-tool issue 829 — Related to generation-aware filter-sync rewind and commit handling.

Possibly related PRs

Suggested labels: ready-for-review

🚥 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 identifies the key-wallet fix for phantom unspent balances caused by out-of-order UTXO spends.
Linked Issues check ✅ Passed The changes record observed spends and prevent stale UTXO reinsertion, directly addressing the out-of-order processing defect in [#649].
Out of Scope Changes check ✅ Passed The implementation, synchronization guards, cleanup, pruning, and regression tests support the wallet consistency objectives in [#649].
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 89.13043% with 40 lines in your changes missing coverage. Please review.
✅ Project coverage is 74.82%. Comparing base (8f78baa) to head (c25cdd4).

Files with missing lines Patch % Lines
...-wallet/src/managed_account/managed_account_ref.rs 50.00% 16 Missing ⚠️
key-wallet/src/wallet/managed_wallet_info/mod.rs 89.04% 8 Missing ⚠️
...allet/managed_wallet_info/wallet_info_interface.rs 25.00% 6 Missing ⚠️
...src/wallet/managed_wallet_info/managed_accounts.rs 33.33% 4 Missing ⚠️
key-wallet-manager/src/process_block.rs 0.00% 3 Missing ⚠️
key-wallet-manager/src/wallet_interface.rs 0.00% 3 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##              dev     #909      +/-   ##
==========================================
+ Coverage   74.75%   74.82%   +0.07%     
==========================================
  Files         328      328              
  Lines       76700    77044     +344     
==========================================
+ Hits        57337    57651     +314     
- Misses      19363    19393      +30     
Flag Coverage Δ
core 77.29% <ø> (ø)
ffi 49.32% <ø> (+<0.01%) ⬆️
rpc 20.00% <ø> (ø)
spv 91.18% <100.00%> (+0.12%) ⬆️
wallet 75.74% <75.30%> (-0.01%) ⬇️
Files with missing lines Coverage Δ
dash-spv/src/sync/filters/batch.rs 97.60% <100.00%> (ø)
dash-spv/src/sync/filters/manager.rs 97.86% <100.00%> (+0.11%) ⬆️
key-wallet/src/managed_account/address_pool.rs 79.22% <100.00%> (+0.11%) ⬆️
.../src/managed_account/managed_core_funds_account.rs 79.57% <100.00%> (+0.58%) ⬆️
...-wallet/src/transaction_checking/wallet_checker.rs 99.24% <100.00%> (+<0.01%) ⬆️
key-wallet-manager/src/process_block.rs 90.53% <0.00%> (-0.64%) ⬇️
key-wallet-manager/src/wallet_interface.rs 10.34% <0.00%> (-1.20%) ⬇️
...src/wallet/managed_wallet_info/managed_accounts.rs 35.34% <33.33%> (+6.64%) ⬆️
...allet/managed_wallet_info/wallet_info_interface.rs 76.36% <25.00%> (-1.54%) ⬇️
key-wallet/src/wallet/managed_wallet_info/mod.rs 75.00% <89.04%> (+8.61%) ⬆️
... and 1 more

... and 5 files with indirect coverage changes

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

bfoss765 pushed a commit to bfoss765/rust-dashcore that referenced this pull request Jul 22, 2026
…urface guard-rewritten records, restore public account API

Addresses three external review findings on top of PR dashpay#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-dashpay#649 behavior); the checker uses
   new pub(crate) *_with_observed_spends variants.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
bfoss765 added a commit to bfoss765/rust-dashcore that referenced this pull request Jul 22, 2026
Review fixes for dashpay#909: generation guard, surfaced record rewrites, restored public API
coderabbitai[bot]
coderabbitai Bot previously approved these changes Jul 22, 2026
bfoss765 added a commit to bfoss765/rust-dashcore that referenced this pull request Jul 23, 2026
… history (dashpay#649/dashpay#846)

HashEngineering reported (against dashpay#851/dashpay#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 dashpay#851";
dashpay#851 is superseded by dashpay#909.

Investigation of dashpay#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>
@bfoss765

Copy link
Copy Markdown
Contributor Author

@HashEngineering thanks for flagging the born-fully-spent case. I dug into it and this PR already records those transactions — the drop you're describing was specific to #851 (the earlier PoC), where the skip gated relevance. #909 reimplemented that path, so I don't think a behavior change is needed here; I've added regression tests to pin it instead. Details:

  • Relevance is decided by address membership, not by observed-spends. check_transaction_for_match marks a funding tx relevant when its outputs pay one of our addresses, even when every one of those outputs has already been observed spent. So a born-fully-spent funding tx is still relevant.
  • The record is inserted unconditionally. record_transaction calls TransactionRecord::compensate_for_observed_spends (which zeroes the already-spent outputs → net 0) and then inserts the record into the history map. There's no relevance/net-value gate on the insert.
  • The only skip is UTXO insertion, not recording. The "never add already-spent value" guard lives in update_utxos, and it only skips adding the UTXO — the transaction still lands in history and surfaces as a new-record event.

I traced the funding-after-spend ordering across InBlock, InChainLockedBlock, a CoinJoin-style intermediate hop, and rescan re-delivery, and there's no path where a born-fully-spent tx is dropped from history (balance/UTXO correctly stay 0).

The existing observed-spent tests only asserted UTXO/balance, so I added two that assert the history-record guarantee directly (just pushed): born_fully_spent_funding_tx_is_recorded_in_history and born_fully_spent_intermediate_hop_is_recorded_in_history in key-wallet/src/tests/observed_spent_outpoints_tests.rs. Also fixed the Documentation CI job (two private intra-doc links).

If you were seeing it drop in a specific scenario, happy to add that as a test case — let me know the ordering/context and I'll reproduce.

coderabbitai[bot]
coderabbitai Bot previously approved these changes Jul 23, 2026
@github-actions github-actions Bot added the ready-for-review CodeRabbit has approved this PR label Jul 24, 2026

@QuantumExplorer QuantumExplorer left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Rereviewed the current head (f76430d). I don't have any remaining findings.

The issues from my earlier passes are now addressed:

  • The filter commit path snapshots each wallet's account_generation, so an account added after a batch scan cannot have unscanned coverage certified—even when the checkpoint rewind lands inside the batch or does not move.
  • Funding records compensated by the classification-independent spend guard are returned as updated_records and propagated by WalletManager independently of is_relevant, preserving event-driven persistence consistency.
  • The existing public ManagedAccountRefMut::record_transaction and confirm_transaction entry points remain source-compatible, with wallet-aware observed-spend handling moved into crate-private helpers.

The latest Continuous integration, Sanitizer, and Pre-commit Checks workflow runs for this head are green.

@github-actions

github-actions Bot commented Aug 5, 2026

Copy link
Copy Markdown
Contributor

This PR has merge conflicts with the base branch. Please rebase or merge the base branch into your branch to resolve them.

bfoss765 and others added 4 commits August 5, 2026 01:15
…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>
…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>
… 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-dashpay#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>
QuantumExplorer and others added 3 commits August 5, 2026 01:16
…urface guard-rewritten records, restore public account API

Addresses three external review findings on top of PR dashpay#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-dashpay#649 behavior); the checker uses
   new pub(crate) *_with_observed_spends variants.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
… history (dashpay#649/dashpay#846)

HashEngineering reported (against dashpay#851/dashpay#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 dashpay#851";
dashpay#851 is superseded by dashpay#909.

Investigation of dashpay#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>
… job passes

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

coderabbitai Bot commented Aug 5, 2026

Copy link
Copy Markdown
Contributor

Note

GitHub couldn't provide a complete incremental comparison for this pull request, so CodeRabbit is performing a full review instead. This review may take a little longer.

@github-actions github-actions Bot removed merge-conflict The PR conflicts with the target branch. ready-for-review CodeRabbit has approved this PR labels Aug 5, 2026

@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-manager/tests/common/mod.rs`:
- Around line 58-70: Parameterize the spend_tx helper with a Network argument
and pass it to Address::dummy instead of hardcoding Network::Testnet. Update
every spend_tx call in the observed-spend tests to supply the intended network,
and execute the scenario for both mainnet and testnet configurations.
🪄 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: b561e970-0f3e-4f8d-8842-c0290b25dbb4

📥 Commits

Reviewing files that changed from the base of the PR and between 8f78baa and e8dd66f.

📒 Files selected for processing (21)
  • dash-spv/src/sync/filters/batch.rs
  • dash-spv/src/sync/filters/manager.rs
  • key-wallet-manager/src/lib.rs
  • key-wallet-manager/src/process_block.rs
  • key-wallet-manager/src/test_utils/mock_wallet.rs
  • key-wallet-manager/src/wallet_interface.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/tests/address_pool_tests.rs
  • key-wallet/src/tests/mod.rs
  • key-wallet/src/tests/observed_spent_outpoints_tests.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
🚧 Files skipped from review as they are similar to previous changes (20)
  • key-wallet/src/managed_account/address_pool.rs
  • key-wallet/src/tests/address_pool_tests.rs
  • key-wallet-manager/src/wallet_interface.rs
  • key-wallet-manager/src/process_block.rs
  • key-wallet/src/tests/mod.rs
  • key-wallet-manager/src/test_utils/mock_wallet.rs
  • key-wallet-manager/tests/observed_spent_multi_wallet_test.rs
  • key-wallet/src/managed_account/managed_account_ref.rs
  • key-wallet-manager/tests/out_of_order_spend_repro_test.rs
  • key-wallet-manager/src/lib.rs
  • key-wallet/src/wallet/managed_wallet_info/managed_accounts.rs
  • key-wallet-manager/tests/observed_spent_large_block_stress_test.rs
  • key-wallet/src/wallet/managed_wallet_info/wallet_info_interface.rs
  • key-wallet/src/managed_account/transaction_record.rs
  • dash-spv/src/sync/filters/batch.rs
  • key-wallet/src/wallet/managed_wallet_info/mod.rs
  • key-wallet/src/transaction_checking/wallet_checker.rs
  • key-wallet/src/managed_account/managed_core_funds_account.rs
  • key-wallet/src/tests/observed_spent_outpoints_tests.rs
  • dash-spv/src/sync/filters/manager.rs

Comment thread key-wallet-manager/tests/common/mod.rs Outdated
…elper

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>
coderabbitai[bot]
coderabbitai Bot previously approved these changes Aug 5, 2026
@github-actions github-actions Bot added the ready-for-review CodeRabbit has approved this PR label Aug 5, 2026

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

What this PR actually fixes

The #649 mechanism is two pieces, ~40 lines:

  • wallet_checker.rs:63-82: record every input seen in a block-context tx into observed_spent_outpoints, independent of classification.
  • managed_core_funds_account.rs:285-295: in update_utxos, skip inserting an output whose outpoint is already in that map.

I disabled everything else on this branch (both remove_spent_from_accounts call sites and compensate_for_observed_spends) and ran the suites: all three new integration tests pass, including the deterministic repro, and cargo test -p key-wallet --lib gives 611 passed / 3 failed: the 3 being tests of the extra machinery itself.

So: 2128 added lines for a ~40-line root-cause fix, bundling four independent bugs.

Suggested split

  1. #649 proper: observed_spent_outpoints + the update_utxos skip + serde adapter/cap + prune_finalized_observed_spends + the repro test. ~250 lines, mergeable as-is.
  2. Unattributable spend with funding already present: remove_spent_from_accounts, compensate_for_observed_spends, the *_with_observed_spends API pair, the updated_records change in key-wallet-manager. Real bug, different from #649.
  3. Filter coverage for late-added accounts: account_generation, the checkpoint rewind, wallet_account_generation, and the dash-spv commit guards. Real bug, unrelated to out-of-order delivery.
  4. AddressPool::prune_unused leaks script_pubkey_index entries: 2 lines plus its test, unrelated to #649.

Two blockers before 2 and 3 land. Note: This blockers where found by AI, I didn't review them myself since they are not related to 1

Group 3 breaks checkpoint-based sync. The contiguity guard (manager.rs:588-592) assumes synced_height + 1 == scan_start, but scan_start = birth_height.max(header_start_height)
(manager.rs:208-214). When headers sync from a checkpoint above the wallet's birth — the case handled at manager.rs:216-220 — that wallet's checkpoint never advances,
wallets_behind() keeps listing it, and the tick at sync_manager.rs:199-221 rescans forever. Repro in the manager's own test module: fresh wallet at synced_height = 0, first batch
at 1_000_000, matching generation → commits leave it at 0. This advances fine on dev.

Group 2 makes history order-dependent. compensate_for_observed_spends records a tx that genuinely received 2 DASH as a 0-value receive, and since the spend isn't attributed
there's no spend record either — the transaction disappears from history, where in-order delivery yields two records. Balance is right, history isn't. Option C from the issue
(retroactive detection at insert time) would record both correctly instead of erasing the receive.

/// A transaction spending `outpoint` and paying `value` to an unrelated
/// external dummy address (`ext_id` selects a distinct address).
pub fn spend_tx(outpoint: OutPoint, value: u64, ext_id: usize) -> Transaction {
Transaction {

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.

Check on dash/src/test_utils/*.rs, there are imps blocks to create dummy Transactions

//!
//! Each test binary pulls this in via `mod common;` and uses a subset of the
//! helpers, so unused-item warnings are expected per binary and silenced here.
#![allow(dead_code)]

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.

Isn't this unnecessary, as far as I know a symbols is not flagged as unused when a subset of tests use it

…d-spend mechanism

Applies ZocoLini's requested simplification (PR dashpay#909 review): the dashpay#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>
@github-actions github-actions Bot removed the ready-for-review CodeRabbit has approved this PR label Aug 5, 2026
@bfoss765

bfoss765 commented Aug 5, 2026

Copy link
Copy Markdown
Contributor Author

@ZocoLini applied the core simplification — head is now c6cec0f5. Summary of what was removed vs kept, verified against the current code.

Removed (group 2 — the unattributable-spend compensation machinery)

  • TransactionRecord::compensate_for_observed_spends + its two 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 producer was the funding-first guard, so it's reverted to relevance-gated (back to what dev does)
  • the two white-box tests that drove remove_spent_from_accounts directly (funding_first_guard_removes_held_coin_and_compensates_record, wallet_level_set_outlives_account_local_reload)

Net −417 lines in the #909 commit.

Kept (the two-piece mechanism + its plumbing)

  • (a) wallet_checker.rs: record every block-context input into observed_spent_outpoints, independent of classification
  • (b) managed_core_funds_account.rs::update_utxos: skip inserting an output whose outpoint is already in that map
  • record_observed_spends / prune_finalized_observed_spends / the serde adapter + deser cap

One rebuttal. You listed the record_transaction_with_observed_spends / confirm_transaction_with_observed_spends pair under group 2, but it is the delivery path for piece (b) — it's what threads the wallet-level observed_spent map down into update_utxos. Remove it and piece (b) no longer sees the map, so the two-piece mechanism stops working. Your empirical "disable" only turned off the two remove_spent_from_accounts call sites and compensate_for_observed_spends (not this pair), which is why it stayed green. So I kept it. The public record_transaction / confirm_transaction entry points still forward with an empty map, so they stay source-compatible (the point @QuantumExplorer verified).

Order-dependent history

Removing compensate_for_observed_spends fixes exactly the divergence you flagged: a receive delivered before its spend is now preserved in history (net_amount / output_details intact) instead of being rewritten to a 0-value entry. Balance/UTXO correctness comes purely from piece (b) skipping the already-spent coin, not from rewriting the record — so in-order and out-of-order delivery now record the same history. The born_fully_spent_* tests were updated to pin that the receive stays in history.

Test-helper nits (key-wallet-manager/tests/common/mod.rs)

  • (b) Network param on spend_tx — agreed it was superfluous: every caller passed Testnet, and the payee's network is irrelevant to observed-spend logic (it keys on the input outpoint, never the payee). Dropped it. To avoid re-introducing the hardcode CodeRabbit had flagged, the external payee script is now built directly (a P2PKH shape seeded by ext_id), so the helper needs no network at all.
  • (a) existing dummy-tx buildersTransaction::dummy doesn't fit spend_tx: it derives its inputs from an id range and can't spend a caller-specified outpoint, which is the whole point of a spend. Kept a minimal hand-rolled builder for that reason.

Scope

This pass is just the group-1-vs-group-2 split (removed 2, kept 1). Groups 3 (filter coverage / account_generation) and 4 (AddressPool::prune_unused) are untouched here per the split.

Verification

  • all three observed-spend integration tests (incl. the deterministic repro) pass
  • cargo test -p key-wallet --lib: 610 passed / 0 failed
  • workspace clippy -D warnings (debug + release) clean; rustfmt clean

…iant 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 dashpay#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.
@github-actions github-actions Bot added the ready-for-review CodeRabbit has approved this PR label Aug 5, 2026
@QuantumExplorer
QuantumExplorer merged commit a041f44 into dashpay:dev Aug 6, 2026
37 of 38 checks passed
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.

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

3 participants