Skip to content

fix(key-wallet): out-of-order UTXO spend causes history divergence (#649)#851

Draft
lklimek wants to merge 29 commits into
devfrom
fix/key-wallet-out-of-order-utxo-spend-649
Draft

fix(key-wallet): out-of-order UTXO spend causes history divergence (#649)#851
lklimek wants to merge 29 commits into
devfrom
fix/key-wallet-out-of-order-utxo-spend-649

Conversation

@lklimek

@lklimek lklimek commented Jul 7, 2026

Copy link
Copy Markdown
Contributor

Why this PR exists

  • Problem: key-wallet could record a transaction's history net_amount incorrectly when a UTXO's spend was observed out of order relative to the funding transaction being recorded — the wallet's transaction history and its balance could permanently diverge for affected coins.
  • What breaks without it: a coin whose spend is observed out of order from its funding transaction leaves the wallet's transaction-history net amount wrong indefinitely — no rescan recovers it, because the divergence isn't in the UTXO set (balance stays correct) but in the historical record of what was received. Users would see an incorrect running-total history for an account even though spendable balance is accurate.
  • Blocking relationship: none.

What was done

Restructured how key-wallet reconciles a transaction record's net_amount against observed spends, replacing an after-the-fact compensation mechanism (which mutated a TransactionRecord that an orthogonal finalization/pruning subsystem could remove out from under it) with two structurally sound orderings:

  • Spend-first (born correct): record_transaction/update_utxos now check the wallet-level observed-spent set at construction time and never insert already-spent value in the first place.
  • Funding-first (declarative recompute): when a spend is observed for a coin whose funding record is still live, the record's net_amount/output_details are recomputed declaratively (idempotent, safe to re-run), and left alone (no-op) if the record has already been pruned/finalized — that state is out of scope for history by design, identical to any other finalized coin.

Also pins, with regression tests, that history_net == balance.total() is not a system invariant for pruned/finalized records under the default (keep-finalized-transactions = OFF) feature configuration — only balance is guaranteed correct there.

Fixes a related latent bug found during investigation: AddressPool::prune_unused failed to clear the corresponding script_pubkey_index entry when pruning an address (currently unreachable in production — no callers — but fixed before anything wires it up).

Follow-up work from review (post-initial-submission, addressing findings from an independent review pass):

  • Tightened visibility on ManagedAccountRefMut::record_transaction/confirm_transaction to pub(crate) — these are internal reconciliation hooks, not part of the crate's public surface.
  • Cleaned up test module naming/structure and deduplicated shared test-block-building helpers into key-wallet/src/test_utils/blocks.rs and key-wallet-manager/tests/common/mod.rs.
  • Bounded ManagedWalletInfo::observed_spent_outpoints, which the original design left as permanent/one-way (an unbounded-growth risk raised in review). Added finality-boundary pruning: an entry is safe to evict once its height is <= min(last_applied_chain_lock.block_height, synced_height), since ChainLock finalization durably short-circuits every resurrection path at that point. Naive LRU/age-based eviction was considered and rejected — it could silently reopen bug: out-of-order block processing causes SPV wallet to miss UTXO spends #649 for evicted entries; the finality boundary is the only eviction rule proven safe against the fix this PR makes.
  • Closed a resurrection gap this pruning introduced (found in adversarial QA): synced_height acts as a certificate that "every filter ≤ h was matched against this wallet's script set," but that certificate is relative to the account set at scan time — adding an account after the fact silently grows the set without invalidating the certificate, letting a previously-pruned coin resurrect. Fixed by rewinding synced_height to birth_height - 1 on every account-addition path, plus a dash-spv commit-time contiguity guard so an in-flight filter batch can't clobber the rewind. Verified against multi-wallet, back-to-back-add, and boundary-exact adversarial scenarios.

Testing

  • cargo test -p key-wallet -p key-wallet-manager -p dash-spv (default features): all passing, including new regression coverage for finality-boundary pruning and the account-addition rewind/contiguity fix.
  • cargo test -p key-wallet -p key-wallet-manager --features key-wallet-manager/keep-finalized-transactions: passing.
  • cargo clippy -p key-wallet -p key-wallet-manager -p dash-spv --all-features --all-targets -- -D warnings: clean.
  • cargo +nightly fmt --check: clean.
  • Downstream consumers (dash-spv, key-wallet-ffi) build clean against the changed internal signatures.
  • Independently reviewed and re-verified across multiple rounds of adversarial review (test execution, security, structural/plan-fidelity, architecture) with zero outstanding critical/high findings on the final state.

Breaking changes

None — internal-only changes to key-wallet/key-wallet-manager/dash-spv; no public API signature changes reach downstream crates.

Follow-ups tracked separately (out of scope for this PR)

Checklist

  • Tests added/updated
  • cargo fmt / cargo clippy clean
  • Default and keep-finalized-transactions feature configurations both covered locally
  • CI green (pending)

Attribution

🤖 Co-authored by Claudius the Magnificent AI Agent

lklimek and others added 21 commits July 7, 2026 16:04
…nd-before-funding defect

Forensically traced (not guessed) from a real backend-e2e failure log
against live Dash testnet:
/data/tmp/backend-e2e-tc004-coldstart-JyD9Vy.log shows a real spend at
height 1,474,746 processed at 08:45:45.155707Z with the spending
transaction logged as `sent=0 DASH` (the wallet did not yet recognize the
input as its own), and the transaction that created that same UTXO, at
the earlier height 1,474,688, processed 0.87 seconds LATER at
08:45:46.028186Z — and inserted as a fresh, spendable UTXO despite its own
spend having already been observed. That UTXO was later selected as
require_final_inputs-eligible asset-lock funding and rejected by the real
network (FinalityTimeout) because it was already spent.

This test reproduces the exact same order — process the spending block,
then the funding block — synthetically and deterministically (no
mnemonic, no network, ~0.07s), via key-wallet-manager's public
WalletInterface::process_block_for_wallets, matching the style of the
existing spv_integration_tests.rs in this crate.

Result: RED. `update_utxos`'s own is_outpoint_spent guard
(managed_core_funds_account.rs) — which exists specifically to handle
"out-of-order block processing during rescan" per its own comment — does
not prevent the funding UTXO from being (re-)inserted as tracked/spendable
once its spend has already been observed.

Relationship to the CoinJoin gap-limit precedent already on this branch
(coinjoin_gap_discovery_tests.rs): related in theme (rescan reordering
breaking wallet-state invariants) but a different specific mechanism —
that precedent's RED case is a CROSS-batch defect (a committed batch's
funding output is never rescanned once its address is derived later);
this is an INTRA-batch ordering defect (funding and spend land in the
SAME committed batch, 1474001-1479000 per the real log, but get processed
in the wrong order and the guard meant to compensate does not hold).

cargo check / clippy --all-features -- -D warnings / +nightly fmt --check
all clean. Actually run and confirmed red twice (before and after fmt).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
… UTXO leak (#649)

An out-of-order rescan can deliver a spend before the transaction that funds
the coin it spends. The old `is_outpoint_spent` guard lived per-account inside
`update_utxos` and only fired when the spend was attributable to the owning
account — so a spend seen first (funding not yet inserted), or a spend routed
away from the owning account by transaction-type narrowing, left the funding
UTXO permanently tracked and selectable, which the real network then rejected
as already-spent (FinalityTimeout).

Fix: a wallet-level, classification-independent, persisted record of every
outpoint observed spent in a processed block (`observed_spent_outpoints:
BTreeMap<OutPoint, CoreBlockHeight>` on `ManagedWalletInfo`). In
`check_core_transaction`, for block contexts only (`InBlock` /
`InChainLockedBlock`):

- record every input un-gated by relevance or classification (insert-only, so a
  matched spend still builds its `input_details` from the live funding UTXO);
- on the unattributed short-circuit path, drop any coin the spend consumes from
  whichever funding account holds it (covers the mirror / cross-account-type
  routing case);
- after processing matched accounts, reconcile freshly-inserted outputs against
  the observed set, dropping any coin whose spend was seen earlier at a higher
  height (the spend-first ordering).

Mempool / InstantSend-only spends are never recorded and self-heal on
confirmation. The set is permanent and one-way (no removal/rollback path) — the
accepted mirror-image trade documented on the field. Persisted via a
`(OutPoint, height)`-pair serde adapter because `OutPoint` is not a valid JSON
map key, with `#[serde(default)]` for backward-compatible loads.

Turns the committed RED repro green and adds a full suite covering all 19 QA
scenarios (persistence, bounded growth, cross-account-type routing, dual
bookkeeping, multi-wallet isolation, idempotent re-delivery, permanent-by-design
reorg shape). The two existing regression guards and record-detail tests stay
green; `input_details` are preserved by the insert-only recording order.

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

QA verification (Marvin): the existing many_orphaned_spend_blocks_stay_bounded_and_linear
stress test spreads growth across 2,000 separate block deliveries (3 inputs each).
process_block_for_wallets iterates every tx in block.txdata unconditionally, so the
real worst case is transactions-per-matched-block, not block count. This test builds
one Block with 5,000 unrelated 3-input transactions plus one wallet-relevant spend and
confirms: (a) the spend is still correctly reconciled when buried in a large noisy
block, and (b) observed_spent_outpoints grows by the block's total input count
(15,002 entries from ONE block in ~20ms debug / ~3ms release), not just the relevant
tx's inputs — the true growth driver the dev plan's "single-digit MB" estimate did not
measure directly.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014Vq23cSxwwL4zSimo491Dr
…649 guard removals; polish

Follow-up polish on the #649 out-of-order guard.

MUST-FIX (history/balance divergence): when the guard removes a UTXO whose spend
was observed but never recorded (the spend looked irrelevant, `sent = 0`), the
funding transaction's `TransactionRecord` still credited `+funding_value` while
the recomputed balance was zeroed — so `transaction_history()` showed a phantom
"received" against a zero balance. Both orderings were affected: the spend-first
reconcile path and the funding-first un-attributed-spend path (a spend routed
away from the owning account by tx-type narrowing). Fix: `compensate_removed_utxo`
reverses the removed value from the funding record and drops it when the tx is
left with no net effect and no surviving wallet output; the caller re-syncs the
emitted `new_records` from the post-reconciliation account state. Invariant now
tested both ways: `transaction_history()` net == `balance.total()`.

Also in this pass:
- SEC-003: cap `observed_spent_outpoints` deserialize at MAX_OBSERVED_SPENT_OUTPOINTS
  via a streaming visitor, so a corrupted/hostile wallet file cannot force an
  unbounded allocation on load.
- Correct the serde-adapter rationale: `OutPoint` *is* a valid JSON map key; the
  pair-form adapter exists for format-agnosticism (bincode/`platform_value`
  encode `OutPoint` as a struct, unusable as a map key). Fixed in both the
  production doc and the test helper doc.
- Hoist `all_funding_accounts_mut()` out of the per-input/per-output loops in
  `remove_spent_from_accounts` and `reconcile_inserts_with_observed_spends`.
- Document (and test) that reconciliation is intentionally NOT gated to block
  context: the observed set only holds block-confirmed spends, so a mempool
  funding must still be reconciled away.
- Fix the reconcile doc: it is a plain set-membership lookup, not a height
  comparison.
- Reword the growth/pruning note (driver is transactions-per-matched-block, a
  documented no-pruning limitation) and drop the overclaimed "survives restart"
  wording in favor of order-independent correctness within one processing run.

TDD: the two history-vs-balance tests were confirmed RED without the
compensation. key-wallet 540 + observed-guard module 20 tests green; clippy
(`-D warnings`) and `cargo +nightly fmt` clean; default and no-serde builds
compile.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…ng (QA-001/QA-002)

Two regressions in the compensation logic from the previous commit, both rooted
in the guard removals never fully mimicking the (unrecorded) spend they stand in
for.

QA-001 (HIGH) — reprocessing double-compensates, drives net_amount negative:
`confirm_transaction` re-runs `update_utxos` on every reprocessing (rescan /
duplicate block delivery, exercised by this repo's own rescan integration test).
The guard's `account.utxos.remove` never registered the outpoint in the
account-local `spent_outpoints` set that `update_utxos`' `is_outpoint_spent`
guard checks, so the invalidated output was resurrected and reconciliation
subtracted its value a second time (e.g. 500,000 -> -1,500,000 for a two-output
funding with one output spent). Fix: `mark_outpoint_spent` registers the removed
outpoint locally so `update_utxos` will not re-insert it; and compensation is now
idempotent — an output's `output_details` entry marks "not yet compensated", so a
re-removal (including across a serialize/deserialize, where the derived local set
is rebuilt from recorded transactions and omits the unrecorded spend) is a no-op
for the record.

Fully-compensated records are now KEPT at net 0 rather than deleted: deleting one
made a reprocessing look `is_new`, which — with the coin now marked spent so
`update_utxos` won't re-insert it — re-recorded a positive net with no coin to
reconcile it back, reopening the divergence. A kept net-0 record is both
history-consistent and reprocess-safe.

QA-002 (MEDIUM) — output_details desynced from net_amount: after a partial
compensation `net_amount` reflected only the surviving output but
`output_details` still listed the removed output as Received at full value.
Compensation now drops the removed output's `output_details` entry too, so
per-output line items agree with the aggregate.

`compensate_removed_utxo` renamed to `finalize_guard_removed_utxo` to reflect the
broadened responsibility (mark-spent + idempotent record/output compensation).

TDD: QA-001 (`-1,500,000`) and QA-002 reproduced RED first, plus a
fully-compensated-reprocess case. key-wallet 543 tests green (observed-guard
module 23); clippy (`-D warnings`) and `cargo +nightly fmt` clean; default and
no-serde builds compile.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Independently verifies 0185922's idempotent-compensation fix and adds a
regression test for a newly found divergence:

- reprocessing_partially_compensated_funding_stays_idempotent_across_many_replays:
  idempotency holds across 4 replays, not just one retry.
- all_outputs_independently_observed_spent_reaches_net_zero_and_stays_consistent:
  a multi-output tx where every output is independently observed spent
  converges to net 0 and stays stable on reprocess.
- compensation_survives_simulated_reload_across_two_restart_cycles (+
  ManagedCoreFundsAccount::simulate_reload_rebuild_spent_outpoints, #[cfg(test)]):
  confirms the output_details compensation marker survives a simulated
  save/reload (account-local spent_outpoints rebuilt the way Deserialize
  does), across two restart cycles — the persisted wallet-level
  observed_spent_outpoints, not the transient local mark, is what makes
  this hold.
- spend_first_ordering_with_chainlocked_first_sighting_diverges_history_from_balance
  (FAILING, default features): when a funding tx's first sighting is
  already InChainLockedBlock (the normal case for a full historical
  rescan), record_transaction drops the record to finalized_txids before
  reconcile_inserts_with_observed_spends can compensate it. Compensation
  silently no-ops on the missing record, so transaction_history() omits
  the transaction entirely while balance.total() still reflects the
  surviving output — history_net() != balance.total(), the exact
  invariant this whole #649 fix chain exists to protect. Reproducible only
  under default features; --all-features masks it by force-enabling
  keep-finalized-transactions, which is off by default in every shipping
  build (key-wallet-ffi, dash-spv-ffi's production dependency).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014Vq23cSxwwL4zSimo491Dr
…ed records (#649 B1)

Per the #649 restructure architecture decision: history_net == balance.total()
is not a system invariant under default features (keep-finalized-transactions
= OFF) for ANY chainlocked-and-pruned funding, independent of #649. Adds a
documenting test for the default-feature divergence and a mirror test proving
the equality DOES hold when keep-finalized-transactions keeps records live —
so a future reviewer does not re-file the pruning-driven divergence as a
regression.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…txos (#649 B2)

Plumbing-only step of the #649 restructure: `ManagedCoreFundsAccount::
update_utxos`/`record_transaction`/`confirm_transaction` and the
`ManagedAccountRefMut` dispatch now take the wallet-level
`observed_spent_outpoints` view as a read-only parameter from
`check_core_transaction`, which already owns it. No behavior change —
`update_utxos` does not yet consult its parameter. Prepares the
check-before-insert change (B3).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Spend-first ordering is now dissolved at the source instead of patched
after the fact: `update_utxos` skips inserting a UTXO whose outpoint is
already in `observed_spent_outpoints`, and `record_transaction` (via the
new `TransactionRecord::compensate_for_observed_spends`) drops such
outputs from `output_details`/`net_amount` before the record is ever
inserted. The record and UTXO set are born correct, so the post-insert
`reconcile_inserts_with_observed_spends` reconciliation pass — and its
result-resync in `check_core_transaction` — has nothing left to do and
is deleted.

`compensate_for_observed_spends` recomputes declaratively (from current
output_details + input_details) rather than subtracting incrementally,
so it is naturally idempotent; `finalize_guard_removed_utxo` (funding-
first ordering) still uses the old incremental form and is updated to
the same declarative helper in the next commit (B4).

All 544 previously-passing key-wallet/key-wallet-manager tests remain
green under default features — the born-correct value matches the old
after-the-fact compensated value bit-for-bit for every existing scenario.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…#649 B4)

Replaces `finalize_guard_removed_utxo`'s incremental net_amount subtract
and output_details-presence idempotency marker with a call to
`TransactionRecord::compensate_for_observed_spends` (added in B3): the
funding-first ordering (an already-live record whose coin is later
guard-removed by an unattributed spend) now recomputes output_details/
net_amount declaratively from the current record state every time,
instead of subtracting a delta once. Idempotent by construction, so
repeated or replayed guard removals can never double-compensate or
drift — no marker needed. A no-op when the record has been pruned
(default `keep-finalized-transactions = OFF`): β is undefined for a
pruned record regardless of this guard.

`remove_spent_from_accounts` threads the wallet-level
`observed_spent_outpoints` view through to the recompute. Both
`key-wallet`/`key-wallet-manager` test suites stay green (544 passed,
1 known pre-existing failure fixed in the next commit) and the #649
repro (`out_of_order_spend_repro_test.rs`) stays green throughout.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…ords (#649 B5)

The previously-failing `spend_first_ordering_with_chainlocked_first_sighting_
diverges_history_from_balance` asserted history_net == balance.total() after
a chainlocked-first-sighting funding — an equality that was never a system
invariant for pruned records (see B1). Split into two feature-scoped tests:

- Under default features: the record is born correct (B3) then pruned to
  finalized_txids; history_net is 0 while balance reflects the live coin.
  Documented as the general pruning non-invariant, not a #649 defect.
- Under keep-finalized-transactions: the record stays live, was born correct
  at construction, and β holds — confirming the default-feature divergence
  is purely a pruning artifact, not an unclosed #649 gap.

key-wallet: 545 passed / 0 failed (default), 543 passed / 0 failed
(keep-finalized-transactions). key-wallet-manager: all green including the
#649 repro. clippy clean under both configs.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…#649 B6)

Self-review pass: record_transaction's doc comment described history
("consulted starting in the check-before-insert change that follows this
plumbing commit") instead of present behavior. Points at
compensate_for_observed_spends directly, which is called a few lines below.

Final B6 verification: key-wallet + key-wallet-manager green under default
features (545 passed) and --features keep-finalized-transactions (543
passed); clippy --all-features --all-targets -D warnings clean; cargo
+nightly fmt --check clean; downstream dash-spv and key-wallet-ffi build
clean against the changed (still-unreleased) ManagedAccountRefMut API.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
… sum (RUST-001/SEC-02)

QA (Adams RUST-001, converged with Smythe SEC-02): compensate_for_observed_spends
recomputed net_amount unconditionally as sum(surviving Received/Change
output_details) - sum(input_details), silently replacing the authoritative
account_match.received - account_match.sent basis for EVERY record, not just
#649-affected ones. Two concrete divergences: an output matched by script
(contains_script_pub_key) but unresolved to a tracked address is counted by
account_match.received but invisible to output_details; account_match.sent can
be nonzero while input_details is empty (partial rescan, per the existing
comment at managed_core_funds_account.rs).

Fix: compensate_for_observed_spends now only ever subtracts a delta — the
value of output_details entries newly found in observed_spent — from
whatever net_amount currently is, and never re-derives the base from
output_details/input_details sums. Since record_transaction seeds net_amount
from account_match.received - account_match.sent before calling this helper,
the account_match-anchored value survives untouched whenever nothing is
excluded (no threading of account_match through the helper needed). Still
idempotent by construction: retain() permanently removes a compensated
entry, so a repeat call for the same output finds nothing left to subtract.

Explicitly did NOT apply an `if observed_spent.is_empty() { return }` guard:
observed_spent_outpoints is permanent/monotonic (no reorg-removal path
anywhere in key-wallet/key-wallet-manager), so after a wallet's first
observed spend that guard would never fire again for the wallet's lifetime.

Adds two regression tests (observed_spent empty, no #649 spend at all):
(a) an output matching by script but failing address resolution (forced via
direct AddressPool field manipulation, isolated from the separate
prune_unused desync bug), (b) a partial-rescan case with account_match.sent
> 0 and empty input_details (constructed via a direct record_transaction
call with a stale account_match, mirroring the existing backfill-test
pattern). Both assert net_amount == account_match.received - account_match.sent.

547 passed / 0 failed (default features), 545 passed / 0 failed
(keep-finalized-transactions); clippy --all-features --all-targets -D
warnings clean; +nightly fmt clean.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…+ idempotency

Independently reconstructs decision-doc gap #5 (async apply_chain_lock
prune racing an out-of-order unattributed spend) as a new integration
test — passes, confirming the restructure's "recognized as β-undefined
for pruned records, not a defect" closure claim rather than assuming it.

Adds a white-box unit test directly exercising
TransactionRecord::compensate_for_observed_spends twice (plus a third
call with an expanded observed-spent set) to prove the "declarative,
not incremental" idempotency claim in code, not just by trusting the
doc comment.

QA verification only — no production code changed.
…aming (DOC-001..006)

Adams' docs review of the #649 restructure: several touched comments sat on
private/pub(crate) items well over the internal ≤3-line budget, and narrated
the *removed* incremental-compensation approach by contrast instead of
describing present behavior — both against coding-best-practices. Applies
her proposed rewrites:

- update_utxos (DOC-001): 6 lines -> 2, drops "instead of needing
  after-the-fact compensation" contrast.
- compensate_for_observed_spends (DOC-002): re-tightened for the RUST-001
  fix's new delta-based implementation (already rewritten in the prior
  commit; trimmed further here).
- record_transaction's #649 inline comment (DOC-003): 5 lines -> 2.
- wallet_checker.rs's #649 block comment (DOC-004): 11 lines -> 3, drops
  "no longer needs a post-insert reconciliation pass" history framing.
- gap-4 test docs (DOC-005): drop the "(B3)" plan-step label (meaningless
  outside the uncommitted decision doc) from both the default-feature and
  keep-finalized-transactions test doc comments.
- finalize_guard_removed_utxo / remove_spent_from_accounts (DOC-006): trim
  ~20/~16 lines to ~10/~3, drop the "not an incremental subtract" contrast.

Also corrects three comments left stale by the RUST-001 fix (not part of
Adams' review, since it predates that fix): the cherry-picked idempotency
test's and gap-5 test's doc comments described compensate_for_observed_spends
as "recomputing" from output_details/input_details, which the RUST-001 fix
replaced with a delta-subtract from the current net_amount.

No behavior change. 549 passed / 0 failed (default), 546 / 0
(keep-finalized-transactions); clippy --all-features --all-targets -D
warnings clean; +nightly fmt clean.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
… prune_unused

Marvin's LOW finding (discovered chasing SEC-02): AddressPool::prune_unused
removed a pruned address from address_index but left its scriptPubkey behind
in script_pubkey_index — a stale script->index mapping that would make
contains_script_pub_key keep matching an address the pool otherwise no
longer resolves. prune_unused has zero production callers today, so this was
dormant, but it is a pub fn (part of the crate's API contract) and a
one-line fix, worth landing before anything is ever wired up to call it.

No behavior change to any current caller (there are none); no existing test
exercised prune_unused. 549 passed / 0 failed (default), 546 / 0
(keep-finalized-transactions); clippy --all-features --all-targets -D
warnings clean; +nightly fmt clean.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…1 — investigated, no live defect

Marvin's exhaustive empirical follow-up (Addendum 2, /tmp/claudius-649-restructure-marvin-report.md)
tested all three of Adams's RUST-001 divergence scenarios directly rather than
theorizing about them, and found no reachable trigger for the concern the
prior commit (c2184f3) fixed:

- Bare P2PK (scenario 1): unreachable by two independent mechanisms —
  AddressType has no P2PK variant so it's never registered into
  script_pubkey_index, and a funding tx to a real P2PK script isn't even
  recognized as account-relevant (check_transaction_for_match returns None),
  so it never reaches record_transaction.
- Partial-rescan sent/input_details mismatch (scenario 2): unreachable —
  sent's one assignment site and input_details' loop use the identical
  predicate over the same account state with no mutation in between; they
  cannot diverge as the (stale/defensive) code comment implies.
- Funds vs. keys formula difference (scenario 3): true but deliberate — keys
  accounts are documented thin markers with permanently-empty input/output
  details; #649's premise doesn't apply to them.

The only demonstrated live trigger for the whole RUST-001/SEC-02 concern is
the dormant AddressPool::prune_unused desync (fixed separately, one-liner).
Once that lands, there is no evidence of a reachable path where net_amount
diverges from account_match. Touching the core accounting formula to guard
against an unreached path is exactly the kind of change that should earn its
risk rather than get it for free — so it's reverted rather than kept
"just in case."

Restores the original declarative-recompute compensate_for_observed_spends
(net_amount recomputed from surviving output_details/input_details, never a
delta) and removes the now-superseded net_amount_source_of_truth_tests.rs.
Also reverts the three comments the prior commits had updated to describe
the delta-subtract mechanism (the idempotency test's and gap-5 test's doc
comments, and finalize_guard_removed_utxo's doc) back to accurately
describing recompute, and re-applies Adams's exact DOC-002 proposed text
(dropped in the interim by the now-reverted refactor).

RUST-001 is closed as "investigated, no live defect found" (pinned by
Marvin's ported regression suite in the next commit), not "fixed" — a valid
QA outcome per team-lead. 547 passed / 0 failed (default features); clippy
--all-features --all-targets -D warnings clean; +nightly fmt clean.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…urce-of-truth shift

Adds 4 tests requested by Smythe's security pass on the #649 restructure:

- 3 regression tests pinning net_amount == account_match.received -
  account_match.sent (the pre-restructure formula) when observed_spent is
  empty, across incoming/outgoing/self-transfer flows — confirms B3's
  unconditional compensate_for_observed_spends call doesn't silently change
  the common-case value.
- 1 adversarial test constructing Smythe's hypothesized divergence
  condition directly: forces an AddressPool desync via prune_unused (which
  clears address_index but leaves script_pubkey_index stale — a
  pre-existing, #649-unrelated gap, reachable via prune_unused's public API
  even though it currently has no production callers), funds an output to
  the desynced address alongside a normal one, and confirms: the OLD
  account_match-derived formula would have over-counted a value the wallet
  never actually held as a spendable UTXO, while the NEW recompute agrees
  with balance. The restructure doesn't introduce a new spendable-fund
  miscount here — it happens to resolve a pre-existing mismatch instead of
  preserving it.

QA verification only — no production code changed.
…unreachable

Follow-up to Adams's independent RUST-001 finding (net_amount source-of-truth
shift, same root cause as Smythe's SEC-02). Adds 2 tests attempting Adams's
two concrete scenarios with observed_spent empty, isolating from #649 entirely:

- scenario1_bare_p2pk_is_structurally_unreachable: builds a real P2PK script
  from the account's own derived pubkey and confirms contains_script_pub_key
  never matches it (AddressType has no P2PK variant; the pool only ever
  registers P2PKH scripts) — the transaction isn't even recognized as
  relevant, so it never reaches record_transaction. The underlying general
  class Adams illustrates with P2PK is real, just already demonstrated via a
  different, genuinely constructible trigger (see
  pruned_address_script_desync_does_not_under_report_relative_to_balance).

- scenario2_sent_and_input_details_cannot_diverge_in_current_code: confirms
  account_match.sent (account_checker.rs, the single assignment site) and
  record_transaction's input_details both derive from the identical
  self.utxos.get(&input.previous_output) predicate on the same account
  instance with no intervening mutation — so sent > 0 cannot occur with
  empty input_details in the current code. The cited code comment's
  rationale doesn't correspond to a live divergence today.

QA verification only — no production code changed. Fixed one clippy
unnecessary-get-then-check flagged in my own test before committing.
…ect field removal

Marvin's cherry-picked pruned_address_script_desync_does_not_under_report_
relative_to_balance (fbcbdf32) constructed its script/address desync by
calling AddressPool::prune_unused — but this branch's earlier prune_unused
fix (6217f1f, already landed when this test was cherry-picked) closed
exactly that gap, so prune_unused no longer leaves the desync behind and the
test's own setup assertion started failing immediately after the cherry-pick.

Ports the test's intent (not its now-invalid setup mechanism): constructs the
same address_index/script_pubkey_index desync via direct field removal
(`external_pool.address_index.remove(...)`), the same technique already used
elsewhere on this branch for the equivalent construction. All downstream
assertions (account_match divergence, balance, net_amount, the three-way
comparison) are unchanged — only the now-defunct prune_unused-based setup and
its doc comment's description of the (now-fixed) mechanism are updated.

553 passed / 0 failed (default features), 550 / 0
(keep-finalized-transactions); clippy --all-features --all-targets -D
warnings clean; +nightly fmt clean.

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

coderabbitai Bot commented Jul 7, 2026

Copy link
Copy Markdown
Contributor

Important

Review skipped

Draft detected.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: e9a3957d-70f2-473b-8d34-e17f15e0dffd

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/key-wallet-out-of-order-utxo-spend-649

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.

@codecov

codecov Bot commented Jul 7, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 88.40125% with 37 lines in your changes missing coverage. Please review.
✅ Project coverage is 73.92%. Comparing base (0ae8df4) to head (be6e776).

Files with missing lines Patch % Lines
key-wallet/src/wallet/managed_wallet_info/mod.rs 71.73% 26 Missing ⚠️
.../src/managed_account/managed_core_funds_account.rs 82.35% 6 Missing ⚠️
...src/wallet/managed_wallet_info/managed_accounts.rs 33.33% 4 Missing ⚠️
key-wallet/src/managed_account/address_pool.rs 0.00% 1 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##              dev     #851      +/-   ##
==========================================
+ Coverage   73.81%   73.92%   +0.11%     
==========================================
  Files         326      326              
  Lines       74365    74665     +300     
==========================================
+ Hits        54890    55199     +309     
+ Misses      19475    19466       -9     
Flag Coverage Δ
core 77.09% <ø> (ø)
ffi 48.85% <ø> (+<0.01%) ⬆️
rpc 20.00% <ø> (ø)
spv 91.16% <100.00%> (+0.21%) ⬆️
wallet 73.63% <83.25%> (+0.16%) ⬆️
Files with missing lines Coverage Δ
dash-spv/src/sync/filters/manager.rs 97.73% <100.00%> (+0.23%) ⬆️
...-wallet/src/managed_account/managed_account_ref.rs 55.72% <100.00%> (+0.46%) ⬆️
...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%) ⬆️
key-wallet/src/managed_account/address_pool.rs 67.01% <0.00%> (-0.09%) ⬇️
...src/wallet/managed_wallet_info/managed_accounts.rs 35.34% <33.33%> (+6.64%) ⬆️
.../src/managed_account/managed_core_funds_account.rs 77.88% <82.35%> (+0.02%) ⬆️
key-wallet/src/wallet/managed_wallet_info/mod.rs 68.72% <71.73%> (+2.33%) ⬆️

... and 9 files with indirect coverage changes

lklimek and others added 6 commits July 8, 2026 07:27
`mis-processed` tokenized to a standalone `mis`, which the typos
dictionary flags as a likely misspelling of `miss`/`mist`. Reword to
avoid the hyphenated prefix; no behavior or test change.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
Cleanup, hygiene, and API-surface fixes on top of the out-of-order UTXO
spend reconciliation (#649). Core reconciliation logic
and its assertions are unchanged.

- PROJ-001: demote `ManagedAccountRefMut::{record_transaction,
  confirm_transaction}` to `pub(crate)` — both gained a required parameter
  and have no external callers (verified workspace-wide), so this closes the
  unintended semver break.
- CODE-001: rename `sec02_*`/`gap5_*` test files, and rewrite review-session
  narrative (finding IDs, reviewer personas, machine-local log paths,
  cross-repo doc refs) into present-tense comments citing #649.
- CODE-002: extract shared `rebuild_spent_outpoints` helper (Deserialize +
  test reload) and consolidate duplicated test helpers into
  `key-wallet/src/test_utils/blocks.rs` and `key-wallet-manager/tests/common`.
- RUST-002/PROJ-002: make the observed-spent-set growth/pruning and
  `#[serde(default)]` backward-compat docs honest.
- RUST-003: rewrite the `has_inputs` comment to state the actual invariant.
- RUST-004: release build reservations in the guard-removal path
  (`finalize_guard_removed_utxo`), matching `update_utxos`; add a regression
  test proving immediate release (not via TTL).
- RUST-005: convert CI-flaky wall-clock timing asserts into `eprintln!`
  diagnostics.

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

Phase 1 of the RUST-002 design: replace the insert-only, permanent-forever
`observed_spent_outpoints` set with bounded permanence. Entries are evicted
only at or below the finality boundary `min(last_applied_chain_lock height,
synced_height)` — the point at which the spend is chain-locked (never reorged
out) and any funding transaction has been delivered and finalized, so no
redelivery path can resurrect the coin in either `keep-finalized-transactions`
configuration or across a reload (#649).

Eviction is event-driven (chainlock application, sync-checkpoint commit),
never age- or recency-based: a naive age/LRU eviction is unsafe because it
would drop the cold entries whose funding tx may still arrive out of order.

- `ManagedWalletInfo::prune_finalized_observed_spends`: O(n) `retain` above the
  boundary; no-op until a chainlock defines the boundary.
- Hook it into `apply_chain_lock` (after account promotion + metadata advance)
  and `update_synced_height`.
- Rewrite the field doc: bounded-permanence invariant replaces the
  "not pruned" limitation.
- Replace the permanence pin test with `observed_spend_removal_only_via_
  finality_boundary`; add 6 regression tests (redelivery, across-reload,
  guard-removed survives-reload-then-prunes, no-prune-above-boundary,
  event-driven-not-age-based, self-heal-on-replay).

Phase 2 (reorg retraction of the provisional tier) is out of scope — blocked
on a dash-spv reorg pipeline and a WalletInterface disconnect event.

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

Marvin/QA adversarial regression test for commit 5d7737b's
finality-boundary pruning (#649 follow-up). Prunes
the wallet-level observed_spent_outpoints entry via chainlock +
synced_height advance, THEN adds a brand-new managed account, THEN
delivers the funding tx to that account for the first time (no replay
of the spend block in between).

FAILS as expected: the coin resurrects. Before 5d7737b this scenario
was safe because the guard map was permanent; the finality-boundary
pruning now evicts the only protection available to an account that
didn't exist at prune time, and nothing guarantees the spend block is
replayed to re-guard it. This is a real regression, not the documented
"self-heals on same-rescan replay" residual from the design doc — this
test constructs exactly that residual's shape and shows it does NOT
self-heal without an explicit rescan trigger.

Intentionally left failing (red) to pin the bug for the developer;
does not modify any existing test. See QA report for full findings:
/tmp/claudius-q2TJyL/marvin-pr851-rust002-verify.md

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
QA-001: finality-boundary pruning (5d7737b) was unsound across account
addition. `synced_height` certifies "every filter at or below this height was
matched against the wallet's scripts" — but only for the account set present
when those filters were scanned. `add_managed_*` grew the script set without
invalidating the certificate, so `prune_finalized_observed_spends` consumed a
stale certificate and could evict the only guard a newly-added account had for
a coin spent before it existed, reopening #649 (#649).

Fix the certificate, not the rule (Nagatha design §7):

Part 1 (key-wallet): `ManagedWalletInfo::rewind_sync_checkpoint_for_new_account`
rewinds `synced_height` to `birth_height - 1` on the success path of all six
`add_managed_*` variants. The prune boundary collapses immediately and the sync
layer backfills the new account from birth through the existing per-wallet
rescan machinery. `last_processed_height` and `last_applied_chain_lock` are left
untouched (chain facts, not coverage facts; keeping the chainlock makes the
replayed history arrive born-chainlocked).

Part 2 (dash-spv): commit-time contiguity guard in `try_commit_batches` — a
batch advances a wallet's checkpoint only if the wallet was already certified up
to the batch's start (`synced_height + 1 >= batch_start`). Closes the race where
a batch scanned before the rewind commits afterward and clobbers the rewound
checkpoint forward, permanently cancelling the rescan. Transparent to normal
ascending commits.

Tests: re-scope Marvin's pinning test (its literal assertion specified the
deferred per-account-floor option, not this fix) into
`late_account_finality_boundary_test.rs` — rewind pin, forward-replay
convergence (both feature configs), interrupted-replay non-prunability, and an
all-variants/no-op/no-underflow pin. Extend the boundary-invariant test to cover
account addition. Add dash-spv contiguity-guard tests (mid-flight rewind not
clobbered; guard transparent in normal advance). Rider: rename the stale
`orphaned_spend_never_expires_no_pruning` test to state the current invariant.

The accepted residual is a transient backfill window (funding replayed before
its spend), same shape as the other documented residuals — the durable
resurrection is eliminated. Per-account spendability gating (zero window) and
reorg retraction remain deferred follow-ups.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Two additions probing edge cases the task explicitly asked to attack:

- dash-spv: contiguity_guard_is_per_wallet_not_batch_wide — two wallets in
  the same batch, only one rewound mid-flight (simulating an account add).
  Proves the try_commit_batches contiguity guard has no cross-wallet leak:
  the rewound wallet stays blocked, the untouched wallet still advances in
  the same commit call.

- key-wallet: back_to_back_account_adds_rewind_idempotently — two accounts
  added with no intervening update_synced_height between them. Proves the
  rewind floor (wallet-level birth_height) makes repeated rewinds
  idempotent regardless of sequencing.

Both pass. Full writeup and two new findings (QA-005, QA-006) in
/tmp/claudius-q2TJyL/marvin-pr851-qa001-final-verify.md.

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

Copy link
Copy Markdown
Contributor

Two findings from field-testing this branch (merged with #866)

#866's committed-range rescan re-applies old funding blocks after their spends were already processed, which makes out-of-order application routine — a good stress test for this PR. Tested via the dash-spv CLI against a testnet wallet with heavy CoinJoin history.

The core fix works: without this PR the out-of-order re-application left four denominated coins falsely unspent; with it the final balance is exactly correct.

1. Infinite restart loop when wallet birth is below the checkpoint anchor

The commit-time contiguity guard (wallet_synced_height + 1 >= batch_start) deadlocks when a wallet's birth height sits below the scan anchor. The CLI hardcodes birth = 0 (main.rs:256), so with --start-height 850000 the first batch starts at 850000, the guard refuses every advance, and the tick's "wallet fell behind, restarting scan" path resets the scan forever — the range below the checkpoint has no stored headers/filters, so the rescan the guard defers to can never succeed. Observed: 6,421 restarts in one run, each batch re-scanned ~50×, blocks re-downloaded every cycle. #849's --birth-height flag fixes the CLI side, but the guard should handle birth-below-anchor gracefully (e.g. treat the scan anchor as the coverage floor: advance permitted when batch_start <= anchor) rather than loop.

2. Out-of-order funding transactions are dropped from history entirely

A funding tx recovered after its spends were observed — all of its wallet-relevant outputs already in the observed-spent set — currently produces no TransactionRecord and no detection event: the spend-first path ("never insert already-spent value") makes the tx come out as not-relevant, so it's skipped rather than recorded as a born-fully-spent history entry. In our run 8 such transactions were downloaded and processed but are absent from the wallet's history (balance is exactly right). The PR's carve-out for pruned/finalized records doesn't seem to cover this: these are live recoveries. Suggest the spend-first path still record the transaction with its outputs marked spent, so history stays complete. Happy to share logs and txids.

🤖 Generated with Claude Code

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.

3 participants