Skip to content

feat(chain)!: taint-aware CanonicalView::balance - #2246

Open
Dmenec wants to merge 4 commits into
bitcoindevkit:masterfrom
Dmenec:feat/classify-outpoints
Open

feat(chain)!: taint-aware CanonicalView::balance #2246
Dmenec wants to merge 4 commits into
bitcoindevkit:masterfrom
Dmenec:feat/classify-outpoints

Conversation

@Dmenec

@Dmenec Dmenec commented Jul 22, 2026

Copy link
Copy Markdown
Contributor

Description

Takes over #2235 (thanks @evanlinjin for the go-ahead). It reworks CanonicalView::balance to derive trust from an output's unconfirmed ancestry, and adds classify_outpoints, a per-output spend-eligibility classifier that balance becomes a thin fold over.

The old balance decided trust using a per-output trust_predicate, which cannot express transitive trust. As a result, owned outputs whose unconfirmed ancestry included foreign coins were counted as trusted. That is the root cause of the wallet trust-classification bugs (bitcoindevkit/bdk_wallet#16, bitcoindevkit/bdk_wallet#273).

The API now takes two separate predicates, one per concern:

  • does_taint(&tx) - should this transaction be considered tainted? (e.g., because it spends a
    foreign unconfirmed output)
  • is_settled(&pos) - do we consider this chain position settled / final? (generalizes
    min_confirmations)

For each unspent output, classify_outpoints reports its chain-level spend eligibility:

  • Settled if considered settled according to is_settled
  • Immature for a coinbase output that has not yet matured
  • TrustedPending if the output is pending but trusted
  • UntrustedPending if the output itself, or any of its unconfirmed ancestors, is tainted

balance then sums each output's value into the bucket corresponding to its Eligibility.

Notes to the reviewers

  • Trust is resolved through a self-contained per-output ancestry walk. The traversal is memoized: each visited transaction is cached so that already-classified transactions (and their ancestors) do not need to be walked again.
  • Kept Balance::confirmed and did not rename it to settled. That rename is out of scope here. The folding logic is slated to move into bdk_wallet, and the current balance function in chain will eventually be deprecated.
  • balance also drops the O generic and now takes plain OutPoints, since the taint predicate operates on transactions rather than per-outpoint associated data.
  • does_taint is evaluated at most once per transaction.
  • More sophisticated classification rules (e.g., for coin control or locked funds) can be built on top of classify_outpoints. Left for a follow-up.

Changelog notice

  • Breaking: CanonicalView::balance now takes does_taint: impl FnMut(&CanonicalTx) -> bool and
    is_settled: impl Fn(&ChainPosition) -> bool instead of a per-output trust predicate and
    min_confirmations, and plain OutPoints instead of (identifier, outpoint) pairs (this drops
    the O generic). Trust is now derived from an output's unconfirmed ancestry.
  • Added CanonicalView::classify_outpoints and the Eligibility enum.

Checklists

All Submissions:

New Features:

  • I've added tests for the new feature
  • I've added docs for the new feature

Bugfixes:

  • This pull request breaks the existing API

Dmenec and others added 4 commits July 22, 2026 19:32
Adds classify_outpoints, which decides per-output whether it's settled,
immature, or pending (trusted/untrusted) based on its unsettled ancestry.
Taint is resolved with a memoized ancestry walk, so ancestors shared by
several outputs are only walked once.

Co-authored-by: 志宇 <hello@evanlinjin.me>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
balance becomes a fold over classify_outpoints. A pending output's trust is now
taken from its unsettled ancestry (untrusted if any unsettled ancestor taints),
not from a per-output flag.

BREAKING: balance takes does_taint and is_settled instead of trust_predicate
and min_confirmations, and plain OutPoints instead of (identifier, outpoint)
pairs.

Co-authored-by: Dmenec <dmenec@proton.me>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- taint propagates through a pending output's unsettled ancestry
- is_settled alone decides the settled boundary, even for unconfirmed outputs
- taint never crosses a settled ancestor
- an immature coinbase is classified apart from a settled output
- two UTXOs sharing a tainting ancestor are both untrusted (shared taint cache)
- classify_outpoints skips spent and out-of-view outpoints

Co-authored-by: 志宇 <hello@evanlinjin.me>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Times the memoized classification over fan-in, disjoint, untrusted, diamond
and deep-taint-mid-chain graphs at a range of widths and depths.
@codecov

codecov Bot commented Jul 22, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 93.33333% with 7 lines in your changes missing coverage. Please review.
✅ Project coverage is 78.36%. Comparing base (1acb4d0) to head (8897579).

Files with missing lines Patch % Lines
crates/chain/src/canonical.rs 93.33% 4 Missing and 3 partials ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##           master    #2246      +/-   ##
==========================================
+ Coverage   78.32%   78.36%   +0.03%     
==========================================
  Files          30       30              
  Lines        5934     5995      +61     
  Branches      281      282       +1     
==========================================
+ Hits         4648     4698      +50     
- Misses       1210     1221      +11     
  Partials       76       76              
Flag Coverage Δ
rust 78.36% <93.33%> (+0.03%) ⬆️

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.

@Dmenec

Dmenec commented Jul 24, 2026

Copy link
Copy Markdown
Contributor Author

Thinking about some cases where settledness makes a transaction fall into a different Eligibility. With foreign inputs (e.g. a tx that needs 6 min_confirmations to be settled, while the current tip is only 1 block ahead of it) the output ends up as UntrustedPending, which doesn't really make sense: it isn't pending, just not settled. The same happens with owned inputs that falls into TrustedPending.

I think it might be clearer to call them TrustedUnsettled / UntrustedUnsettled rather than *Pending.

@evanlinjin

Copy link
Copy Markdown
Member

@Dmenec Good point. What do you think about this?

pub enum Eligibility {
    Settled,
    Immature,
    Unsettled(Trust),
}

pub enum Trust {
    Trusted,
    Untrusted,
}

I think this increases clarity and makes it a bit easier for call sites that only care about whether it's unsettled or not.

@evanlinjin evanlinjin left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for pushing this forward - this is looking great.

I haven't looked too hard into the tests yet, follow-up reviews will come.

Comment on lines +37 to +39
///
/// This is a *chain-level* classification. It deliberately knows nothing about wallet policies such
/// as locked or reserved coins; callers layer their own categories on top.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
///
/// This is a *chain-level* classification. It deliberately knows nothing about wallet policies such
/// as locked or reserved coins; callers layer their own categories on top.

Let's remove this - I don't think it's useful.

/// as locked or reserved coins; callers layer their own categories on top.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum Eligibility {
/// A confirmed output unlikely to be replated.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
/// A confirmed output unlikely to be replated.
/// A confirmed output unlikely to be replaced.

.into_iter()
.filter_map(|op| self.txout(op))
.filter(|txo| txo.spent_by.is_none())
.collect::<Vec<_>>();

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No need to collect here.

// it once instead of redoing the same work for each outpoint.
let mut cache = HashMap::<Txid, bool>::new();
let results: Vec<_> = utxos
.into_iter()

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can remove this once utxos is an iterator.

Suggested change
.into_iter()

Comment on lines +452 to +456
if txout.is_mature(tip) {
Eligibility::Settled
} else {
Eligibility::Immature
}

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The coinbase maturity check lives inside the is_settled check - this is wrong. Unsettled is not the same as unconfirmed.

Example: A confirmed transaction can be unsettled because the caller requires 3 confirmations to be classified as settled. With the current logic, this transaction will become "pending" instead of "immature".

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.

So any unconfirmed coinbase tx with an owned script pubkey should be treated as Immature, not TrustedPending, even if it doesn't have a single confirmation?

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hopefully there shouldn't be such a thing as an "unconfirmed coinbase" - the canonicalization algorithm should have considered those as non-canonical! If not, let's file a bug.

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.

Not even if you assume it canonical?

@Dmenec Dmenec Jul 29, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Tried it with a coinbase without an anchor (which, as @evanlinjin said, shouldn't be possible on a real chain, but you can still construct it in a test), and if you assume it canonical it does end up in the set as unconfirmed.

let conf_height = match self.pos.confirmation_height_upper_bound() {
Some(height) => height,
None => {
debug_assert!(false, "coinbase tx can never be unconfirmed");
return false;
}

As you can see above, it falls back to false there, so such an output is treated as immature anyway.

I agree with moving the maturity check out of is_settled, so it stays Immature regardless of settledness.

}
}
Frame::Exit(txid) => {
let c_tx = self.tx(txid).expect("present, checked in Enter");

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If we change the signature of Exit to also contain the transaction itself, we can avoid calling self.tx() again.

Comment on lines +515 to +516
// Not canonical
cache.insert(txid, false);

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If a transaction does not exist in the CanonicalView, it just means the wallet has sparse data. Therefore, "not canonical" is incorrect.

We can guarantee that all transactions are canonical at this point as in line 440, we filtered by self.txout().

Proposal

Assuming we introduce the Eligibility::Unsettled(Trust) variant, we can add another variant to the Trust enum: Trust::Unknown.

The cache type will become HashMap<Txid, Trust> where Untrusted beats Unknown and Unknown beats Trusted.

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.

We can guarantee that all transactions are canonical at this point as in line 440, we filtered by self.txout().

You mean we consider txs for which its txid appear as parents of a tx in the CanonicalView as canonical?

Proposal

Assuming we introduce the Eligibility::Unsettled(Trust) variant, we can add another variant to the Trust enum: Trust::Unknown.

The cache type will become HashMap<Txid, Trust> where Untrusted beats Unknown and Unknown beats Trusted.

Do you expect wallets to also handle this category as "unresolved" balance? Or it's just an intermediate category to trigger request for those txids and be able to resolve the whole trustness of the txs chains?

In my mind we will end up considering this as Untrusted because we cannot claim that we know if these are tainted or not.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You mean we consider txs for which its txid appear as parents of a tx in the CanonicalView as canonical?

Yes. However note that CanonicalView is a sparse view as TxGraph itself is also sparse. Therefore, the comment and logic here is wrong since we are visiting ancestors of canonical transactions, so anything we visit must also be canonical.

Do you expect wallets to also handle this category as "unresolved" balance? Or it's just an intermediate category to trigger request for those txids and be able to resolve the whole trustness of the txs chains?

I think it's good to be transparent to the caller. Some callers may want to find those ancestors. Other callers may prefer to just treat unknown-trust transactions as untrusted.

In my mind we will end up considering this as Untrusted because we cannot claim that we know if these are tainted or not.

I think you're probably correct. I also assume that for the majority of cases, the caller would just want to treat those transactions as untrusted. However, a transparent API is a more understandable API.

Comment on lines +469 to +470
.collect();
results.into_iter()

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There is a way to write this to not .collect and then call .into_iter on it again.

Comment on lines +452 to +456
if txout.is_mature(tip) {
Eligibility::Settled
} else {
Eligibility::Immature
}

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.

So any unconfirmed coinbase tx with an owned script pubkey should be treated as Immature, not TrustedPending, even if it doesn't have a single confirmation?

Comment on lines +515 to +516
// Not canonical
cache.insert(txid, false);

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.

We can guarantee that all transactions are canonical at this point as in line 440, we filtered by self.txout().

You mean we consider txs for which its txid appear as parents of a tx in the CanonicalView as canonical?

Proposal

Assuming we introduce the Eligibility::Unsettled(Trust) variant, we can add another variant to the Trust enum: Trust::Unknown.

The cache type will become HashMap<Txid, Trust> where Untrusted beats Unknown and Unknown beats Trusted.

Do you expect wallets to also handle this category as "unresolved" balance? Or it's just an intermediate category to trigger request for those txids and be able to resolve the whole trustness of the txs chains?

In my mind we will end up considering this as Untrusted because we cannot claim that we know if these are tainted or not.

0,
env.indexer.outpoints().iter().map(|(_, op)| *op),
|_| false,
|pos| pos.is_confirmed(),

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.

The new closure isn't equivalent to the old one. You must check the indexing of the script pubkey.

graph.index.outpoints().iter().cloned(),
|_, txout| trusted_spks.contains(&txout.txout.script_pubkey),
0,
graph.index.outpoints().iter().map(|(_, op)| *op),

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.

I think the below tests assertions shouldn't be changed to match the new behavior, but the test be reworked to match the assertions. If the test was expecting some untrusted_pending balance, it should be changed to still produce that untrusted pending balance.

},
|pos| pos.is_confirmed(),
)
.map(|(txout, eligibility)| (txout.outpoint, eligibility))

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.

Does it make sense to have impl From<Iter<TxOut, Elegibility>>> for Balance? Is it possible?

let mut tx_graph = TxGraph::<ConfirmationBlockTime>::default();
let spk = ScriptBuf::new();

// Coinbase confirmed at height 1; far below `COINBASE_MATURITY` → immature.

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.

Please, remove any non-ASCII characters from output: (use ->)


/// `classify_outpoints` distinguishes an immature coinbase from a settled output.
#[test]
fn test_classify_immature_and_settled() {

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.

After evan's review, we should have a test that contains a mature input at current height, and is_settled to aim for 3 confs, and check mature is still mature.


/// `classify_outpoints` skips outpoints that are already spent or absent from the view.
#[test]
fn test_classify_skips_spent_and_unknown() {

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.

We should consider the other kind of unknown evan mentioned, parent txids, that aren't indexed, and are the parent root of an unconfirmed tx chain.

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.

3 participants