Skip to content

fix(key-wallet): preserve UTXO flags across reprocessing - #837

Merged
xdustinface merged 2 commits into
dashpay:devfrom
xdustinface:fix/preserve-utxo-flags-on-reprocess
Jul 6, 2026
Merged

fix(key-wallet): preserve UTXO flags across reprocessing#837
xdustinface merged 2 commits into
dashpay:devfrom
xdustinface:fix/preserve-utxo-flags-on-reprocess

Conversation

@xdustinface

@xdustinface xdustinface commented Jul 2, 2026

Copy link
Copy Markdown
Collaborator

Summary

update_utxos rebuilds each of our outputs with a fresh Utxo::new on every reprocess of a transaction. On the mempool→block transition this silently reset flags that had already been set on an earlier pass, in particular is_instantlocked: a UTXO whose transaction received an InstantSend lock while in the mempool lost the lock flag the moment the transaction was confirmed in a block.

This carries forward the flags that must not regress when an entry for the outpoint already exists:

  • is_instantlocked and is_trusted latch monotonically. An InstantSend lock is permanent for a txid (DIP-0010) and trust only ever settles, so once either is true it stays true.
  • is_locked (the coin-reservation flag, toggled only via lock/unlock) is carried through unchanged rather than being reset.
  • is_confirmed is left freshly derived from the context so a reorg can still downgrade it.

It was latent so far because every current consumer ORs is_instantlocked with is_confirmed, and a confirmed reprocess sets is_confirmed true in the same pass. It is worth fixing on its own since is_instantlocked is now also an input to asset-lock funding selection, and a standalone consumer or a UI badge would surface the wipe.

Testing

test_full_confirmation_lifecycle now asserts the IS-lock flag survives the block-confirmation stage. The assertion fails without the fix and passes with it.

Found during CodeRabbit review of #836.

Summary by CodeRabbit

  • Bug Fixes
    • Preserved wallet UTXO lock and trust state when transactions are reprocessed, so prior status is no longer lost during updates.
    • Continued to derive confirmation status from the latest chain context so reorg changes are reflected correctly.
  • Tests
    • Added an explicit check to ensure InstantSend lock status is retained when a transaction transitions from unconfirmed to confirmed during reprocessing.

`update_utxos` rebuilds each output with a fresh `Utxo::new` on every reprocess, so a mempool→block confirmation silently reset `is_instantlocked` (and `is_trusted` and any coin reservation) back to false. An InstantSend lock is permanent for a txid per DIP-0010 and trust only ever settles, so both now latch monotonically when an entry for the outpoint already exists, and the coin-reservation flag is carried through unchanged. `is_confirmed` stays freshly derived so a reorg can still downgrade it.

Addresses CodeRabbit review comment on PR dashpay#836
dashpay#836 (comment)
@coderabbitai

coderabbitai Bot commented Jul 2, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 204c2b42-cfd7-4678-97eb-7f7637c5ef04

📥 Commits

Reviewing files that changed from the base of the PR and between 55d241b and a74ba77.

📒 Files selected for processing (2)
  • key-wallet/src/managed_account/managed_core_funds_account.rs
  • key-wallet/src/transaction_checking/wallet_checker.rs
✅ Files skipped from review due to trivial changes (1)
  • key-wallet/src/transaction_checking/wallet_checker.rs
🚧 Files skipped from review as they are similar to previous changes (1)
  • key-wallet/src/managed_account/managed_core_funds_account.rs

📝 Walkthrough

Walkthrough

ManagedCoreFundsAccount::update_utxos now preserves selected UTXO flags from any prior entry with the same outpoint while still recomputing is_confirmed. A test adds coverage for the InstantSend lock flag across mempool-to-block reprocessing.

Changes

UTXO Flag Preservation

Layer / File(s) Summary
Preserve state flags across reprocessing
key-wallet/src/managed_account/managed_core_funds_account.rs, key-wallet/src/transaction_checking/wallet_checker.rs
update_utxos carries forward is_instantlocked, is_trusted, and is_locked from prior UTXO entries with matching outpoints, while is_confirmed is still recomputed; a test asserts is_instantlocked persists after confirmation reprocessing.

Estimated code review effort: 2 (Simple) | ~10 minutes

Possibly related issues

Suggested reviewers: llbartekll, ZocoLini

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly summarizes the main change: preserving UTXO flags during reprocessing in key-wallet.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
🧪 Generate 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.

@codecov

codecov Bot commented Jul 2, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 73.36%. Comparing base (417d61d) to head (a74ba77).

Additional details and impacted files
@@            Coverage Diff             @@
##              dev     #837      +/-   ##
==========================================
+ Coverage   73.33%   73.36%   +0.03%     
==========================================
  Files         324      324              
  Lines       72923    72929       +6     
