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..09daf609b
--- /dev/null
+++ b/crates/chain/tests/test_canonical_ancestors.rs
@@ -0,0 +1,266 @@
+#![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::>()
+ );
+}
+
+#[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 2cbe02103..b63bd370d 100644
--- a/crates/chain/tests/test_canonical_view.rs
+++ b/crates/chain/tests/test_canonical_view.rs
@@ -2,10 +2,22 @@
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};
+/// 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,42 +67,43 @@ 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));
+ 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)
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.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)
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.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)
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.settled, Amount::from_sat(50_000));
assert_eq!(balance_0_conf.trusted_pending, Amount::ZERO);
assert_eq!(balance_0_conf, balance_1_conf);
}
@@ -143,16 +156,18 @@ 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)
- assert_eq!(balance.confirmed, Amount::ZERO);
+ // Should be untrusted pending (not deep enough to be settled, and tainted)
+ assert_eq!(balance.settled, 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,19 +272,20 @@ 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,
+ balance.settled,
Amount::from_sat(10_000 + 20_000) // tx0 + tx1
);
assert_eq!(
@@ -282,10 +298,10 @@ 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,
+ balance_high.settled,
Amount::from_sat(10_000) // only tx0
);
assert_eq!(
@@ -294,3 +310,339 @@ 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.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));
+ // `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
+/// 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.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);
+}
+
+/// 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));
+}
diff --git a/crates/chain/tests/test_indexed_tx_graph.rs b/crates/chain/tests/test_indexed_tx_graph.rs
index 96cafcb8e..d64150d2b 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,
+ settled: 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,
+ settled: 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,
+ settled: 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,
+ settled: 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,
+ 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 f1f166984..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,
}
}
];
@@ -1028,13 +1028,11 @@ 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..6106f7913 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)
}
@@ -613,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",
@@ -649,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",
@@ -746,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",
@@ -771,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}",
@@ -897,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()
},
);