From 58ceab4391bd28c2bcb8f00c47963a1df33d7fb1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=BF=97=E5=AE=87?= Date: Mon, 22 Jun 2026 07:17:09 +0000 Subject: [PATCH 1/6] feat(chain): add `CanonicalAncestors` reverse-topological walk 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) --- crates/chain/src/canonical_ancestors.rs | 234 ++++++++++++++++++ crates/chain/src/lib.rs | 2 + .../chain/tests/test_canonical_ancestors.rs | 212 ++++++++++++++++ 3 files changed, 448 insertions(+) create mode 100644 crates/chain/src/canonical_ancestors.rs create mode 100644 crates/chain/tests/test_canonical_ancestors.rs diff --git a/crates/chain/src/canonical_ancestors.rs b/crates/chain/src/canonical_ancestors.rs new file mode 100644 index 000000000..4bb947e50 --- /dev/null +++ b/crates/chain/src/canonical_ancestors.rs @@ -0,0 +1,234 @@ +//! Reverse-topological traversal of canonical ancestors. +//! +//! This module provides [`CanonicalAncestors`], an iterator over the canonical ancestors of a set +//! of seed transactions, accumulating a [`Merge`]able value from each transaction's descendants. + +use crate::collections::{HashMap, HashSet, VecDeque}; +use alloc::vec::Vec; + +use bitcoin::Txid; + +use crate::{Canonical, CanonicalTx, Merge}; + +impl Canonical { + /// Walk the canonical ancestors of the given `seeds`, accumulating a [`Merge`]able value. + /// + /// Ancestors are yielded in reverse topological order — each transaction is visited only after + /// every descendant within the traversed set that spends it has been visited — and each + /// transaction is visited exactly once. + /// + /// Accumulation is a fold over the ancestor DAG: + /// + /// * `map` computes a transaction's *own* contribution from the transaction. + /// * A transaction's final accumulator is its own contribution [merged](Merge::merge) with the + /// final accumulators of every descendant within the traversed set. Where multiple descendant + /// paths converge on the same ancestor, their accumulators are all merged in. + /// + /// `should_walk` controls pruning: returning `false` for a transaction stops the traversal from + /// descending into *its* ancestors (the transaction itself is still visited). The decision is + /// made from the transaction (and its [position](CanonicalTx::pos)) alone, so it is + /// well-defined before any accumulation happens. + /// + /// The `seeds` are the txids of the starting transactions. Seeds that are not part of this + /// canonical set are ignored. Seeds are not yielded; they only seed the traversal and propagate + /// their accumulators into their ancestors. + /// + /// Each item is a `(accumulator, ancestor)` pair, where `accumulator` is the ancestor's final + /// (fully merged) accumulator. + /// + /// # Note on merge order + /// + /// The order in which descendant accumulators are merged into a shared ancestor is unspecified. + /// For order-independent (idempotent / commutative) [`Merge`] implementations the result is + /// deterministic regardless; for order-sensitive implementations (e.g. last-write-wins) the + /// outcome at a convergence point may depend on traversal order. + pub fn ancestors( + &self, + seeds: impl IntoIterator, + map: M, + should_walk: S, + ) -> CanonicalAncestors<'_, A, P, T> + where + P: Clone, + T: Merge + Clone, + M: FnMut(CanonicalTx

) -> T, + S: FnMut(CanonicalTx

) -> bool, + { + CanonicalAncestors::new(self, seeds, map, should_walk) + } +} + +/// An iterator over the canonical ancestors of a set of seed transactions. +/// +/// Created by [`Canonical::ancestors`]. Ancestors are yielded in reverse topological order (each +/// transaction is visited only after every descendant within the traversed set that spends it has +/// been visited) and each transaction is visited exactly once. Each transaction's accumulator is +/// its own contribution (from `map`) [merged](Merge::merge) with the accumulators of all its +/// descendants within the traversed set. +/// +/// The seed transactions themselves are *not* yielded; they only seed the accumulation. +/// +/// Each item is a `(accumulator, ancestor)` pair. +pub struct CanonicalAncestors<'c, A, P, T> { + canonical: &'c Canonical, + /// The set of seed txids, excluded from emission. + seeds: HashSet, + /// For each visited tx, the distinct in-set parents reached *through* it (empty if pruned). + parents: HashMap>, + /// Remaining number of in-set descendants for each tx; a tx is ready once this hits zero. + in_degree: HashMap, + /// Accumulator per tx. Seeded with the tx's own contribution, then descendants are merged in. + acc: HashMap, + /// Txs whose `in_degree` has reached zero and are ready to be finalized/emitted. + ready: VecDeque, + /// Number of non-seed transactions yet to be yielded. + remaining: usize, +} + +impl<'c, A, P, T> CanonicalAncestors<'c, A, P, T> { + pub(crate) fn new( + canonical: &'c Canonical, + seeds: impl IntoIterator, + mut map: M, + mut should_walk: S, + ) -> Self + where + P: Clone, + M: FnMut(CanonicalTx

) -> T, + S: FnMut(CanonicalTx

) -> bool, + { + let mut seeds_set = HashSet::::new(); + let mut reachable = HashSet::::new(); + let mut acc = HashMap::::new(); + let mut stack = Vec::::new(); + + for txid in seeds { + // Only transactions in this canonical set participate. + if !canonical.txs.contains_key(&txid) { + continue; + } + seeds_set.insert(txid); + if reachable.insert(txid) { + stack.push(txid); + } + } + + // Phase 1: discover the reachable subgraph and in-set edges (respecting pruning), seeding + // each visited tx's accumulator with its own contribution. + let mut parents = HashMap::>::new(); + let mut in_degree = HashMap::::new(); + while let Some(txid) = stack.pop() { + // Present because we only ever push txids found in `canonical.txs`. + let (tx, pos) = &canonical.txs[&txid]; + let canonical_tx = || CanonicalTx { + pos: pos.clone(), + txid, + tx: tx.clone(), + }; + acc.insert(txid, map(canonical_tx())); + + if !should_walk(canonical_tx()) { + parents.insert(txid, Vec::new()); + continue; + } + let mut my_parents = Vec::::new(); + let mut seen = HashSet::::new(); + for txin in &tx.input { + let parent_txid = txin.previous_output.txid; + // distinct parents only, and only those that are canonical + if !seen.insert(parent_txid) { + continue; + } + if canonical.txs.contains_key(&parent_txid) { + my_parents.push(parent_txid); + *in_degree.entry(parent_txid).or_insert(0) += 1; + if reachable.insert(parent_txid) { + stack.push(parent_txid); + } + } + } + parents.insert(txid, my_parents); + } + + // Phase 2 seed: every reachable tx with no in-set descendants is ready immediately. + let mut ready = VecDeque::::new(); + for &txid in &reachable { + if in_degree.get(&txid).copied().unwrap_or(0) == 0 { + ready.push_back(txid); + } + } + + let remaining = reachable.len() - seeds_set.len(); + + Self { + canonical, + seeds: seeds_set, + parents, + in_degree, + acc, + ready, + remaining, + } + } +} + +impl Iterator for CanonicalAncestors<'_, A, P, T> +where + P: Clone, + T: Merge + Clone, +{ + type Item = (T, CanonicalTx

); + + fn next(&mut self) -> Option { + loop { + let txid = self.ready.pop_front()?; + + // The accumulator for `txid` is now finalized: all descendants have been merged in. + let node_acc = self + .acc + .remove(&txid) + .expect("accumulator must exist once a tx is ready"); + + // Merge this tx's accumulator into each of its in-set parents. + let parents = self.parents.remove(&txid).unwrap_or_default(); + for parent_txid in parents { + self.acc + .get_mut(&parent_txid) + .expect("parent accumulator was seeded during discovery") + .merge(node_acc.clone()); + + let d = self + .in_degree + .get_mut(&parent_txid) + .expect("parent must have an in-degree entry"); + *d -= 1; + if *d == 0 { + self.ready.push_back(parent_txid); + } + } + + // Seeds seed the accumulation but are not themselves yielded. + if self.seeds.contains(&txid) { + continue; + } + + let (tx, pos) = self.canonical.txs[&txid].clone(); + self.remaining -= 1; + return Some((node_acc, CanonicalTx { pos, txid, tx })); + } + } + + fn size_hint(&self) -> (usize, Option) { + (self.remaining, Some(self.remaining)) + } +} + +impl ExactSizeIterator for CanonicalAncestors<'_, A, P, T> +where + P: Clone, + T: Merge + Clone, +{ + fn len(&self) -> usize { + self.remaining + } +} diff --git a/crates/chain/src/lib.rs b/crates/chain/src/lib.rs index 41ed7cc09..677a446cb 100644 --- a/crates/chain/src/lib.rs +++ b/crates/chain/src/lib.rs @@ -46,6 +46,8 @@ mod canonical_task; pub use canonical_task::*; mod canonical; pub use canonical::*; +mod canonical_ancestors; +pub use canonical_ancestors::*; mod canonical_view_task; pub use canonical_view_task::*; diff --git a/crates/chain/tests/test_canonical_ancestors.rs b/crates/chain/tests/test_canonical_ancestors.rs new file mode 100644 index 000000000..85f259203 --- /dev/null +++ b/crates/chain/tests/test_canonical_ancestors.rs @@ -0,0 +1,212 @@ +#![cfg(feature = "miniscript")] + +use std::collections::{BTreeMap, BTreeSet, HashSet}; + +use bdk_chain::{local_chain::LocalChain, CanonicalTx, ConfirmationBlockTime, TxGraph}; +use bdk_testenv::{hash, utils::new_tx}; +use bitcoin::{Amount, BlockHash, OutPoint, ScriptBuf, Transaction, TxIn, TxOut, Txid}; + +fn out(val: u64) -> TxOut { + TxOut { + value: Amount::from_sat(val), + script_pubkey: ScriptBuf::new(), + } +} + +fn txin(op: OutPoint) -> TxIn { + TxIn { + previous_output: op, + ..Default::default() + } +} + +/// `map` that contributes the visited txid into a `BTreeSet`. A node's final accumulator is then +/// the set of every txid in its descendant-closure within the traversed set, plus itself. +fn collect_self

(ctx: CanonicalTx

) -> BTreeSet { + [ctx.txid].into_iter().collect() +} + +/// Builds a diamond of confirmed transactions and returns the chain, graph and the four txs. +/// +/// ```text +/// tx_a (2 outputs) +/// / \ +/// tx_b tx_c +/// \ / +/// tx_d (root we walk ancestors from) +/// ``` +fn diamond() -> (LocalChain, TxGraph, [Transaction; 4]) { + let blocks: BTreeMap = [ + (0, hash!("block0")), + (1, hash!("block1")), + (2, hash!("block2")), + (3, hash!("block3")), + (4, hash!("block4")), + (5, hash!("block5")), + ] + .into_iter() + .collect(); + let chain = LocalChain::from_blocks(blocks).unwrap(); + let mut tx_graph = TxGraph::::default(); + + let tx_a = Transaction { + input: vec![txin(OutPoint::new(hash!("external"), 0))], + output: vec![out(10_000), out(10_000)], + ..new_tx(0) + }; + let txid_a = tx_a.compute_txid(); + + let tx_b = Transaction { + input: vec![txin(OutPoint::new(txid_a, 0))], + output: vec![out(9_000)], + ..new_tx(1) + }; + let txid_b = tx_b.compute_txid(); + + let tx_c = Transaction { + input: vec![txin(OutPoint::new(txid_a, 1))], + output: vec![out(9_000)], + ..new_tx(2) + }; + let txid_c = tx_c.compute_txid(); + + let tx_d = Transaction { + input: vec![ + txin(OutPoint::new(txid_b, 0)), + txin(OutPoint::new(txid_c, 0)), + ], + output: vec![out(15_000)], + ..new_tx(3) + }; + let txid_d = tx_d.compute_txid(); + + for (txid, tx, height) in [ + (txid_a, &tx_a, 1u32), + (txid_b, &tx_b, 2), + (txid_c, &tx_c, 2), + (txid_d, &tx_d, 3), + ] { + let _ = tx_graph.insert_tx(tx.clone()); + let _ = tx_graph.insert_anchor( + txid, + ConfirmationBlockTime { + block_id: chain.get(height).unwrap().block_id(), + confirmation_time: 100, + }, + ); + } + + (chain, tx_graph, [tx_a, tx_b, tx_c, tx_d]) +} + +#[test] +fn ancestors_reverse_topological_and_merged() { + let (chain, tx_graph, [tx_a, tx_b, tx_c, tx_d]) = diamond(); + let (txid_a, txid_b, txid_c, txid_d) = ( + tx_a.compute_txid(), + tx_b.compute_txid(), + tx_c.compute_txid(), + tx_d.compute_txid(), + ); + + let view = chain.canonical_view(&tx_graph, chain.tip().block_id(), Default::default()); + + let result: Vec<_> = view + .ancestors([txid_d], collect_self, |_ctx| true) + .collect(); + + // Three ancestors (root tx_d is not yielded), each exactly once. + let order: Vec = result.iter().map(|(_, ct)| ct.txid).collect(); + assert_eq!(order.len(), 3); + assert_eq!(order.iter().copied().collect::>().len(), 3); + + let pos = |t: Txid| order.iter().position(|&x| x == t).unwrap(); + // Reverse topological: the shared ancestor tx_a comes after both of its spenders. + assert!(pos(txid_a) > pos(txid_b)); + assert!(pos(txid_a) > pos(txid_c)); + + let acc_of = |t: Txid| { + result + .iter() + .find(|(_, ct)| ct.txid == t) + .unwrap() + .0 + .clone() + }; + // The accumulator flows from the root up into ancestors: tx_d (the root) spends tx_b and tx_c, + // so its contribution is merged into each of them (alongside their own). + assert_eq!( + acc_of(txid_b), + [txid_b, txid_d].into_iter().collect::>() + ); + assert_eq!( + acc_of(txid_c), + [txid_c, txid_d].into_iter().collect::>() + ); + // tx_a is reached from both tx_b and tx_c, so the full descendant-closure merges into it. + assert_eq!( + acc_of(txid_a), + [txid_a, txid_b, txid_c, txid_d] + .into_iter() + .collect::>() + ); + // tx_d is a root and is never yielded. + assert!(result.iter().all(|(_, ct)| ct.txid != txid_d)); +} + +#[test] +fn ancestors_exact_size() { + let (chain, tx_graph, [.., tx_d]) = diamond(); + let txid_d = tx_d.compute_txid(); + let view = chain.canonical_view(&tx_graph, chain.tip().block_id(), Default::default()); + let it = view.ancestors([txid_d], collect_self, |_ctx| true); + assert_eq!(it.len(), 3); + assert_eq!(it.count(), 3); +} + +#[test] +fn ancestors_pruning_stops_a_branch() { + let (chain, tx_graph, [tx_a, tx_b, tx_c, tx_d]) = diamond(); + let (txid_a, txid_b, txid_c, txid_d) = ( + tx_a.compute_txid(), + tx_b.compute_txid(), + tx_c.compute_txid(), + tx_d.compute_txid(), + ); + + let view = chain.canonical_view(&tx_graph, chain.tip().block_id(), Default::default()); + + // Prune tx_b: tx_a is no longer reached *through* tx_b, only through tx_c. + let result: Vec<_> = view + .ancestors([txid_d], collect_self, |ctx| ctx.txid != txid_b) + .collect(); + + // tx_b is still emitted (it is a direct ancestor of the root); only its onward edge is pruned. + let order: Vec = result.iter().map(|(_, ct)| ct.txid).collect(); + assert_eq!(order.len(), 3); + + let acc_of = |t: Txid| { + result + .iter() + .find(|(_, ct)| ct.txid == t) + .unwrap() + .0 + .clone() + }; + // tx_d still spends tx_b, so it merges into tx_b regardless of pruning tx_b's onward walk. + assert_eq!( + acc_of(txid_b), + [txid_b, txid_d].into_iter().collect::>() + ); + assert_eq!( + acc_of(txid_c), + [txid_c, txid_d].into_iter().collect::>() + ); + // The tx_b -> tx_a edge is pruned, so only tx_c's closure reaches tx_a (tx_b absent). + assert_eq!( + acc_of(txid_a), + [txid_a, txid_c, txid_d] + .into_iter() + .collect::>() + ); +} From 2b3b7a2ddc7c7845110afc73a08f0019e567ffef Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=BF=97=E5=AE=87?= Date: Mon, 22 Jun 2026 23:10:38 +0000 Subject: [PATCH 2/6] feat(chain): add `Canonical::ancestors_inclusive` 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) --- crates/chain/src/canonical_ancestors.rs | 100 ++++++++++++++---------- 1 file changed, 59 insertions(+), 41 deletions(-) diff --git a/crates/chain/src/canonical_ancestors.rs b/crates/chain/src/canonical_ancestors.rs index 4bb947e50..4bc9cbe6a 100644 --- a/crates/chain/src/canonical_ancestors.rs +++ b/crates/chain/src/canonical_ancestors.rs @@ -13,35 +13,12 @@ use crate::{Canonical, CanonicalTx, Merge}; impl Canonical { /// Walk the canonical ancestors of the given `seeds`, accumulating a [`Merge`]able value. /// - /// Ancestors are yielded in reverse topological order — each transaction is visited only after - /// every descendant within the traversed set that spends it has been visited — and each - /// transaction is visited exactly once. + /// The `seeds` are the txids of the starting transactions — the leaf-most transactions whose + /// ancestry is walked. The walk proceeds *backwards* from the seeds towards their ancestors + /// (the transactions they spend, transitively). The seeds are *not* yielded; see + /// [`ancestors_inclusive`](Self::ancestors_inclusive) for a variant that yields them too. /// - /// Accumulation is a fold over the ancestor DAG: - /// - /// * `map` computes a transaction's *own* contribution from the transaction. - /// * A transaction's final accumulator is its own contribution [merged](Merge::merge) with the - /// final accumulators of every descendant within the traversed set. Where multiple descendant - /// paths converge on the same ancestor, their accumulators are all merged in. - /// - /// `should_walk` controls pruning: returning `false` for a transaction stops the traversal from - /// descending into *its* ancestors (the transaction itself is still visited). The decision is - /// made from the transaction (and its [position](CanonicalTx::pos)) alone, so it is - /// well-defined before any accumulation happens. - /// - /// The `seeds` are the txids of the starting transactions. Seeds that are not part of this - /// canonical set are ignored. Seeds are not yielded; they only seed the traversal and propagate - /// their accumulators into their ancestors. - /// - /// Each item is a `(accumulator, ancestor)` pair, where `accumulator` is the ancestor's final - /// (fully merged) accumulator. - /// - /// # Note on merge order - /// - /// The order in which descendant accumulators are merged into a shared ancestor is unspecified. - /// For order-independent (idempotent / commutative) [`Merge`] implementations the result is - /// deterministic regardless; for order-sensitive implementations (e.g. last-write-wins) the - /// outcome at a convergence point may depend on traversal order. + /// See [`CanonicalAncestors`] for the traversal order and accumulation semantics. pub fn ancestors( &self, seeds: impl IntoIterator, @@ -54,25 +31,60 @@ impl Canonical { M: FnMut(CanonicalTx

) -> T, S: FnMut(CanonicalTx

) -> bool, { - CanonicalAncestors::new(self, seeds, map, should_walk) + CanonicalAncestors::new(self, seeds, map, should_walk, false) + } + + /// Like [`ancestors`](Self::ancestors), but the `seeds` are also yielded (each with its own + /// final accumulator). + pub fn ancestors_inclusive( + &self, + seeds: impl IntoIterator, + map: M, + should_walk: S, + ) -> CanonicalAncestors<'_, A, P, T> + where + P: Clone, + T: Merge + Clone, + M: FnMut(CanonicalTx

) -> T, + S: FnMut(CanonicalTx

) -> bool, + { + CanonicalAncestors::new(self, seeds, map, should_walk, true) } } /// An iterator over the canonical ancestors of a set of seed transactions. /// -/// Created by [`Canonical::ancestors`]. Ancestors are yielded in reverse topological order (each -/// transaction is visited only after every descendant within the traversed set that spends it has -/// been visited) and each transaction is visited exactly once. Each transaction's accumulator is -/// its own contribution (from `map`) [merged](Merge::merge) with the accumulators of all its -/// descendants within the traversed set. +/// Created by [`Canonical::ancestors`] / [`Canonical::ancestors_inclusive`]. Transactions are +/// yielded in reverse topological order (each transaction is visited only after every descendant +/// within the traversed set that spends it has been visited) and each is visited exactly once. +/// +/// Accumulation is a fold over the ancestor DAG: +/// +/// * `map` computes a transaction's *own* contribution from the transaction (and its +/// [position](CanonicalTx::pos)). +/// * A transaction's final accumulator is its own contribution [merged](Merge::merge) with the +/// final accumulators of every descendant within the traversed set. Where multiple descendant +/// paths converge on the same ancestor, their accumulators are all merged in. +/// +/// `should_walk` controls pruning: returning `false` for a transaction stops the traversal from +/// descending into *its* ancestors (the transaction itself is still visited). The decision is made +/// from the transaction alone, so it is well-defined before any accumulation happens. +/// +/// Each item is a `(accumulator, transaction)` pair, where `accumulator` is the transaction's final +/// (fully merged) accumulator. /// -/// The seed transactions themselves are *not* yielded; they only seed the accumulation. +/// # Note on merge order /// -/// Each item is a `(accumulator, ancestor)` pair. +/// The order in which descendant accumulators are merged into a shared ancestor is unspecified. For +/// order-independent (idempotent / commutative) [`Merge`] implementations the result is +/// deterministic regardless; for order-sensitive implementations (e.g. last-write-wins) the outcome +/// at a convergence point may depend on traversal order. pub struct CanonicalAncestors<'c, A, P, T> { canonical: &'c Canonical, - /// The set of seed txids, excluded from emission. + /// The set of seed txids. seeds: HashSet, + /// Whether seeds are yielded (in addition to seeding the accumulation). + include_seeds: bool, /// For each visited tx, the distinct in-set parents reached *through* it (empty if pruned). parents: HashMap>, /// Remaining number of in-set descendants for each tx; a tx is ready once this hits zero. @@ -81,7 +93,7 @@ pub struct CanonicalAncestors<'c, A, P, T> { acc: HashMap, /// Txs whose `in_degree` has reached zero and are ready to be finalized/emitted. ready: VecDeque, - /// Number of non-seed transactions yet to be yielded. + /// Number of transactions yet to be yielded. remaining: usize, } @@ -91,6 +103,7 @@ impl<'c, A, P, T> CanonicalAncestors<'c, A, P, T> { seeds: impl IntoIterator, mut map: M, mut should_walk: S, + include_seeds: bool, ) -> Self where P: Clone, @@ -158,11 +171,16 @@ impl<'c, A, P, T> CanonicalAncestors<'c, A, P, T> { } } - let remaining = reachable.len() - seeds_set.len(); + let remaining = if include_seeds { + reachable.len() + } else { + reachable.len() - seeds_set.len() + }; Self { canonical, seeds: seeds_set, + include_seeds, parents, in_degree, acc, @@ -207,8 +225,8 @@ where } } - // Seeds seed the accumulation but are not themselves yielded. - if self.seeds.contains(&txid) { + // Seeds propagate their accumulation but are only yielded when requested. + if !self.include_seeds && self.seeds.contains(&txid) { continue; } From fb8784da13e1f54be75b6cc20d5cab8171c60f34 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=BF=97=E5=AE=87?= Date: Mon, 22 Jun 2026 23:12:17 +0000 Subject: [PATCH 3/6] feat(chain)!: taint-aware `CanonicalView::balance` 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) --- crates/bitcoind_rpc/tests/test_emitter.rs | 6 +- crates/chain/benches/indexer.rs | 6 +- crates/chain/src/canonical.rs | 150 +++++++----- crates/chain/tests/test_canonical_view.rs | 216 ++++++++++++++++-- crates/chain/tests/test_indexed_tx_graph.rs | 48 ++-- crates/chain/tests/test_tx_graph_conflicts.rs | 11 +- crates/electrum/tests/test_electrum.rs | 6 +- 7 files changed, 336 insertions(+), 107 deletions(-) diff --git a/crates/bitcoind_rpc/tests/test_emitter.rs b/crates/bitcoind_rpc/tests/test_emitter.rs index 2aeede628..44797cd76 100644 --- a/crates/bitcoind_rpc/tests/test_emitter.rs +++ b/crates/bitcoind_rpc/tests/test_emitter.rs @@ -325,7 +325,11 @@ fn get_balance( recv_chain.tip().block_id(), Default::default(), ) - .balance(outpoints, |_, _| true, 0); + .balance( + outpoints.into_iter().map(|(_, op)| op), + |_| false, + |pos| pos.is_confirmed(), + ); Ok(balance) } diff --git a/crates/chain/benches/indexer.rs b/crates/chain/benches/indexer.rs index 97917ce35..8c0b0bae3 100644 --- a/crates/chain/benches/indexer.rs +++ b/crates/chain/benches/indexer.rs @@ -85,7 +85,11 @@ fn do_bench(indexed_tx_graph: &KeychainTxGraph, chain: &LocalChain) { let op = graph.index.outpoints().clone(); let bal = chain .canonical_view(graph.graph(), chain.tip().block_id(), Default::default()) - .balance(op, |_, _| false, 1); + .balance( + op.into_iter().map(|(_, o)| o), + |_| false, + |pos| pos.is_confirmed(), + ); assert_eq!(bal.total(), AMOUNT * TX_CT as u64); } diff --git a/crates/chain/src/canonical.rs b/crates/chain/src/canonical.rs index c2aecb756..306562488 100644 --- a/crates/chain/src/canonical.rs +++ b/crates/chain/src/canonical.rs @@ -22,7 +22,8 @@ //! } //! ``` -use crate::collections::HashMap; +use crate::collections::{HashMap, HashSet}; +use alloc::collections::BTreeSet; use alloc::sync::Arc; use alloc::vec::Vec; use core::{fmt, ops::RangeBounds}; @@ -402,25 +403,50 @@ impl CanonicalView { /// /// # Arguments /// - /// * `outpoints` - Iterator of `(identifier, outpoint)` pairs to calculate balance for - /// * `trust_predicate` - Function that returns `true` for trusted scripts. Trusted outputs - /// count toward `trusted_pending` balance, while untrusted ones count toward - /// `untrusted_pending` - /// * `min_confirmations` - Minimum confirmations required for an output to be considered - /// confirmed. Outputs with fewer confirmations are treated as pending. + /// * `outpoints` - Iterator of outpoints to calculate balance for + /// * `does_taint` - Function that returns `true` for transactions that should *taint* their + /// descendants. A pending output counts toward `untrusted_pending` if its transaction, or any + /// of its unsettled ancestors, taints; otherwise it counts toward `trusted_pending`. See + /// [Taint](#taint) below. + /// * `is_settled` - Function that returns `true` for the [position](ChainPosition) of an output + /// that should be treated as settled (i.e. confirmed deeply enough to count toward + /// `confirmed`/`immature`). Outputs whose position is not settled are pending. `is_settled` is + /// the sole authority on this boundary — a settled output is never dropped or tainted. See + /// [Settled](#settled) below. /// - /// # Minimum Confirmations + /// # Settled /// - /// The `min_confirmations` parameter controls when outputs are considered confirmed. A - /// `min_confirmations` value of `0` is equivalent to `1` (require at least 1 confirmation). + /// `is_settled` controls the boundary between confirmed and pending outputs. Typically it + /// checks that an output has at least some number of confirmations, for example: /// - /// Outputs with fewer than `min_confirmations` are categorized as pending (trusted or - /// untrusted based on the trust predicate). + /// ``` + /// # use bdk_chain::ChainPosition; + /// # use bdk_core::BlockId; + /// # let tip_height: u32 = 100; + /// # let min_confirmations: u32 = 6; + /// let is_settled = |pos: &ChainPosition| { + /// pos.confirmation_height_upper_bound() + /// .is_some_and(|h| tip_height.saturating_sub(h).saturating_add(1) >= min_confirmations) + /// }; + /// # let _ = is_settled; + /// ``` + /// + /// # Taint + /// + /// `does_taint` decides whether a pending output is trusted. The canonical *unsettled* ancestry + /// of each pending output is walked (stopping at settled transactions); if `does_taint` returns + /// `true` for the output's own transaction or any walked ancestor, the output counts toward + /// `untrusted_pending`, otherwise toward `trusted_pending`. + /// + /// A common use is to taint transactions that spend outputs the wallet does not own while + /// unconfirmed (i.e. unconfirmed coins received from, or chained on top of, a third party). + /// Returning `false` for every transaction treats all pending owned outputs as trusted; + /// returning `true` treats them all as untrusted. /// /// # Example /// /// ``` - /// # use bdk_chain::{CanonicalParams, TxGraph, local_chain::LocalChain, keychain_txout::KeychainTxOutIndex}; + /// # use bdk_chain::{CanonicalParams, ChainPosition, TxGraph, local_chain::LocalChain, keychain_txout::KeychainTxOutIndex}; /// # use bdk_core::BlockId; /// # use bitcoin::hashes::Hash; /// # let tx_graph = TxGraph::::default(); @@ -428,55 +454,75 @@ impl CanonicalView { /// # let chain_tip = chain.tip().block_id(); /// # let view = chain.canonical_view(&tx_graph, chain_tip, CanonicalParams::default()); /// # let indexer = KeychainTxOutIndex::<&str>::default(); - /// // Calculate balance with 6 confirmations, trusting all outputs + /// let tip_height = view.tip().height; + /// // Calculate balance requiring 6 confirmations, tainting nothing (all pending trusted) /// let balance = view.balance( - /// indexer.outpoints().into_iter().map(|(k, op)| (k.clone(), *op)), - /// |_keychain, _script| true, // Trust all outputs - /// 6, // Require 6 confirmations + /// indexer.outpoints().iter().map(|(_, op)| *op), + /// |_tx| false, // Never taint + /// |pos: &ChainPosition<_>| { + /// pos.confirmation_height_upper_bound() + /// .is_some_and(|h| tip_height.saturating_sub(h).saturating_add(1) >= 6) + /// }, /// ); /// ``` - pub fn balance<'v, O: Clone + 'v>( - &'v self, - outpoints: impl IntoIterator + 'v, - mut trust_predicate: impl FnMut(&O, &CanonicalTxOut>) -> bool, - min_confirmations: u32, + pub fn balance( + &self, + outpoints: impl IntoIterator, + mut does_taint: impl FnMut(CanonicalTx>) -> bool, + is_settled: impl Fn(&ChainPosition) -> bool, ) -> Balance { + let utxos = outpoints + .into_iter() + .filter_map(|op| self.txout(op)) + .filter(|txo| txo.spent_by.is_none()) + .collect::>(); + + // The set of transaction ids of pending outputs that are tainted by themselves or an + // unsettled ancestor. + let tainted = { + // Pending outputs seed the walk; settled ones cannot be tainted. + let seeds = utxos + .iter() + .filter(|txo| !is_settled(&txo.pos)) + .map(|txo| txo.outpoint.txid) + .collect::>(); + + let mut tainted = HashSet::::new(); + // Walk each pending output together with its unsettled ancestry (deduped across seeds, + // stopping at settled transactions). Each transaction carries the set of descendants it + // reaches; when an unsettled transaction taints, all of them are tainted. The settled + // boundary is yielded but never taints (a settled transaction cannot be replaced). + for (descendants, c_tx) in self.ancestors_inclusive::, _, _>( + seeds.iter().copied(), + |c_tx| core::iter::once(c_tx.txid).collect(), + |c_tx| !is_settled(&c_tx.pos), + ) { + if !is_settled(&c_tx.pos) && does_taint(c_tx) { + tainted.extend(descendants); + } + } + tainted + }; + let mut immature = Amount::ZERO; let mut trusted_pending = Amount::ZERO; let mut untrusted_pending = Amount::ZERO; let mut confirmed = Amount::ZERO; - for (spk_i, txout) in self.filter_unspent_outpoints(outpoints) { - match &txout.pos { - ChainPosition::Confirmed { anchor, .. } => { - let confirmation_height = anchor.confirmation_height_upper_bound(); - let confirmations = self - .tip - .height - .saturating_sub(confirmation_height) - .saturating_add(1); - let min_confirmations = min_confirmations.max(1); // 0 and 1 behave identically - - if confirmations < min_confirmations { - // Not enough confirmations, treat as trusted/untrusted pending - if trust_predicate(&spk_i, &txout) { - trusted_pending += txout.txout.value; - } else { - untrusted_pending += txout.txout.value; - } - } else if txout.is_confirmed_and_spendable(self.tip.height) { - confirmed += txout.txout.value; - } else if !txout.is_mature(self.tip.height) { - immature += txout.txout.value; - } - } - ChainPosition::Unconfirmed { .. } => { - if trust_predicate(&spk_i, &txout) { - trusted_pending += txout.txout.value; - } else { - untrusted_pending += txout.txout.value; - } + for txout in utxos { + if is_settled(&txout.pos) { + // Settled outputs count as settled unless they are an immature coinbase. We rely on + // `is_settled` alone (not on a confirmation height), so a caller is free to treat + // unconfirmed outputs as settled without their value being dropped. + if txout.is_mature(self.tip.height) { + confirmed += txout.txout.value; + } else { + immature += txout.txout.value; } + } else if tainted.contains(&txout.outpoint.txid) { + untrusted_pending += txout.txout.value; + } else { + trusted_pending += txout.txout.value; } } diff --git a/crates/chain/tests/test_canonical_view.rs b/crates/chain/tests/test_canonical_view.rs index 2cbe02103..1183595f0 100644 --- a/crates/chain/tests/test_canonical_view.rs +++ b/crates/chain/tests/test_canonical_view.rs @@ -6,6 +6,18 @@ use bdk_chain::{local_chain::LocalChain, ConfirmationBlockTime, TxGraph}; use bdk_testenv::{hash, utils::new_tx}; use bitcoin::{Amount, BlockHash, OutPoint, ScriptBuf, Transaction, TxIn, TxOut}; +/// Builds an `is_settled` predicate requiring at least `min_confirmations` confirmations. +fn settled( + tip_height: u32, + min_confirmations: u32, +) -> impl Fn(&bdk_chain::ChainPosition) -> bool { + move |pos| { + pos.confirmation_height_upper_bound().is_some_and(|h| { + tip_height.saturating_sub(h).saturating_add(1) >= min_confirmations.max(1) + }) + } +} + #[test] fn test_min_confirmations_parameter() { // Create a local chain with several blocks @@ -55,12 +67,13 @@ fn test_min_confirmations_parameter() { let canonical_view = chain.canonical_view(&tx_graph, chain.tip().block_id(), Default::default()); + let tip_height = canonical_view.tip().height; // Test min_confirmations = 1: Should be confirmed (has 6 confirmations) let balance_1_conf = canonical_view.balance( - [((), outpoint)], - |_, _| true, // trust all - 1, + [outpoint], + |_| false, // taint nothing + settled(tip_height, 1), ); assert_eq!(balance_1_conf.confirmed, Amount::from_sat(50_000)); @@ -68,27 +81,27 @@ fn test_min_confirmations_parameter() { // Test min_confirmations = 6: Should be confirmed (has exactly 6 confirmations) let balance_6_conf = canonical_view.balance( - [((), outpoint)], - |_, _| true, // trust all - 6, + [outpoint], + |_| false, // taint nothing + settled(tip_height, 6), ); assert_eq!(balance_6_conf.confirmed, Amount::from_sat(50_000)); assert_eq!(balance_6_conf.trusted_pending, Amount::ZERO); // Test min_confirmations = 7: Should be trusted pending (only has 6 confirmations) let balance_7_conf = canonical_view.balance( - [((), outpoint)], - |_, _| true, // trust all - 7, + [outpoint], + |_| false, // taint nothing + settled(tip_height, 7), ); assert_eq!(balance_7_conf.confirmed, Amount::ZERO); assert_eq!(balance_7_conf.trusted_pending, Amount::from_sat(50_000)); // Test min_confirmations = 0: Should behave same as 1 (confirmed) let balance_0_conf = canonical_view.balance( - [((), outpoint)], - |_, _| true, // trust all - 0, + [outpoint], + |_| false, // taint nothing + settled(tip_height, 0), ); assert_eq!(balance_0_conf.confirmed, Amount::from_sat(50_000)); assert_eq!(balance_0_conf.trusted_pending, Amount::ZERO); @@ -143,15 +156,17 @@ fn test_min_confirmations_with_untrusted_tx() { let canonical_view = chain.canonical_view(&tx_graph, chain.tip().block_id(), Default::default()); + let tip_height = canonical_view.tip().height; - // Test with min_confirmations = 5 and untrusted predicate + // Test with min_confirmations = 5. The output has only 3 confirmations, so it is unsettled and + // treated as pending. Tainting everything demotes the pending output to untrusted. let balance = canonical_view.balance( - [((), outpoint)], - |_, _| false, // don't trust - 5, + [outpoint], + |_| true, // taint everything + settled(tip_height, 5), ); - // Should be untrusted pending (not enough confirmations and not trusted) + // Should be untrusted pending (not deep enough to be settled, and tainted) assert_eq!(balance.confirmed, Amount::ZERO); assert_eq!(balance.trusted_pending, Amount::ZERO); assert_eq!(balance.untrusted_pending, Amount::from_sat(25_000)); @@ -209,7 +224,7 @@ fn test_min_confirmations_multiple_transactions() { confirmation_time: 123456, }, ); - outpoints.push(((), outpoint0)); + outpoints.push(outpoint0); // Transaction 1: anchored at height 10, has 6 confirmations (15-10+1 = 6) let tx1 = Transaction { @@ -233,7 +248,7 @@ fn test_min_confirmations_multiple_transactions() { confirmation_time: 123457, }, ); - outpoints.push(((), outpoint1)); + outpoints.push(outpoint1); // Transaction 2: anchored at height 13, has 3 confirmations (15-13+1 = 3) let tx2 = Transaction { @@ -257,16 +272,17 @@ fn test_min_confirmations_multiple_transactions() { confirmation_time: 123458, }, ); - outpoints.push(((), outpoint2)); + outpoints.push(outpoint2); let canonical_view = chain.canonical_view(&tx_graph, chain.tip().block_id(), Default::default()); + let tip_height = canonical_view.tip().height; // Test with min_confirmations = 5 // tx0: 11 confirmations -> confirmed // tx1: 6 confirmations -> confirmed // tx2: 3 confirmations -> trusted pending - let balance = canonical_view.balance(outpoints.clone(), |_, _| true, 5); + let balance = canonical_view.balance(outpoints.clone(), |_| false, settled(tip_height, 5)); assert_eq!( balance.confirmed, @@ -282,7 +298,7 @@ fn test_min_confirmations_multiple_transactions() { // tx0: 11 confirmations -> confirmed // tx1: 6 confirmations -> trusted pending // tx2: 3 confirmations -> trusted pending - let balance_high = canonical_view.balance(outpoints, |_, _| true, 10); + let balance_high = canonical_view.balance(outpoints, |_| false, settled(tip_height, 10)); assert_eq!( balance_high.confirmed, @@ -294,3 +310,159 @@ fn test_min_confirmations_multiple_transactions() { ); assert_eq!(balance_high.untrusted_pending, Amount::ZERO); } + +/// A pending output is `untrusted_pending` if it, or any of its unsettled ancestors, taints; the +/// taint propagates from a foreign-funded unconfirmed ancestor down to its descendants. +#[test] +fn test_balance_taint_propagates_through_unconfirmed_ancestry() { + use std::collections::HashSet; + + let blocks: BTreeMap = [(0, hash!("g")), (1, hash!("b1")), (2, hash!("tip"))] + .into_iter() + .collect(); + let chain = LocalChain::from_blocks(blocks).unwrap(); + let mut tx_graph = TxGraph::::default(); + + let owned_spk = ScriptBuf::new(); + + // A confirmed coin we own. + let coin = Transaction { + input: vec![TxIn { + previous_output: OutPoint::new(hash!("coinbase"), 0), + ..Default::default() + }], + output: vec![TxOut { + value: Amount::from_sat(100_000), + script_pubkey: owned_spk.clone(), + }], + ..new_tx(0) + }; + let coin_txid = coin.compute_txid(); + + // Unconfirmed, spends our own confirmed coin -> not tainted -> trusted_pending. + let trusted = Transaction { + input: vec![TxIn { + previous_output: OutPoint::new(coin_txid, 0), + ..Default::default() + }], + output: vec![TxOut { + value: Amount::from_sat(40_000), + script_pubkey: owned_spk.clone(), + }], + ..new_tx(1) + }; + let trusted_txid = trusted.compute_txid(); + + // Unconfirmed, funded by a third party (spends a foreign outpoint) -> taints itself. + let foreign = Transaction { + input: vec![TxIn { + previous_output: OutPoint::new(hash!("third_party"), 0), + ..Default::default() + }], + output: vec![TxOut { + value: Amount::from_sat(30_000), + script_pubkey: owned_spk.clone(), + }], + ..new_tx(2) + }; + let foreign_txid = foreign.compute_txid(); + + // Unconfirmed, spends our own `foreign` output -> tainted via its ancestor `foreign`. + let chained = Transaction { + input: vec![TxIn { + previous_output: OutPoint::new(foreign_txid, 0), + ..Default::default() + }], + output: vec![TxOut { + value: Amount::from_sat(25_000), + script_pubkey: owned_spk.clone(), + }], + ..new_tx(3) + }; + let chained_txid = chained.compute_txid(); + + let _ = tx_graph.insert_tx(coin.clone()); + let _ = tx_graph.insert_anchor( + coin_txid, + ConfirmationBlockTime { + block_id: chain.get(1).unwrap().block_id(), + confirmation_time: 100, + }, + ); + for tx in [&trusted, &foreign, &chained] { + let _ = tx_graph.insert_tx(tx.clone()); + let _ = tx_graph.insert_seen_at(tx.compute_txid(), 1000); + } + + // The set of outpoints we own. + let owned = [ + OutPoint::new(coin_txid, 0), + OutPoint::new(trusted_txid, 0), + OutPoint::new(foreign_txid, 0), + OutPoint::new(chained_txid, 0), + ] + .into_iter() + .collect::>(); + + let view = chain.canonical_view(&tx_graph, chain.tip().block_id(), Default::default()); + + // Our unspent owned outputs: `trusted` and `chained` (the others are spent). + let utxos = [ + OutPoint::new(trusted_txid, 0), + OutPoint::new(chained_txid, 0), + ]; + + let balance = view.balance( + utxos, + // Taint any transaction that spends an outpoint we do not own. + |c_tx| { + c_tx.tx + .input + .iter() + .any(|txin| !owned.contains(&txin.previous_output)) + }, + |pos| pos.is_confirmed(), + ); + + assert_eq!(balance.confirmed, Amount::ZERO); + assert_eq!(balance.immature, Amount::ZERO); + // `trusted` spends only our own (confirmed) coin -> trusted. + assert_eq!(balance.trusted_pending, Amount::from_sat(40_000)); + // `chained` inherits taint from its foreign-funded ancestor `foreign`. + assert_eq!(balance.untrusted_pending, Amount::from_sat(25_000)); +} + +/// `is_settled` is the sole authority on the settled boundary: a caller may treat an unconfirmed +/// output as settled, and its value must be counted (as settled), never silently dropped. +#[test] +fn test_balance_is_settled_is_authoritative_for_unconfirmed() { + let blocks: BTreeMap = + [(0, hash!("g")), (1, hash!("tip"))].into_iter().collect(); + let chain = LocalChain::from_blocks(blocks).unwrap(); + let mut tx_graph = TxGraph::::default(); + + let tx = Transaction { + input: vec![TxIn { + previous_output: OutPoint::new(hash!("parent"), 0), + ..Default::default() + }], + output: vec![TxOut { + value: Amount::from_sat(50_000), + script_pubkey: ScriptBuf::new(), + }], + ..new_tx(1) + }; + let txid = tx.compute_txid(); + let _ = tx_graph.insert_tx(tx); + let _ = tx_graph.insert_seen_at(txid, 1000); + + let view = chain.canonical_view(&tx_graph, chain.tip().block_id(), Default::default()); + + // An `is_settled` that claims everything is settled counts the (mature, non-coinbase) + // unconfirmed output as settled rather than dropping it. + let balance = view.balance([OutPoint::new(txid, 0)], |_| false, |_| true); + assert_eq!(balance.confirmed, Amount::from_sat(50_000)); + assert_eq!(balance.immature, Amount::ZERO); + assert_eq!(balance.trusted_pending, Amount::ZERO); + assert_eq!(balance.untrusted_pending, Amount::ZERO); +} diff --git a/crates/chain/tests/test_indexed_tx_graph.rs b/crates/chain/tests/test_indexed_tx_graph.rs index 96cafcb8e..7ab35b979 100644 --- a/crates/chain/tests/test_indexed_tx_graph.rs +++ b/crates/chain/tests/test_indexed_tx_graph.rs @@ -481,9 +481,11 @@ fn test_list_owned_txouts() { .collect::>(); let balance = canonical_view.balance( - graph.index.outpoints().iter().cloned(), - |_, txout| trusted_spks.contains(&txout.txout.script_pubkey), - 0, + graph.index.outpoints().iter().map(|(_, op)| *op), + // None of these transactions spend third-party coins (their inputs are empty or + // owned), so nothing is tainted and every pending output is trusted. + |_| false, + |pos| pos.is_confirmed(), ); let confirmed_txouts_txid = txouts @@ -574,10 +576,10 @@ fn test_list_owned_txouts() { assert_eq!( balance, Balance { - immature: Amount::from_sat(70000), // immature coinbase - trusted_pending: Amount::from_sat(25000), // tx3, tx5 - untrusted_pending: Amount::from_sat(20000), // tx4 - confirmed: Amount::ZERO // Nothing is confirmed yet + immature: Amount::from_sat(70000), // immature coinbase + trusted_pending: Amount::from_sat(45000), // tx3, tx4, tx5 (nothing tainted) + untrusted_pending: Amount::ZERO, + confirmed: Amount::ZERO // Nothing is confirmed yet } ); } @@ -612,10 +614,10 @@ fn test_list_owned_txouts() { assert_eq!( balance, Balance { - immature: Amount::from_sat(70000), // immature coinbase - trusted_pending: Amount::from_sat(25000), // tx3, tx5 - untrusted_pending: Amount::from_sat(20000), // tx4 - confirmed: Amount::from_sat(0) // tx2 got confirmed (but spent by 3) + immature: Amount::from_sat(70000), // immature coinbase + trusted_pending: Amount::from_sat(45000), // tx3, tx4, tx5 (nothing tainted) + untrusted_pending: Amount::ZERO, + confirmed: Amount::from_sat(0) // tx2 got confirmed (but spent by 3) } ); } @@ -653,10 +655,10 @@ fn test_list_owned_txouts() { assert_eq!( balance, Balance { - immature: Amount::from_sat(70000), // immature coinbase - trusted_pending: Amount::from_sat(15000), // tx5 - untrusted_pending: Amount::from_sat(20000), // tx4 - confirmed: Amount::from_sat(10000) // tx3 got confirmed + immature: Amount::from_sat(70000), // immature coinbase + trusted_pending: Amount::from_sat(35000), // tx4, tx5 (nothing tainted) + untrusted_pending: Amount::ZERO, + confirmed: Amount::from_sat(10000) // tx3 got confirmed } ); } @@ -694,10 +696,10 @@ fn test_list_owned_txouts() { assert_eq!( balance, Balance { - immature: Amount::from_sat(70000), // immature coinbase - trusted_pending: Amount::from_sat(15000), // tx5 - untrusted_pending: Amount::from_sat(20000), // tx4 - confirmed: Amount::from_sat(10000) // tx3 is confirmed + immature: Amount::from_sat(70000), // immature coinbase + trusted_pending: Amount::from_sat(35000), // tx4, tx5 (nothing tainted) + untrusted_pending: Amount::ZERO, + confirmed: Amount::from_sat(10000) // tx3 is confirmed } ); } @@ -710,10 +712,10 @@ fn test_list_owned_txouts() { assert_eq!( balance, Balance { - immature: Amount::ZERO, // coinbase matured - trusted_pending: Amount::from_sat(15000), // tx5 - untrusted_pending: Amount::from_sat(20000), // tx4 - confirmed: Amount::from_sat(80000) // tx1 + tx3 + immature: Amount::ZERO, // coinbase matured + trusted_pending: Amount::from_sat(35000), // tx4, tx5 (nothing tainted) + untrusted_pending: Amount::ZERO, + confirmed: Amount::from_sat(80000) // tx1 + tx3 } ); } diff --git a/crates/chain/tests/test_tx_graph_conflicts.rs b/crates/chain/tests/test_tx_graph_conflicts.rs index f1f166984..c6360ddd8 100644 --- a/crates/chain/tests/test_tx_graph_conflicts.rs +++ b/crates/chain/tests/test_tx_graph_conflicts.rs @@ -1028,13 +1028,10 @@ fn test_tx_conflict_handling() { ); let balance = canonical_view.balance( - env.indexer.outpoints().iter().cloned(), - |_, txout| { - env.indexer - .index_of_spk(txout.txout.script_pubkey.as_script()) - .is_some() - }, - 0, + env.indexer.outpoints().iter().map(|(_, op)| *op), + // All these outpoints are owned (so were "trusted" under the old predicate); taint none. + |_| false, + |pos| pos.is_confirmed(), ); assert_eq!( balance, scenario.exp_balance, diff --git a/crates/electrum/tests/test_electrum.rs b/crates/electrum/tests/test_electrum.rs index c569b065d..810c15dc4 100644 --- a/crates/electrum/tests/test_electrum.rs +++ b/crates/electrum/tests/test_electrum.rs @@ -62,7 +62,11 @@ fn get_balance( recv_chain.tip().block_id(), Default::default(), ) - .balance(outpoints, |_, _| true, 0); + .balance( + outpoints.into_iter().map(|(_, op)| op), + |_| false, + |pos| pos.is_confirmed(), + ); Ok(balance) } From 7737e306d5028ed1d02c1c8ae050edbb749cd382 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=BF=97=E5=AE=87?= Date: Tue, 23 Jun 2026 10:38:20 +0000 Subject: [PATCH 4/6] refactor(chain)!: rename `Balance::confirmed` to `Balance::settled` 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) --- crates/bitcoind_rpc/tests/test_emitter.rs | 4 +- crates/chain/src/balance.rs | 17 ++++---- crates/chain/src/canonical.rs | 19 +++++---- crates/chain/tests/test_canonical_view.rs | 18 ++++---- crates/chain/tests/test_indexed_tx_graph.rs | 10 ++--- crates/chain/tests/test_tx_graph_conflicts.rs | 41 ++++++++++--------- crates/electrum/tests/test_electrum.rs | 10 ++--- 7 files changed, 61 insertions(+), 58 deletions(-) diff --git a/crates/bitcoind_rpc/tests/test_emitter.rs b/crates/bitcoind_rpc/tests/test_emitter.rs index 44797cd76..989d1e806 100644 --- a/crates/bitcoind_rpc/tests/test_emitter.rs +++ b/crates/bitcoind_rpc/tests/test_emitter.rs @@ -399,7 +399,7 @@ fn tx_can_become_unconfirmed_after_reorg() -> anyhow::Result<()> { assert_eq!( get_balance(&recv_chain, &recv_graph)?, Balance { - confirmed: SEND_AMOUNT * ADDITIONAL_COUNT as u64, + settled: SEND_AMOUNT * ADDITIONAL_COUNT as u64, ..Balance::default() }, "initial balance must be correct", @@ -414,7 +414,7 @@ fn tx_can_become_unconfirmed_after_reorg() -> anyhow::Result<()> { get_balance(&recv_chain, &recv_graph)?, Balance { trusted_pending: SEND_AMOUNT * reorg_count as u64, - confirmed: SEND_AMOUNT * (ADDITIONAL_COUNT - reorg_count) as u64, + settled: SEND_AMOUNT * (ADDITIONAL_COUNT - reorg_count) as u64, ..Balance::default() }, "reorg_count: {reorg_count}", diff --git a/crates/chain/src/balance.rs b/crates/chain/src/balance.rs index 2d4dc9dbe..1252086db 100644 --- a/crates/chain/src/balance.rs +++ b/crates/chain/src/balance.rs @@ -10,22 +10,23 @@ pub struct Balance { pub trusted_pending: Amount, /// Unconfirmed UTXOs received from an external wallet pub untrusted_pending: Amount, - /// Confirmed and immediately spendable balance - pub confirmed: Amount, + /// Settled balance: outputs whose transaction we are confident will not be replaced (e.g. + /// confirmed deeply enough), as determined by the balance query's `is_settled` predicate. + pub settled: Amount, } impl Balance { - /// Get sum of trusted_pending and confirmed coins. + /// Get sum of trusted_pending and settled coins. /// /// This is the balance you can spend right now that shouldn't get cancelled via another party /// double spending it. pub fn trusted_spendable(&self) -> Amount { - self.confirmed + self.trusted_pending + self.settled + self.trusted_pending } /// Get the whole balance visible to the wallet. pub fn total(&self) -> Amount { - self.confirmed + self.trusted_pending + self.untrusted_pending + self.immature + self.settled + self.trusted_pending + self.untrusted_pending + self.immature } } @@ -33,8 +34,8 @@ impl core::fmt::Display for Balance { fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { write!( f, - "{{ immature: {}, trusted_pending: {}, untrusted_pending: {}, confirmed: {} }}", - self.immature, self.trusted_pending, self.untrusted_pending, self.confirmed + "{{ immature: {}, trusted_pending: {}, untrusted_pending: {}, settled: {} }}", + self.immature, self.trusted_pending, self.untrusted_pending, self.settled ) } } @@ -47,7 +48,7 @@ impl core::ops::Add for Balance { immature: self.immature + other.immature, trusted_pending: self.trusted_pending + other.trusted_pending, untrusted_pending: self.untrusted_pending + other.untrusted_pending, - confirmed: self.confirmed + other.confirmed, + settled: self.settled + other.settled, } } } diff --git a/crates/chain/src/canonical.rs b/crates/chain/src/canonical.rs index 306562488..bfd94a8bd 100644 --- a/crates/chain/src/canonical.rs +++ b/crates/chain/src/canonical.rs @@ -409,15 +409,16 @@ impl CanonicalView { /// of its unsettled ancestors, taints; otherwise it counts toward `trusted_pending`. See /// [Taint](#taint) below. /// * `is_settled` - Function that returns `true` for the [position](ChainPosition) of an output - /// that should be treated as settled (i.e. confirmed deeply enough to count toward - /// `confirmed`/`immature`). Outputs whose position is not settled are pending. `is_settled` is - /// the sole authority on this boundary — a settled output is never dropped or tainted. See - /// [Settled](#settled) below. + /// whose transaction is considered settled — unlikely to be replaced (e.g. confirmed deeply + /// enough). Settled outputs count toward `settled`/`immature`; the rest are pending. + /// `is_settled` is the sole authority on this boundary — a settled output is never dropped or + /// tainted. See [Settled](#settled) below. /// /// # Settled /// - /// `is_settled` controls the boundary between confirmed and pending outputs. Typically it - /// checks that an output has at least some number of confirmations, for example: + /// `is_settled` controls the boundary between settled and pending outputs — i.e. whether we are + /// confident a transaction will not be replaced. Typically it checks that an output has at + /// least some number of confirmations, for example: /// /// ``` /// # use bdk_chain::ChainPosition; @@ -507,7 +508,7 @@ impl CanonicalView { let mut immature = Amount::ZERO; let mut trusted_pending = Amount::ZERO; let mut untrusted_pending = Amount::ZERO; - let mut confirmed = Amount::ZERO; + let mut settled = Amount::ZERO; for txout in utxos { if is_settled(&txout.pos) { @@ -515,7 +516,7 @@ impl CanonicalView { // `is_settled` alone (not on a confirmation height), so a caller is free to treat // unconfirmed outputs as settled without their value being dropped. if txout.is_mature(self.tip.height) { - confirmed += txout.txout.value; + settled += txout.txout.value; } else { immature += txout.txout.value; } @@ -530,7 +531,7 @@ impl CanonicalView { immature, trusted_pending, untrusted_pending, - confirmed, + settled, } } } diff --git a/crates/chain/tests/test_canonical_view.rs b/crates/chain/tests/test_canonical_view.rs index 1183595f0..b7e6ab15e 100644 --- a/crates/chain/tests/test_canonical_view.rs +++ b/crates/chain/tests/test_canonical_view.rs @@ -76,7 +76,7 @@ fn test_min_confirmations_parameter() { settled(tip_height, 1), ); - assert_eq!(balance_1_conf.confirmed, Amount::from_sat(50_000)); + assert_eq!(balance_1_conf.settled, Amount::from_sat(50_000)); assert_eq!(balance_1_conf.trusted_pending, Amount::ZERO); // Test min_confirmations = 6: Should be confirmed (has exactly 6 confirmations) @@ -85,7 +85,7 @@ fn test_min_confirmations_parameter() { |_| false, // taint nothing settled(tip_height, 6), ); - assert_eq!(balance_6_conf.confirmed, Amount::from_sat(50_000)); + assert_eq!(balance_6_conf.settled, Amount::from_sat(50_000)); assert_eq!(balance_6_conf.trusted_pending, Amount::ZERO); // Test min_confirmations = 7: Should be trusted pending (only has 6 confirmations) @@ -94,7 +94,7 @@ fn test_min_confirmations_parameter() { |_| false, // taint nothing settled(tip_height, 7), ); - assert_eq!(balance_7_conf.confirmed, Amount::ZERO); + assert_eq!(balance_7_conf.settled, Amount::ZERO); assert_eq!(balance_7_conf.trusted_pending, Amount::from_sat(50_000)); // Test min_confirmations = 0: Should behave same as 1 (confirmed) @@ -103,7 +103,7 @@ fn test_min_confirmations_parameter() { |_| false, // taint nothing settled(tip_height, 0), ); - assert_eq!(balance_0_conf.confirmed, Amount::from_sat(50_000)); + assert_eq!(balance_0_conf.settled, Amount::from_sat(50_000)); assert_eq!(balance_0_conf.trusted_pending, Amount::ZERO); assert_eq!(balance_0_conf, balance_1_conf); } @@ -167,7 +167,7 @@ fn test_min_confirmations_with_untrusted_tx() { ); // Should be untrusted pending (not deep enough to be settled, and tainted) - assert_eq!(balance.confirmed, Amount::ZERO); + assert_eq!(balance.settled, Amount::ZERO); assert_eq!(balance.trusted_pending, Amount::ZERO); assert_eq!(balance.untrusted_pending, Amount::from_sat(25_000)); } @@ -285,7 +285,7 @@ fn test_min_confirmations_multiple_transactions() { let balance = canonical_view.balance(outpoints.clone(), |_| false, settled(tip_height, 5)); assert_eq!( - balance.confirmed, + balance.settled, Amount::from_sat(10_000 + 20_000) // tx0 + tx1 ); assert_eq!( @@ -301,7 +301,7 @@ fn test_min_confirmations_multiple_transactions() { let balance_high = canonical_view.balance(outpoints, |_| false, settled(tip_height, 10)); assert_eq!( - balance_high.confirmed, + balance_high.settled, Amount::from_sat(10_000) // only tx0 ); assert_eq!( @@ -424,7 +424,7 @@ fn test_balance_taint_propagates_through_unconfirmed_ancestry() { |pos| pos.is_confirmed(), ); - assert_eq!(balance.confirmed, Amount::ZERO); + assert_eq!(balance.settled, Amount::ZERO); assert_eq!(balance.immature, Amount::ZERO); // `trusted` spends only our own (confirmed) coin -> trusted. assert_eq!(balance.trusted_pending, Amount::from_sat(40_000)); @@ -461,7 +461,7 @@ fn test_balance_is_settled_is_authoritative_for_unconfirmed() { // An `is_settled` that claims everything is settled counts the (mature, non-coinbase) // unconfirmed output as settled rather than dropping it. let balance = view.balance([OutPoint::new(txid, 0)], |_| false, |_| true); - assert_eq!(balance.confirmed, Amount::from_sat(50_000)); + assert_eq!(balance.settled, Amount::from_sat(50_000)); assert_eq!(balance.immature, Amount::ZERO); assert_eq!(balance.trusted_pending, Amount::ZERO); assert_eq!(balance.untrusted_pending, Amount::ZERO); diff --git a/crates/chain/tests/test_indexed_tx_graph.rs b/crates/chain/tests/test_indexed_tx_graph.rs index 7ab35b979..d64150d2b 100644 --- a/crates/chain/tests/test_indexed_tx_graph.rs +++ b/crates/chain/tests/test_indexed_tx_graph.rs @@ -579,7 +579,7 @@ fn test_list_owned_txouts() { immature: Amount::from_sat(70000), // immature coinbase trusted_pending: Amount::from_sat(45000), // tx3, tx4, tx5 (nothing tainted) untrusted_pending: Amount::ZERO, - confirmed: Amount::ZERO // Nothing is confirmed yet + settled: Amount::ZERO // Nothing is confirmed yet } ); } @@ -617,7 +617,7 @@ fn test_list_owned_txouts() { immature: Amount::from_sat(70000), // immature coinbase trusted_pending: Amount::from_sat(45000), // tx3, tx4, tx5 (nothing tainted) untrusted_pending: Amount::ZERO, - confirmed: Amount::from_sat(0) // tx2 got confirmed (but spent by 3) + settled: Amount::from_sat(0) // tx2 got confirmed (but spent by 3) } ); } @@ -658,7 +658,7 @@ fn test_list_owned_txouts() { immature: Amount::from_sat(70000), // immature coinbase trusted_pending: Amount::from_sat(35000), // tx4, tx5 (nothing tainted) untrusted_pending: Amount::ZERO, - confirmed: Amount::from_sat(10000) // tx3 got confirmed + settled: Amount::from_sat(10000) // tx3 got confirmed } ); } @@ -699,7 +699,7 @@ fn test_list_owned_txouts() { immature: Amount::from_sat(70000), // immature coinbase trusted_pending: Amount::from_sat(35000), // tx4, tx5 (nothing tainted) untrusted_pending: Amount::ZERO, - confirmed: Amount::from_sat(10000) // tx3 is confirmed + settled: Amount::from_sat(10000) // tx3 is confirmed } ); } @@ -715,7 +715,7 @@ fn test_list_owned_txouts() { immature: Amount::ZERO, // coinbase matured trusted_pending: Amount::from_sat(35000), // tx4, tx5 (nothing tainted) untrusted_pending: Amount::ZERO, - confirmed: Amount::from_sat(80000) // tx1 + tx3 + settled: Amount::from_sat(80000) // tx1 + tx3 } ); } diff --git a/crates/chain/tests/test_tx_graph_conflicts.rs b/crates/chain/tests/test_tx_graph_conflicts.rs index c6360ddd8..cab961928 100644 --- a/crates/chain/tests/test_tx_graph_conflicts.rs +++ b/crates/chain/tests/test_tx_graph_conflicts.rs @@ -82,7 +82,7 @@ fn test_tx_conflict_handling() { exp_chain_txouts: HashSet::from([("confirmed_genesis", 0), ("confirmed_conflict", 0)]), exp_unspents: HashSet::from([("confirmed_conflict", 0)]), exp_balance: Balance { - confirmed: Amount::from_sat(20000), + settled: Amount::from_sat(20000), ..Default::default() }, }, @@ -119,7 +119,7 @@ fn test_tx_conflict_handling() { immature: Amount::ZERO, trusted_pending: Amount::from_sat(30000), untrusted_pending: Amount::ZERO, - confirmed: Amount::ZERO, + settled: Amount::ZERO, }, }, Scenario { @@ -155,7 +155,7 @@ fn test_tx_conflict_handling() { immature: Amount::ZERO, trusted_pending: Amount::from_sat(30000), untrusted_pending: Amount::ZERO, - confirmed: Amount::ZERO, + settled: Amount::ZERO, }, }, Scenario { @@ -198,7 +198,7 @@ fn test_tx_conflict_handling() { immature: Amount::ZERO, trusted_pending: Amount::from_sat(40000), untrusted_pending: Amount::ZERO, - confirmed: Amount::ZERO, + settled: Amount::ZERO, }, }, Scenario { @@ -235,7 +235,7 @@ fn test_tx_conflict_handling() { immature: Amount::ZERO, trusted_pending: Amount::from_sat(30000), untrusted_pending: Amount::ZERO, - confirmed: Amount::ZERO, + settled: Amount::ZERO, }, }, Scenario { @@ -272,7 +272,7 @@ fn test_tx_conflict_handling() { immature: Amount::ZERO, trusted_pending: Amount::from_sat(20000), untrusted_pending: Amount::ZERO, - confirmed: Amount::ZERO, + settled: Amount::ZERO, }, }, Scenario { @@ -322,7 +322,7 @@ fn test_tx_conflict_handling() { immature: Amount::ZERO, trusted_pending: Amount::ZERO, untrusted_pending: Amount::ZERO, - confirmed: Amount::from_sat(50000), + settled: Amount::from_sat(50000), }, }, Scenario { @@ -367,7 +367,7 @@ fn test_tx_conflict_handling() { immature: Amount::ZERO, trusted_pending: Amount::from_sat(30000), untrusted_pending: Amount::ZERO, - confirmed: Amount::ZERO, + settled: Amount::ZERO, }, }, Scenario { @@ -409,7 +409,7 @@ fn test_tx_conflict_handling() { immature: Amount::ZERO, trusted_pending: Amount::ZERO, untrusted_pending: Amount::ZERO, - confirmed: Amount::from_sat(20000), + settled: Amount::from_sat(20000), }, }, Scenario { @@ -456,7 +456,7 @@ fn test_tx_conflict_handling() { immature: Amount::ZERO, trusted_pending: Amount::from_sat(30000), untrusted_pending: Amount::ZERO, - confirmed: Amount::ZERO, + settled: Amount::ZERO, }, }, Scenario { @@ -502,7 +502,7 @@ fn test_tx_conflict_handling() { immature: Amount::ZERO, trusted_pending: Amount::from_sat(30000), untrusted_pending: Amount::ZERO, - confirmed: Amount::ZERO, + settled: Amount::ZERO, }, }, Scenario { @@ -548,7 +548,7 @@ fn test_tx_conflict_handling() { immature: Amount::ZERO, trusted_pending: Amount::ZERO, untrusted_pending: Amount::ZERO, - confirmed: Amount::from_sat(50000), + settled: Amount::from_sat(50000), }, }, Scenario { @@ -600,7 +600,7 @@ fn test_tx_conflict_handling() { immature: Amount::ZERO, trusted_pending: Amount::ZERO, untrusted_pending: Amount::ZERO, - confirmed: Amount::from_sat(50000), + settled: Amount::from_sat(50000), }, }, Scenario { @@ -633,7 +633,7 @@ fn test_tx_conflict_handling() { immature: Amount::ZERO, trusted_pending: Amount::ZERO, untrusted_pending: Amount::ZERO, - confirmed: Amount::from_sat(800), + settled: Amount::from_sat(800), } }, Scenario { @@ -672,7 +672,7 @@ fn test_tx_conflict_handling() { exp_chain_txouts: HashSet::from([("root", 0), ("transitively_anchored_conflict", 0), ("anchored", 0)]), exp_unspents: HashSet::from([("anchored", 0)]), exp_balance: Balance { - confirmed: Amount::from_sat(8000), + settled: Amount::from_sat(8000), ..Default::default() } }, @@ -848,7 +848,7 @@ fn test_tx_conflict_handling() { immature: Amount::ZERO, trusted_pending: Amount::from_sat(19_000), untrusted_pending: Amount::ZERO, - confirmed: Amount::from_sat(21_000), + settled: Amount::from_sat(21_000), }, }, Scenario { @@ -896,7 +896,7 @@ fn test_tx_conflict_handling() { immature: Amount::ZERO, trusted_pending: Amount::from_sat(19_000), untrusted_pending: Amount::ZERO, - confirmed: Amount::from_sat(21_000), + settled: Amount::from_sat(21_000), }, }, Scenario { @@ -940,7 +940,7 @@ fn test_tx_conflict_handling() { immature: Amount::ZERO, trusted_pending: Amount::from_sat(18_000), untrusted_pending: Amount::ZERO, - confirmed: Amount::ZERO, + settled: Amount::ZERO, }, }, Scenario { @@ -962,7 +962,7 @@ fn test_tx_conflict_handling() { immature: Amount::ZERO, trusted_pending: Amount::ZERO, untrusted_pending: Amount::ZERO, - confirmed: Amount::ZERO, + settled: Amount::ZERO, } } ]; @@ -1029,7 +1029,8 @@ fn test_tx_conflict_handling() { let balance = canonical_view.balance( env.indexer.outpoints().iter().map(|(_, op)| *op), - // All these outpoints are owned (so were "trusted" under the old predicate); taint none. + // All these outpoints are owned (so were "trusted" under the old predicate); taint + // none. |_| false, |pos| pos.is_confirmed(), ); diff --git a/crates/electrum/tests/test_electrum.rs b/crates/electrum/tests/test_electrum.rs index 810c15dc4..6106f7913 100644 --- a/crates/electrum/tests/test_electrum.rs +++ b/crates/electrum/tests/test_electrum.rs @@ -617,7 +617,7 @@ fn test_sync() -> anyhow::Result<()> { assert_eq!( get_balance(&recv_chain, &recv_graph)?, Balance { - confirmed: SEND_AMOUNT, + settled: SEND_AMOUNT, ..Balance::default() }, "balance must be correct", @@ -653,7 +653,7 @@ fn test_sync() -> anyhow::Result<()> { assert_eq!( get_balance(&recv_chain, &recv_graph)?, Balance { - confirmed: SEND_AMOUNT, + settled: SEND_AMOUNT, ..Balance::default() }, "balance must be correct", @@ -750,7 +750,7 @@ fn tx_can_become_unconfirmed_after_reorg() -> anyhow::Result<()> { assert_eq!( get_balance(&recv_chain, &recv_graph)?, Balance { - confirmed: SEND_AMOUNT * REORG_COUNT as u64, + settled: SEND_AMOUNT * REORG_COUNT as u64, ..Balance::default() }, "initial balance must be correct", @@ -775,7 +775,7 @@ fn tx_can_become_unconfirmed_after_reorg() -> anyhow::Result<()> { get_balance(&recv_chain, &recv_graph)?, Balance { trusted_pending: SEND_AMOUNT * depth as u64, - confirmed: SEND_AMOUNT * (REORG_COUNT - depth) as u64, + settled: SEND_AMOUNT * (REORG_COUNT - depth) as u64, ..Balance::default() }, "reorg_count: {depth}", @@ -901,7 +901,7 @@ fn test_check_fee_calculation() -> anyhow::Result<()> { assert_eq!( get_balance(&recv_chain, &recv_graph)?, Balance { - confirmed: SEND_AMOUNT, + settled: SEND_AMOUNT, ..Balance::default() }, ); From ab0c5a2bc39f7ba6fac8d9bf460a058896ae4763 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=BF=97=E5=AE=87?= Date: Wed, 24 Jun 2026 04:33:01 +0000 Subject: [PATCH 5/6] feat(chain): add `classify_outpoints` spend-eligibility classifier MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- crates/chain/src/canonical.rs | 162 +++++++++++++++------- crates/chain/tests/test_canonical_view.rs | 25 +++- 2 files changed, 134 insertions(+), 53 deletions(-) diff --git a/crates/chain/src/canonical.rs b/crates/chain/src/canonical.rs index bfd94a8bd..e6f59ac7f 100644 --- a/crates/chain/src/canonical.rs +++ b/crates/chain/src/canonical.rs @@ -29,9 +29,7 @@ use alloc::vec::Vec; use core::{fmt, ops::RangeBounds}; use bdk_core::BlockId; -use bitcoin::{ - constants::COINBASE_MATURITY, Amount, OutPoint, ScriptBuf, Transaction, TxOut, Txid, -}; +use bitcoin::{constants::COINBASE_MATURITY, OutPoint, ScriptBuf, Transaction, TxOut, Txid}; use crate::{spk_txout::SpkTxOutIndex, Anchor, Balance, CanonicalViewTask, ChainPosition, TxGraph}; @@ -394,25 +392,45 @@ impl Canonical { } } +/// The spend-eligibility classification of a canonical output, produced by +/// [`CanonicalView::classify_outpoints`]. +/// +/// This is a *chain-level* classification: it captures only what the chain can determine +/// (settled-ness, maturity, and taint). It deliberately knows nothing about wallet policies such as +/// locked or reserved coins — callers layer their own categories on top. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum Eligibility { + /// A settled coinbase output that has not yet matured. Not spendable. + Immature, + /// Settled: the output's transaction is considered unlikely to be replaced. + Settled, + /// Pending (not settled), and neither it nor any of its unsettled ancestors taints it. + TrustedPending, + /// Pending (not settled), and it or one of its unsettled ancestors taints it. + UntrustedPending, +} + impl CanonicalView { - /// Calculate the total balance of the given outpoints. + /// Classify each of the given `outpoints` by its [spend eligibility](Eligibility). /// - /// This method computes a detailed balance breakdown for a set of outpoints, categorizing - /// outputs as confirmed, pending (trusted/untrusted), or immature based on their chain - /// position and the provided trust predicate. + /// This is the primitive behind [`balance`](Self::balance) and is the building block for coin + /// selection / coin control: it yields each unspent output paired with a chain-level + /// [`Eligibility`], leaving aggregation (and any wallet-specific categories like "locked") to + /// the caller. Spent outpoints, and outpoints not in this canonical view, are skipped. /// /// # Arguments /// - /// * `outpoints` - Iterator of outpoints to calculate balance for + /// * `outpoints` - Iterator of outpoints to classify. /// * `does_taint` - Function that returns `true` for transactions that should *taint* their - /// descendants. A pending output counts toward `untrusted_pending` if its transaction, or any - /// of its unsettled ancestors, taints; otherwise it counts toward `trusted_pending`. See - /// [Taint](#taint) below. + /// descendants. A pending output is [`UntrustedPending`](Eligibility::UntrustedPending) if + /// its transaction, or any of its unsettled ancestors, taints; otherwise + /// [`TrustedPending`](Eligibility::TrustedPending). See [Taint](#taint) below. /// * `is_settled` - Function that returns `true` for the [position](ChainPosition) of an output /// whose transaction is considered settled — unlikely to be replaced (e.g. confirmed deeply - /// enough). Settled outputs count toward `settled`/`immature`; the rest are pending. - /// `is_settled` is the sole authority on this boundary — a settled output is never dropped or - /// tainted. See [Settled](#settled) below. + /// enough). Settled outputs are [`Settled`](Eligibility::Settled) or + /// [`Immature`](Eligibility::Immature); the rest are pending. `is_settled` is the sole + /// authority on this boundary — a settled output is never tainted. See [Settled](#settled) + /// below. /// /// # Settled /// @@ -436,42 +454,44 @@ impl CanonicalView { /// /// `does_taint` decides whether a pending output is trusted. The canonical *unsettled* ancestry /// of each pending output is walked (stopping at settled transactions); if `does_taint` returns - /// `true` for the output's own transaction or any walked ancestor, the output counts toward - /// `untrusted_pending`, otherwise toward `trusted_pending`. + /// `true` for the output's own transaction or any walked ancestor, the output is + /// [`UntrustedPending`](Eligibility::UntrustedPending), otherwise + /// [`TrustedPending`](Eligibility::TrustedPending). /// /// A common use is to taint transactions that spend outputs the wallet does not own while /// unconfirmed (i.e. unconfirmed coins received from, or chained on top of, a third party). - /// Returning `false` for every transaction treats all pending owned outputs as trusted; - /// returning `true` treats them all as untrusted. + /// Returning `false` for every transaction treats all pending outputs as trusted; returning + /// `true` treats them all as untrusted. /// /// # Example /// /// ``` - /// # use bdk_chain::{CanonicalParams, ChainPosition, TxGraph, local_chain::LocalChain, keychain_txout::KeychainTxOutIndex}; + /// # use bdk_chain::{CanonicalParams, ChainPosition, Eligibility, TxGraph, local_chain::LocalChain, keychain_txout::KeychainTxOutIndex}; /// # use bdk_core::BlockId; /// # use bitcoin::hashes::Hash; /// # let tx_graph = TxGraph::::default(); /// # let chain = LocalChain::from_blocks([(0, bitcoin::BlockHash::all_zeros())].into_iter().collect()).unwrap(); - /// # let chain_tip = chain.tip().block_id(); - /// # let view = chain.canonical_view(&tx_graph, chain_tip, CanonicalParams::default()); + /// # let view = chain.canonical_view(&tx_graph, chain.tip().block_id(), CanonicalParams::default()); /// # let indexer = KeychainTxOutIndex::<&str>::default(); - /// let tip_height = view.tip().height; - /// // Calculate balance requiring 6 confirmations, tainting nothing (all pending trusted) - /// let balance = view.balance( + /// // Coin control: prefer settled coins, fall back to trusted-pending; never spend the rest. + /// let mut candidates = vec![]; + /// for (txout, eligibility) in view.classify_outpoints( /// indexer.outpoints().iter().map(|(_, op)| *op), /// |_tx| false, // Never taint - /// |pos: &ChainPosition<_>| { - /// pos.confirmation_height_upper_bound() - /// .is_some_and(|h| tip_height.saturating_sub(h).saturating_add(1) >= 6) - /// }, - /// ); + /// |pos: &ChainPosition<_>| pos.confirmation_height_upper_bound().is_some(), + /// ) { + /// match eligibility { + /// Eligibility::Settled | Eligibility::TrustedPending => candidates.push(txout.outpoint), + /// Eligibility::Immature | Eligibility::UntrustedPending => {} + /// } + /// } /// ``` - pub fn balance( + pub fn classify_outpoints( &self, outpoints: impl IntoIterator, mut does_taint: impl FnMut(CanonicalTx>) -> bool, is_settled: impl Fn(&ChainPosition) -> bool, - ) -> Balance { + ) -> impl Iterator>, Eligibility)> { let utxos = outpoints .into_iter() .filter_map(|op| self.txout(op)) @@ -505,34 +525,72 @@ impl CanonicalView { tainted }; - let mut immature = Amount::ZERO; - let mut trusted_pending = Amount::ZERO; - let mut untrusted_pending = Amount::ZERO; - let mut settled = Amount::ZERO; - - for txout in utxos { - if is_settled(&txout.pos) { - // Settled outputs count as settled unless they are an immature coinbase. We rely on + let tip = self.tip.height; + utxos.into_iter().map(move |txout| { + let eligibility = if is_settled(&txout.pos) { + // Settled outputs are settled unless they are an immature coinbase. We rely on // `is_settled` alone (not on a confirmation height), so a caller is free to treat - // unconfirmed outputs as settled without their value being dropped. - if txout.is_mature(self.tip.height) { - settled += txout.txout.value; + // unconfirmed outputs as settled. + if txout.is_mature(tip) { + Eligibility::Settled } else { - immature += txout.txout.value; + Eligibility::Immature } } else if tainted.contains(&txout.outpoint.txid) { - untrusted_pending += txout.txout.value; + Eligibility::UntrustedPending } else { - trusted_pending += txout.txout.value; - } - } + Eligibility::TrustedPending + }; + (txout, eligibility) + }) + } - Balance { - immature, - trusted_pending, - untrusted_pending, - settled, + /// Calculate the total [`Balance`] of the given `outpoints`. + /// + /// This is a convenience fold over [`classify_outpoints`](Self::classify_outpoints): each + /// output's value is added to the [`Balance`] bucket matching its [`Eligibility`]. See + /// [`classify_outpoints`](Self::classify_outpoints) for the meaning of `does_taint` and + /// `is_settled`, and for richer per-output handling (e.g. coin control). + /// + /// # Example + /// + /// ``` + /// # use bdk_chain::{CanonicalParams, ChainPosition, TxGraph, local_chain::LocalChain, keychain_txout::KeychainTxOutIndex}; + /// # use bdk_core::BlockId; + /// # use bitcoin::hashes::Hash; + /// # let tx_graph = TxGraph::::default(); + /// # let chain = LocalChain::from_blocks([(0, bitcoin::BlockHash::all_zeros())].into_iter().collect()).unwrap(); + /// # let chain_tip = chain.tip().block_id(); + /// # let view = chain.canonical_view(&tx_graph, chain_tip, CanonicalParams::default()); + /// # let indexer = KeychainTxOutIndex::<&str>::default(); + /// let tip_height = view.tip().height; + /// // Calculate balance requiring 6 confirmations, tainting nothing (all pending trusted) + /// let balance = view.balance( + /// indexer.outpoints().iter().map(|(_, op)| *op), + /// |_tx| false, // Never taint + /// |pos: &ChainPosition<_>| { + /// pos.confirmation_height_upper_bound() + /// .is_some_and(|h| tip_height.saturating_sub(h).saturating_add(1) >= 6) + /// }, + /// ); + /// ``` + pub fn balance( + &self, + outpoints: impl IntoIterator, + does_taint: impl FnMut(CanonicalTx>) -> bool, + is_settled: impl Fn(&ChainPosition) -> bool, + ) -> Balance { + let mut balance = Balance::default(); + for (txout, eligibility) in self.classify_outpoints(outpoints, does_taint, is_settled) { + let bucket = match eligibility { + Eligibility::Immature => &mut balance.immature, + Eligibility::Settled => &mut balance.settled, + Eligibility::TrustedPending => &mut balance.trusted_pending, + Eligibility::UntrustedPending => &mut balance.untrusted_pending, + }; + *bucket += txout.txout.value; } + balance } } diff --git a/crates/chain/tests/test_canonical_view.rs b/crates/chain/tests/test_canonical_view.rs index b7e6ab15e..2196c8533 100644 --- a/crates/chain/tests/test_canonical_view.rs +++ b/crates/chain/tests/test_canonical_view.rs @@ -2,7 +2,7 @@ use std::collections::BTreeMap; -use bdk_chain::{local_chain::LocalChain, ConfirmationBlockTime, TxGraph}; +use bdk_chain::{local_chain::LocalChain, ConfirmationBlockTime, Eligibility, TxGraph}; use bdk_testenv::{hash, utils::new_tx}; use bitcoin::{Amount, BlockHash, OutPoint, ScriptBuf, Transaction, TxIn, TxOut}; @@ -430,6 +430,29 @@ fn test_balance_taint_propagates_through_unconfirmed_ancestry() { assert_eq!(balance.trusted_pending, Amount::from_sat(40_000)); // `chained` inherits taint from its foreign-funded ancestor `foreign`. assert_eq!(balance.untrusted_pending, Amount::from_sat(25_000)); + + // `balance` is a fold over `classify_outpoints`; the per-output classification agrees. + let by_op = view + .classify_outpoints( + utxos, + |c_tx| { + c_tx.tx + .input + .iter() + .any(|txin| !owned.contains(&txin.previous_output)) + }, + |pos| pos.is_confirmed(), + ) + .map(|(txout, eligibility)| (txout.outpoint, eligibility)) + .collect::>(); + assert_eq!( + by_op[&OutPoint::new(trusted_txid, 0)], + Eligibility::TrustedPending + ); + assert_eq!( + by_op[&OutPoint::new(chained_txid, 0)], + Eligibility::UntrustedPending + ); } /// `is_settled` is the sole authority on the settled boundary: a caller may treat an unconfirmed From 71c35d9b420971d795eb913dc3404c42c7ea1015 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=BF=97=E5=AE=87?= Date: Wed, 24 Jun 2026 05:17:42 +0000 Subject: [PATCH 6/6] test(chain): cover taint boundary, immature classification, inclusive walk MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .../chain/tests/test_canonical_ancestors.rs | 54 ++++++ crates/chain/tests/test_canonical_view.rs | 157 ++++++++++++++++++ 2 files changed, 211 insertions(+) diff --git a/crates/chain/tests/test_canonical_ancestors.rs b/crates/chain/tests/test_canonical_ancestors.rs index 85f259203..09daf609b 100644 --- a/crates/chain/tests/test_canonical_ancestors.rs +++ b/crates/chain/tests/test_canonical_ancestors.rs @@ -210,3 +210,57 @@ fn ancestors_pruning_stops_a_branch() { .collect::>() ); } + +#[test] +fn ancestors_inclusive_yields_seeds() { + let (chain, tx_graph, [_tx_a, _tx_b, _tx_c, tx_d]) = diamond(); + let txid_d = tx_d.compute_txid(); + + let view = chain.canonical_view(&tx_graph, chain.tip().block_id(), Default::default()); + + let result: Vec<_> = view + .ancestors_inclusive([txid_d], collect_self, |_ctx| true) + .collect(); + + // All four transactions are yielded (the seed `tx_d` plus its three ancestors), once each. + let yielded: HashSet = result.iter().map(|(_, ct)| ct.txid).collect(); + assert_eq!(yielded.len(), 4); + assert!(yielded.contains(&txid_d)); + + // The seed is yielded with only its own contribution (it has no in-set descendants). + let acc_of_d = &result.iter().find(|(_, ct)| ct.txid == txid_d).unwrap().0; + assert_eq!(*acc_of_d, [txid_d].into_iter().collect::>()); + + // `len()` accounts for the seed too. + let it = view.ancestors_inclusive([txid_d], collect_self, |_ctx| true); + assert_eq!(it.len(), 4); + assert_eq!(it.count(), 4); +} + +#[test] +fn ancestors_multiple_seeds_dedup_shared_ancestor() { + let (chain, tx_graph, [tx_a, tx_b, tx_c, _tx_d]) = diamond(); + let (txid_a, txid_b, txid_c) = ( + tx_a.compute_txid(), + tx_b.compute_txid(), + tx_c.compute_txid(), + ); + + let view = chain.canonical_view(&tx_graph, chain.tip().block_id(), Default::default()); + + // `tx_b` and `tx_c` both spend `tx_a`. Their shared ancestor must be visited once. + let result: Vec<_> = view + .ancestors([txid_b, txid_c], collect_self, |_ctx| true) + .collect(); + + // Only `tx_a` is yielded (the two seeds are excluded), exactly once. + let order: Vec = result.iter().map(|(_, ct)| ct.txid).collect(); + assert_eq!(order, vec![txid_a]); + // Both seeds merged into it. + assert_eq!( + result[0].0, + [txid_a, txid_b, txid_c] + .into_iter() + .collect::>() + ); +} diff --git a/crates/chain/tests/test_canonical_view.rs b/crates/chain/tests/test_canonical_view.rs index 2196c8533..b63bd370d 100644 --- a/crates/chain/tests/test_canonical_view.rs +++ b/crates/chain/tests/test_canonical_view.rs @@ -489,3 +489,160 @@ fn test_balance_is_settled_is_authoritative_for_unconfirmed() { assert_eq!(balance.trusted_pending, Amount::ZERO); assert_eq!(balance.untrusted_pending, Amount::ZERO); } + +/// Taint must not cross the settled boundary: a settled (mined) ancestor that `does_taint` would +/// flag does not taint its unsettled descendants, because the walk stops at — and never taints — +/// settled transactions. +#[test] +fn test_balance_taint_stops_at_settled_ancestor() { + use std::collections::HashSet; + + let blocks: BTreeMap = [(0, hash!("g")), (1, hash!("b1")), (2, hash!("tip"))] + .into_iter() + .collect(); + let chain = LocalChain::from_blocks(blocks).unwrap(); + let mut tx_graph = TxGraph::::default(); + let owned_spk = ScriptBuf::new(); + + // A *settled* (confirmed) tx that itself spends a third-party coin — `does_taint` would flag + // it. + let settled_foreign = Transaction { + input: vec![TxIn { + previous_output: OutPoint::new(hash!("third_party"), 0), + ..Default::default() + }], + output: vec![TxOut { + value: Amount::from_sat(50_000), + script_pubkey: owned_spk.clone(), + }], + ..new_tx(0) + }; + let settled_foreign_txid = settled_foreign.compute_txid(); + + // Unconfirmed child spending our own (settled) output. + let child = Transaction { + input: vec![TxIn { + previous_output: OutPoint::new(settled_foreign_txid, 0), + ..Default::default() + }], + output: vec![TxOut { + value: Amount::from_sat(45_000), + script_pubkey: owned_spk.clone(), + }], + ..new_tx(1) + }; + let child_txid = child.compute_txid(); + + let _ = tx_graph.insert_tx(settled_foreign.clone()); + let _ = tx_graph.insert_anchor( + settled_foreign_txid, + ConfirmationBlockTime { + block_id: chain.get(1).unwrap().block_id(), + confirmation_time: 100, + }, + ); + let _ = tx_graph.insert_tx(child.clone()); + let _ = tx_graph.insert_seen_at(child_txid, 1000); + + let owned = [ + OutPoint::new(settled_foreign_txid, 0), + OutPoint::new(child_txid, 0), + ] + .into_iter() + .collect::>(); + + let view = chain.canonical_view(&tx_graph, chain.tip().block_id(), Default::default()); + + // `child` is the only UTXO (`settled_foreign:0` is spent by it). + let by_op = view + .classify_outpoints( + [OutPoint::new(child_txid, 0)], + |c_tx| { + c_tx.tx + .input + .iter() + .any(|txin| !owned.contains(&txin.previous_output)) + }, + |pos| pos.is_confirmed(), + ) + .map(|(txout, eligibility)| (txout.outpoint, eligibility)) + .collect::>(); + + // The foreign-spending ancestor is settled, so the walk stops there and never taints `child`. + assert_eq!( + by_op[&OutPoint::new(child_txid, 0)], + Eligibility::TrustedPending + ); +} + +/// `classify_outpoints` distinguishes an immature coinbase from a settled output. +#[test] +fn test_classify_immature_and_settled() { + let blocks: BTreeMap = [(0, hash!("g")), (1, hash!("b1")), (2, hash!("tip"))] + .into_iter() + .collect(); + let chain = LocalChain::from_blocks(blocks).unwrap(); + let mut tx_graph = TxGraph::::default(); + let spk = ScriptBuf::new(); + + // Coinbase confirmed at height 1; far below `COINBASE_MATURITY` → immature. + let coinbase = Transaction { + input: vec![TxIn { + previous_output: OutPoint::null(), + ..Default::default() + }], + output: vec![TxOut { + value: Amount::from_sat(50_000), + script_pubkey: spk.clone(), + }], + ..new_tx(0) + }; + let coinbase_txid = coinbase.compute_txid(); + assert!(coinbase.is_coinbase()); + + // A normal (non-coinbase) confirmed output. + let normal = Transaction { + input: vec![TxIn { + previous_output: OutPoint::new(hash!("ext"), 0), + ..Default::default() + }], + output: vec![TxOut { + value: Amount::from_sat(30_000), + script_pubkey: spk.clone(), + }], + ..new_tx(1) + }; + let normal_txid = normal.compute_txid(); + + for (txid, tx) in [(coinbase_txid, &coinbase), (normal_txid, &normal)] { + let _ = tx_graph.insert_tx(tx.clone()); + let _ = tx_graph.insert_anchor( + txid, + ConfirmationBlockTime { + block_id: chain.get(1).unwrap().block_id(), + confirmation_time: 100, + }, + ); + } + + let view = chain.canonical_view(&tx_graph, chain.tip().block_id(), Default::default()); + let ops = [ + OutPoint::new(coinbase_txid, 0), + OutPoint::new(normal_txid, 0), + ]; + + let by_op = view + .classify_outpoints(ops, |_| false, |pos| pos.is_confirmed()) + .map(|(txout, eligibility)| (txout.outpoint, eligibility)) + .collect::>(); + assert_eq!( + by_op[&OutPoint::new(coinbase_txid, 0)], + Eligibility::Immature + ); + assert_eq!(by_op[&OutPoint::new(normal_txid, 0)], Eligibility::Settled); + + // The balance buckets reflect the same classification. + let balance = view.balance(ops, |_| false, |pos| pos.is_confirmed()); + assert_eq!(balance.immature, Amount::from_sat(50_000)); + assert_eq!(balance.settled, Amount::from_sat(30_000)); +}