Skip to content

feat(chain)!: taint-aware CanonicalView::balance via canonical ancestor walk#2235

Draft
evanlinjin wants to merge 6 commits into
bitcoindevkit:masterfrom
evanlinjin:feat/canonical-ancestors
Draft

feat(chain)!: taint-aware CanonicalView::balance via canonical ancestor walk#2235
evanlinjin wants to merge 6 commits into
bitcoindevkit:masterfrom
evanlinjin:feat/canonical-ancestors

Conversation

@evanlinjin

@evanlinjin evanlinjin commented Jun 23, 2026

Copy link
Copy Markdown
Member

Warning

This PR — including this description — is currently entirely AI-generated.
It still needs proper human review (by me, @evanlinjin) before it should be taken seriously. Treat everything below as a draft proposal, not a vetted design.

Description

This is an alternative direction to #2221. Where #2221 makes CanonicalView::balance less restrictive by removing the min_confirmations parameter, this PR instead tries to make it more useful — handing control to the caller via closures, deriving trust from ancestry rather than a per-script heuristic, and exposing a per-output spend-eligibility classifier that's the building block for coin control.

The core new primitive is CanonicalView::classify_outpoints, built on a reusable, sans-TxGraph canonical ancestor walk. balance becomes a thin fold over it.

What's here (commit by commit)

1. Add CanonicalAncestors reverse-topological walk. Canonical::ancestors(seeds, map, should_walk) walks the canonical ancestors of a set of seed txids backwards (the seeds are the leaf-most txs, closer to the leaves of the ancestry DAG than its roots). Each tx is visited once in reverse-topological order, folding a Mergeable accumulator from descendants into ancestors (diamonds merge correctly). should_walk prunes per-tx with the ChainPosition available; the iterator is ExactSizeIterator.

2. Add Canonical::ancestors_inclusive. A variant that also yields the seeds (each with its own final accumulator), so a per-seed contribution can be folded uniformly with the ancestry.

3. Taint-aware CanonicalView::balance (breaking). Replaces trust_predicate + min_confirmations with two closures:

  • does_taint(CanonicalTx) -> bool: a pending output is untrusted if it, or any of its unsettled ancestors, taints. The unsettled ancestry is walked once (deduped across outputs, stopping at settled txs) via ancestors_inclusive. This lets callers demote unconfirmed coins received from — or chained on top of — a third party. Trust is now derived from ancestry instead of a per-script heuristic.
  • is_settled(&ChainPosition) -> bool: generalizes the numeric min_confirmations into a caller-defined "confident this won't be replaced" boundary, and is the sole authority on it — a settled output is never dropped or tainted (even an unconfirmed output a caller chooses to treat as settled is counted, not lost).
  • balance also drops its O identifier generic and now takes bare OutPoints.