==========================================
+ Hits        53478    53506      +28     
+ Misses      19445    19423      -22     
Flag Coverage Δ
core 76.94% <ø> (ø)
ffi 45.48% <ø> (-0.02%) ⬇️
rpc 20.00% <ø> (ø)
spv 90.67% <ø> (+0.13%) ⬆️
wallet 72.97% <100.00%> (+<0.01%) ⬆️
Files with missing lines Coverage Δ
.../src/managed_account/managed_core_funds_account.rs 77.85% <100.00%> (+0.27%) ⬆️
...-wallet/src/transaction_checking/wallet_checker.rs 99.25% <100.00%> (+<0.01%) ⬆️

... and 8 files with indirect coverage changes

@xdustinface

Copy link
Copy Markdown
Collaborator Author

@CodeRabbit review

@coderabbitai

coderabbitai Bot commented Jul 2, 2026

Copy link
Copy Markdown
Contributor
✅ Action performed

Review finished.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

@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/managed_account/managed_core_funds_account.rs (1)

205-216: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Consider extending test coverage to is_trusted/is_locked preservation.

The companion test in wallet_checker.rs only asserts is_instantlocked survives reprocessing. Since this same merge block also latches is_trusted and carries through is_locked, an assertion covering those paths would guard against future regressions in the same spot that caused this bug.

🤖 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/managed_account/managed_core_funds_account.rs` around lines
205 - 216, The reprocessing merge logic in managed_core_funds_account should be
covered by tests for the additional latched fields. Extend the existing
companion test in wallet_checker.rs (the one that already checks UTXO
reprocessing) to also assert that ManagedCoreFundsAccount’s preservation path
keeps is_trusted latched and carries is_locked through from the prior UTXO,
alongside the existing is_instantlocked check.
🤖 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/managed_account/managed_core_funds_account.rs`:
- Around line 205-216: The reprocessing merge logic in
managed_core_funds_account should be covered by tests for the additional latched
fields. Extend the existing companion test in wallet_checker.rs (the one that
already checks UTXO reprocessing) to also assert that ManagedCoreFundsAccount’s
preservation path keeps is_trusted latched and carries is_locked through from
the prior UTXO, alongside the existing is_instantlocked check.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: ff3f3495-2f29-45e0-a1f5-2fc99fa50a91

📥 Commits

Reviewing files that changed from the base of the PR and between a8a0968 and 55d241b.

📒 Files selected for processing (2)
  • key-wallet/src/managed_account/managed_core_funds_account.rs
  • key-wallet/src/transaction_checking/wallet_checker.rs

@github-actions github-actions Bot added the ready-for-review CodeRabbit has approved this PR label Jul 6, 2026
@xdustinface
xdustinface requested a review from ZocoLini July 6, 2026 11:48

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

I guess there must be a reason, but why are we creating a new structure instead of using the same one

@xdustinface

xdustinface commented Jul 6, 2026

Copy link
Copy Markdown
Collaborator Author

I guess there must be a reason, but why are we creating a new structure instead of using the same one

Im not sure, probably no reason.

@xdustinface
xdustinface merged commit 3170ad3 into dashpay:dev Jul 6, 2026
37 checks passed
bfoss765 added a commit to bfoss765/rust-dashcore that referenced this pull request Aug 5, 2026
…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>
QuantumExplorer added a commit that referenced this pull request Aug 6, 2026
…nspent balance (#649) (#909)

* test(key-wallet-manager): deterministic repro of out-of-order spend-before-funding (#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 #837/#864/#891/#893) — those do not address this defect.

Refs: #649

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

* fix(key-wallet): track wallet-level observed_spent_outpoints to fix #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 #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: #649
Adapted-from: #851

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

* test(key-wallet): cover observed_spent guard branches for #649 patch coverage

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

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

* review fixes: gate net_amount recompute, surface observed-spend state 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-#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>

* review fixes round 2: generation guard for mid-flight account adds, surface guard-rewritten records, restore public account API

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

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

* test(key-wallet): pin born-fully-spent recovered transactions stay in history (#649/#846)

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

Investigation of #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>

* docs(key-wallet): fix private intra-doc links so the Documentation CI job passes

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

* test(key-wallet-manager): parameterize network in the spend_tx test helper

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>

* refactor(key-wallet): reduce #649 fix to the two-piece observed-spend mechanism

Applies ZocoLini's requested simplification (PR #909 review): the #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>

* test(key-wallet-manager): name the out-of-order repro after the invariant 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 #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.

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Co-authored-by: Quantum Explorer <quantum@dash.org>
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.

2 participants