fix(key-wallet): out-of-order UTXO spend recorded nowhere — phantom unspent balance (#649)#909
fix(key-wallet): out-of-order UTXO spend recorded nowhere — phantom unspent balance (#649)#909bfoss765 wants to merge 3 commits into
Conversation
…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>
📝 WalkthroughWalkthroughThe 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. ChangesObserved spend protection
Sync checkpoint consistency
Address pool index cleanup
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
Possibly related issues
Possibly related PRs
Suggested reviewers: 🚥 Pre-merge checks | ✅ 5✅ Passed checks (5 passed)
✨ Finishing Touches🧪 Generate unit tests (beta)
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. Comment |
There was a problem hiding this comment.
🧹 Nitpick comments (1)
key-wallet/src/transaction_checking/wallet_checker.rs (1)
63-93: 🚀 Performance & Scalability | 🔵 TrivialScalability 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 anall_funding_accounts_mut()Vecper call and scans accounts×inputs. During a large cold rescansynced_heightstays low, soprune_finalized_observed_spendsdefers, lettingobserved_spent_outpointsgrow 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
📒 Files selected for processing (13)
dash-spv/src/sync/filters/manager.rskey-wallet-manager/tests/common/mod.rskey-wallet-manager/tests/observed_spent_large_block_stress_test.rskey-wallet-manager/tests/observed_spent_multi_wallet_test.rskey-wallet-manager/tests/out_of_order_spend_repro_test.rskey-wallet/src/managed_account/address_pool.rskey-wallet/src/managed_account/managed_account_ref.rskey-wallet/src/managed_account/managed_core_funds_account.rskey-wallet/src/managed_account/transaction_record.rskey-wallet/src/transaction_checking/wallet_checker.rskey-wallet/src/wallet/managed_wallet_info/managed_accounts.rskey-wallet/src/wallet/managed_wallet_info/mod.rskey-wallet/src/wallet/managed_wallet_info/wallet_info_interface.rs
Codecov Report❌ Patch coverage is
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
|
…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>
Preempting the "block processing is order-enforced, so this shouldn't be possible" objectionThis 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
This is not theoretical here. The PR's On coverage: the previously-uncovered branches of this machinery that Codecov flagged (the |
|
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. |
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_outpointsmap 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:out_of_order_spend_repro_test.rs): funds an address, then delivers the spend block before the funding block — fails on currentdev, passes with the fix. Plus two regression tests through the publicWalletManagerAPI (multi-wallet, large-block stress).AddressPool.script_pubkey_indexcleanup inprune_unused.prune_finalized_observed_spendscaps the set at the finality boundary.On-device validation (Android, testnet)
2febe5d7e8ad1dd0fb633004a82a24783d9b2e9095883541576e5f1344eb9975:0, spent per dashj, unspent per dash-spv) that survived a full wallet rebuild from seed — matching this root cause exactly.12.58712954DASH), ~8-minute scan.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
Tests