4. Rename Balance::confirmed to Balance::settled (breaking). The bucket is driven by is_settled (confidence a tx won't be replaced), not strictly confirmation status, so the field is renamed to match. Breaking serde key change (confirmedsettled, no alias).

5. Add classify_outpoints spend-eligibility classifier. CanonicalView::classify_outpoints pairs each unspent output with a chain-level Eligibility (Settled / Immature / TrustedPending / UntrustedPending) from is_settled + the does_taint walk. This is the primitive for coin selection / coin control (e.g. "prefer settled coins, fall back to trusted-pending"): the caller decides how to aggregate and can layer wallet-specific categories like "locked" on top, instead of being constrained to the fixed Balance buckets. balance is re-expressed as a thin fold over it.

Direction / open questions for human review

  • The longer-term idea is that classify_outpoints is the real API and Balance is just one possible fold — wallets that want categories like "locked"/"reserved" aggregate themselves. This PR keeps Balance (as a fold) for now; a follow-up could move it down into bdk_wallet and drop it from bdk_chain.
  • Is replacing trust_predicate with ancestry-based does_taint the right call, or should both coexist? (does_taint can't express per-output trust, but per-tx ancestry is arguably the more principled model.)
  • Balance::confirmedBalance::settled: worth the rename / serde break, or keep the conventional name?
  • bdk_wallet will need its balance call updated (out of this repo's tree).

Notes to reviewers

  • Tests: ancestor-walk tests (diamond merge / reverse-topo order / pruning / exact size), a balance + classify_outpoints ancestry-taint test, and a regression test that a settled-everything is_settled never drops an unconfirmed output. Existing balance tests were migrated (the old per-keychain trust test is recomputed for the new model, since none of its txs actually spend third-party coins).
  • cargo fmt, clippy --all-features --all-targets -p bdk_chain, and RUSTDOCFLAGS="-D warnings" cargo doc are clean; the bdk_chain suite + doctests pass.

Changelog notice

Changed

  • CanonicalView::balance now takes does_taint and is_settled closures instead of trust_predicate and min_confirmations, and accepts bare OutPoints.
  • Renamed Balance::confirmed to Balance::settled.

Added

  • CanonicalView::classify_outpoints + Eligibility: per-output spend-eligibility classification (the building block for coin control).
  • Canonical::ancestors / Canonical::ancestors_inclusive: reverse-topological canonical ancestor walk with a Mergeable accumulator.

🤖 Generated with Claude Code

@codecov

codecov Bot commented Jun 23, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 92.52336% with 16 lines in your changes missing coverage. Please review.
✅ Project coverage is 78.86%. Comparing base (6d03fc3) to head (71c35d9).

Files with missing lines Patch % Lines
crates/chain/src/canonical.rs 87.65% 10 Missing ⚠️
crates/chain/src/balance.rs 0.00% 3 Missing ⚠️
crates/chain/src/canonical_ancestors.rs 97.69% 1 Missing and 2 partials ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##           master    #2235      +/-   ##
==========================================
+ Coverage   78.65%   78.86%   +0.20%     
==========================================
  Files          30       31       +1     
  Lines        5909     6076     +167     
  Branches      279      285       +6     
==========================================
+ Hits         4648     4792     +144     
- Misses       1185     1209      +24     
+ Partials       76       75       -1     
Flag Coverage Δ
rust 78.86% <92.52%> (+0.20%) ⬆️

Flags with carried forward coverage won't be shown. Click here to find out more.

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@evanlinjin evanlinjin force-pushed the feat/canonical-ancestors branch 2 times, most recently from 1fb7787 to 0a9fa74 Compare June 24, 2026 03:11
evanlinjin and others added 4 commits June 24, 2026 03:27
Add `Canonical::ancestors`, returning a `CanonicalAncestors` iterator that
walks the canonical ancestors of a set of seed transactions.

- Ancestors are yielded in reverse topological order (descendants before
  the ancestors they spend) and each transaction is visited exactly once.
- Accumulation is a fold over the ancestor DAG: `map` computes each tx's own
  contribution from its `CanonicalTx`, and a tx's final accumulator is its
  contribution `Merge`d with the accumulators of every in-set descendant.
- `should_walk` prunes the walk per-tx, with the chain position available so
  callers can decide cutoffs (e.g. stop at confirmed).

Seeds are given as txids (looked up in the canonical set) and seed the
accumulation without being yielded.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Add a variant of `Canonical::ancestors` that also yields the seed
transactions (each with its own final accumulator), not just their ancestors.
Pruning, deduplication and the reverse-topological order are unchanged; the
seeds are emitted alongside the walked ancestors.

Useful when the per-seed contribution must be folded uniformly with the
ancestry (e.g. a seed that taints itself), avoiding a separate pass.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Replace `balance`'s `trust_predicate` and `min_confirmations` parameters with
two closures:

* `does_taint(CanonicalTx) -> bool` decides whether a transaction taints its
  descendants. A pending output is `untrusted_pending` if it, or any of its
  unsettled ancestors, taints; otherwise `trusted_pending`. The unsettled
  ancestry of each pending output is walked via `ancestors_inclusive` (deduped
  across outputs, stopping at settled transactions). This lets callers demote
  unconfirmed coins received from (or chained on top of) a third party.
* `is_settled(&ChainPosition) -> bool` decides the confirmed/pending boundary,
  generalizing the old numeric `min_confirmations` (e.g. it can treat
  shallowly-confirmed outputs as pending and taintable).

`does_taint` replaces the per-spk `trust_predicate`: trust is now derived from
ancestry rather than a per-script heuristic. Call sites and tests are updated;
the old per-keychain trust test is recomputed for the new model.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The balance "confirmed" bucket is now driven by the caller-supplied
`is_settled` predicate (transactions we are confident will not be replaced),
not strictly by confirmation status. Rename the field to match the concept and
update `Display`, `Add`, `total`, `trusted_spendable` and all call sites.

This is a breaking change: the serde key changes from "confirmed" to "settled"
(no alias).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Add `CanonicalView::classify_outpoints`, which pairs each unspent output with a
chain-level `Eligibility` (`Settled` / `Immature` / `TrustedPending` /
`UntrustedPending`) computed from `is_settled` and the `does_taint` ancestry
walk. This is the primitive for coin selection / coin control: the caller
decides how to aggregate, and can layer wallet-specific categories (e.g.
"locked") on top — rather than being constrained to the fixed `Balance` buckets.

`CanonicalView::balance` is re-expressed as a thin fold over
`classify_outpoints`, adding each output's value to the `Balance` bucket that
matches its `Eligibility`.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@evanlinjin evanlinjin force-pushed the feat/canonical-ancestors branch from a286ab4 to ab0c5a2 Compare June 24, 2026 05:01
… walk

Close review-identified coverage gaps:

- `test_balance_taint_stops_at_settled_ancestor`: a settled ancestor that
  `does_taint` would flag must not taint its unsettled descendant — isolates the
  `!is_settled` guard in the taint walk.
- `test_classify_immature_and_settled`: `classify_outpoints` returns `Immature`
  for an immature coinbase and `Settled` for a mature output.
- `ancestors_inclusive_yields_seeds`: the inclusive walk yields the seeds (with
  their own accumulators) and `len()` accounts for them.
- `ancestors_multiple_seeds_dedup_shared_ancestor`: an ancestor shared by two
  seeds is visited once.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@evanlinjin evanlinjin force-pushed the feat/canonical-ancestors branch from c9c6f24 to 71c35d9 Compare June 25, 2026 11:37
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

Status: No status

Development

Successfully merging this pull request may close these issues.

1 participant