Skip to content

fix(key-wallet): walk a sweep's descendant closure once instead of per generation - #969

Merged
romchornyi merged 1 commit into
devfrom
fix/sweep-descendant-walk-linear
Aug 19, 2026
Merged

fix(key-wallet): walk a sweep's descendant closure once instead of per generation#969
romchornyi merged 1 commit into
devfrom
fix/sweep-descendant-walk-linear

Conversation

@romchornyi

@romchornyi romchornyi commented Aug 19, 2026

Copy link
Copy Markdown
Contributor

Issue being fixed or feature implemented

drop_conflicted_transactions walks the unconfirmed descendant closure of a sweep's losers with a fixed-point loop: every iteration rescans every retained record and collects only the next reachable generation, extends the loser set, and repeats until nothing is added. A depth-D chain therefore costs about D full scans of H retained records — O(D×H), quadratic when the chain dominates the history.

Neither factor is bounded by anything the wallet controls. A peer can deliver a deep chain of wallet-relevant unconfirmed transactions (each spending the previous one's change) and then a valid finalized replacement for the root's input. The whole cost lands while the account is held mutably, before persistence records the sweep.

Reported during review of dashpay/platform#4406, which pins this crate. Same class as #966, different traversal.

What was done?

Build a parent→children index over the retained records in one pass, then follow a queue from the initial losers, visiting each record once: O(records + edges).

The carve-outs are unchanged and deliberately so — they are load-bearing:

  • confirmed records are never followed (a transaction in a block spent something real), and nothing reachable only through one is swept;
  • InstantSend-locked records are never followed, same for their exclusive descendants;
  • the winner is never a candidate, and an already-collected loser is never re-added (the index makes a diamond join a single visit instead of a re-scan trigger).

Exactly the same records are swept as before. No behaviour change, only the shape of the walk.

How Has This Been Tested?

  • New the_walk_sweeps_the_closure_and_respects_the_carve_outs: a rooted six-deep chain with a diamond join, a confirmed branch, an IS-locked branch (each with an unconfirmed child of its own), and an unrelated record — pins that the chain and the diamond are swept exactly once and every carve-out survivor is retained. This test also passes against the old loop, which is what pins behavioural equivalence.
  • New a_deep_chain_is_swept_with_linearly_many_visits: a 2000-deep chain against 2500 retained records, asserting a deterministic linear visit bound counted by test-only instrumentation (a #[cfg(test)] thread-local counter; the production call compiles to an empty inline function). This is not a timing assertion. Revert-tested: with the old per-generation loop restored, this test fails with 5,000,000 visits against a bound of 7,500 — exactly the D×H shape.
  • cargo test -p key-wallet — 663 passed, 0 failed.
  • cargo clippy --all-features --all-targets -- -D warnings and cargo fmt --check clean.
  • Not verified: no device or live-sync testing.

Breaking Changes

None.

Checklist:

  • I have performed a self-review of my own code
  • I have commented my code, particularly in hard-to-understand areas
  • I have added or updated relevant unit/integration/functional/e2e tests
  • I have added "!" to the title and described breaking changes in the corresponding section if my code contains any
  • I have made corresponding changes to the documentation if needed

For repository code-owners and collaborators only

  • I have assigned this pull request to a milestone

Summary by CodeRabbit

  • Bug Fixes
    • Improved conflict sweeping to consistently process all descendant accounts.
    • Correctly handles closure sweeping, carve-outs, and complex branching account relationships.
    • Improved performance for long account hierarchies by avoiding repeated history scans.

…r generation

`drop_conflicted_transactions` rescanned every retained record once per
generation of the unconfirmed descendant closure: each pass over the
history collects only the next reachable generation, extends the loser
set, and repeats until nothing is added. A depth-D chain therefore costs
about D full scans of H retained records — O(D×H), quadratic when the
chain dominates the history. Neither factor is bounded by anything the
wallet controls: a peer can deliver a deep chain of wallet-relevant
unconfirmed transactions and then a valid finalized replacement for the
root's input, and the sweep runs while the account is held mutably and
before persistence records anything.

Build a parent-to-children index in one pass and follow a queue, so each
record is visited once: O(records + edges). The carve-outs are unchanged
— confirmed records and InstantSend-locked records are never followed,
the winner and already-collected losers are never re-added — so exactly
the same records are swept as before.

Covered by a closure pin exercising the carve-outs and a diamond join,
and by a 2000-deep chain asserting a linear visit bound through
test-only instrumentation: the per-generation rescan counts five million
visits there against a bound of seventy-five hundred.
@coderabbitai

coderabbitai Bot commented Aug 19, 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 Plus

Run ID: 4a6b95b4-0548-4dde-9dad-930417c54538

📥 Commits

Reviewing files that changed from the base of the PR and between e9ef99c and 4963bb7.

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

Included review availability: Your plan provides up to 3 included reviews per hour; 2 remain after this review.


📝 Walkthrough

Walkthrough

Conflict sweeping now indexes parent-child relationships and traverses descendants once with a breadth-first queue. Tests cover conflict closure, carve-outs, diamond graphs, unrelated transactions, and linear visit complexity.

Changes

Conflict sweeping

Layer / File(s) Summary
Indexed descendant traversal
key-wallet/src/managed_account/managed_core_funds_account.rs
The sweep builds a parent-to-children HashMap and uses a VecDeque to visit descendants once. Confirmed, InstantSend-locked, and winning transactions remain excluded.
Traversal instrumentation and validation
key-wallet/src/managed_account/managed_core_funds_account.rs
Test-only visit counting supports tests for chains, diamond-shaped descendants, carve-outs, surviving children, unrelated transactions, and linear traversal complexity.

Estimated code review effort: 3 (Moderate) | ~25 minutes

Merge Risk: ⚪ Minimal · up to 4963b

This PR changes only the traversal strategy while preserving swept records and carve-outs, with targeted tests and standard checks passing. The current title already uses a Conventional Commit prefix, so no actionable merge-blocking risk remains.

Suggested labels: ready-for-review

Suggested reviewers: quantumexplorer, xdustinface

🚥 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 and concisely describes the main change: replacing repeated descendant scans with a single closure traversal.
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 docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/sweep-descendant-walk-linear

Comment @coderabbitai help to get the list of available commands.

@codecov

codecov Bot commented Aug 19, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 76.97%. Comparing base (e9ef99c) to head (4963bb7).

Additional details and impacted files
@@            Coverage Diff             @@
##              dev     #969      +/-   ##
==========================================
+ Coverage   76.92%   76.97%   +0.04%     
==========================================
  Files         329      329              
  Lines       82527    82676     +149     
==========================================
+ Hits        63482    63637     +155     
+ Misses      19045    19039       -6     
Flag Coverage Δ
core 78.25% <ø> (ø)
ffi 52.08% <ø> (ø)
rpc 20.00% <ø> (ø)
spv 91.91% <ø> (+0.03%) ⬆️
wallet 79.05% <100.00%> (+0.12%) ⬆️
Files with missing lines Coverage Δ
.../src/managed_account/managed_core_funds_account.rs 87.77% <100.00%> (+2.93%) ⬆️

... and 6 files with indirect coverage changes

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

Two things I'd note, neither blocking:

  1. The common path now pays for an index it never uses. Building the index lazily on the first non-empty generation would avoid it

  2. The index is O(total inputs) rather than O(records): it keys on every
    previous_output.txid, including txids the wallet does not hold (external
    funding). Still linear, just worth knowing the memory tracks input count, not
    record count

Neither changes the verdict, so go ahead with the squash merge. Worth a look if there's time

#[inline]
fn note_descendant_walk_visit() {
#[cfg(test)]
descendant_walk_instrumentation::VISITS.with(|visits| visits.set(visits.get() + 1));

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 would rather prefer to see this inlined instead of hidden behind a function but its okay

@romchornyi
romchornyi merged commit 5877d15 into dev Aug 19, 2026
38 checks passed
@romchornyi
romchornyi deleted the fix/sweep-descendant-walk-linear branch August 19, 2026 11:31
romchornyi pushed a commit to dashpay/platform that referenced this pull request Aug 19, 2026
dashpay/rust-dashcore#969 merged as 5877d15f, so the pin moves off the
#966 merge commit onto it. The revision replaces the conflict sweep's
per-generation rescan of the whole retained history with a
parent-to-children index built once and a queue traversal that visits
each record exactly once — O(records + edges) instead of O(depth ×
history), which a peer could drive with a deep chain of unconfirmed
wallet-relevant transactions followed by a finalized replacement for
the root input.

Skip semantics are unchanged: confirmed and InstantSend-locked records
are still never followed, the winner is never a candidate, and an
IS-locked initial loser still has its descendants walked.

All eight workspace pins and Cargo.lock move together; no API changed.
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.

3 participants