From df136809a2edaa2242c5faa7fb830e0024932f1b Mon Sep 17 00:00:00 2001 From: Dmenec Date: Wed, 22 Jul 2026 17:30:35 +0200 Subject: [PATCH 1/4] feat(chain): classify outpoints by chain-level spend eligibility MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds classify_outpoints, which decides per-output whether it's settled, immature, or pending (trusted/untrusted) based on its unsettled ancestry. Taint is resolved with a memoized ancestry walk, so ancestors shared by several outputs are only walked once. Co-authored-by: 志宇 Co-Authored-By: Claude Opus 4.8 (1M context) --- crates/chain/src/canonical.rs | 161 +++++++++++++++++++++++++++++++++- 1 file changed, 160 insertions(+), 1 deletion(-) diff --git a/crates/chain/src/canonical.rs b/crates/chain/src/canonical.rs index c2aecb756..f19222ee5 100644 --- a/crates/chain/src/canonical.rs +++ b/crates/chain/src/canonical.rs @@ -22,7 +22,7 @@ //! } //! ``` -use crate::collections::HashMap; +use crate::collections::{HashMap, HashSet}; use alloc::sync::Arc; use alloc::vec::Vec; use core::{fmt, ops::RangeBounds}; @@ -34,6 +34,23 @@ use bitcoin::{ use crate::{spk_txout::SpkTxOutIndex, Anchor, Balance, CanonicalViewTask, ChainPosition, TxGraph}; +/// The spend-eligibility classification of a canonical output, produced by +/// [`CanonicalView::classify_outpoints`]. +/// +/// This is a *chain-level* classification. It deliberately knows nothing about wallet policies such +/// as locked or reserved coins; callers layer their own categories on top. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum Eligibility { + /// A confirmed output unlikely to be replated. + Settled, + /// A settled coinbase output that has not yet matured and it is not spendable. + Immature, + /// Pending output that is tainted by itself or one of its unsettled ancestors. + UntrustedPending, + /// Pending output that is not tainted by itself or any of its unsettled ancestors. + TrustedPending, +} + /// A single canonical transaction with its position. /// /// This struct represents a transaction that has been determined to be canonical (not @@ -394,6 +411,148 @@ impl Canonical { } impl CanonicalView { + /// Classify each of the given `outpoints` by its [spend eligibility](Eligibility). + /// This is the primitive behind [`balance`](Self::balance). + /// + /// Callers that need richer handling (coin selection, coin control, or + /// wallet-specific categories like "locked") can fold over this instead of `balance`. + /// + /// Outpoints that are already spent, or that aren't part of this canonical view, are skipped. + /// + /// # Arguments + /// + /// * `outpoints` - The outpoints to classify. + /// * `does_taint` - Returns `true` for a transaction that should taint its descendants (e.g. it + /// spends an output the wallet doesn't own while unconfirmed). A pending output is + /// [`UntrustedPending`](Eligibility::UntrustedPending) if its own transaction, or any of its + /// unsettled ancestors, taints it; otherwise it's + /// [`TrustedPending`](Eligibility::TrustedPending). + /// * `is_settled` - Returns `true` for the [position](ChainPosition) of a transaction we + /// consider settled (unlikely to be replaced), for example one with enough confirmations. + /// Settled outputs are [`Settled`](Eligibility::Settled) or + /// [`Immature`](Eligibility::Immature) and are never tainted. + pub fn classify_outpoints( + &self, + outpoints: impl IntoIterator, + mut does_taint: impl FnMut(&CanonicalTx>) -> bool, + is_settled: impl Fn(&ChainPosition) -> bool, + ) -> impl Iterator>, Eligibility)> { + let utxos = outpoints + .into_iter() + .filter_map(|op| self.txout(op)) + .filter(|txo| txo.spent_by.is_none()) + .collect::>(); + + let tip = self.tip.height; + // Remembers whether a tx is tainted, so if two outpoints share an ancestor we only walk + // it once instead of redoing the same work for each outpoint. + let mut cache = HashMap::::new(); + let results: Vec<_> = utxos + .into_iter() + .map(|txout| { + let eligibility = if is_settled(&txout.pos) { + if txout.is_mature(tip) { + Eligibility::Settled + } else { + Eligibility::Immature + } + } else if self.is_ancestry_tainted( + txout.outpoint.txid, + &mut does_taint, + &is_settled, + &mut cache, + ) { + Eligibility::UntrustedPending + } else { + Eligibility::TrustedPending + }; + (txout, eligibility) + }) + .collect(); + results.into_iter() + } + + /// Returns `true` if `seed_txid`, or any of its unsettled ancestors, is tainted by + /// `does_taint`. + /// + /// Walks backwards from `seed_txid` through its unsettled ancestry using a stack. + /// Each visited transaction is cached, so an ancestor shared by several outpoints only gets + /// walked once across the calls to this method that share the same `cache`. + /// The walk stops (without tainting) at settled transactions and at transactions outside this + /// canonical view, and performs a short-circuit as soon as a tainting transaction is found. + fn is_ancestry_tainted( + &self, + seed_txid: Txid, + does_taint: &mut F, + is_settled: &S, + cache: &mut HashMap, + ) -> bool + where + F: FnMut(&CanonicalTx>) -> bool, + S: Fn(&ChainPosition) -> bool, + { + if let Some(&tainted) = cache.get(&seed_txid) { + return tainted; + } + + // `Enter`: look at the tx; if it's unsettled and not directly tainted, queue its parents. + // `Exit`: by now every parent is resolved, so the tx is tainted iff any parent is. + enum Frame { + Enter(Txid), + Exit(Txid), + } + + let mut stack = alloc::vec![Frame::Enter(seed_txid)]; + // Txids currently on the stack between their `Enter` and `Exit`, so we don't queue the + // same parent twice while it's still being processed. + let mut pending = HashSet::::new(); + + while let Some(frame) = stack.pop() { + match frame { + Frame::Enter(txid) => { + if cache.contains_key(&txid) || pending.contains(&txid) { + continue; + } + let Some(c_tx) = self.tx(txid) else { + // Not canonical + cache.insert(txid, false); + continue; + }; + if is_settled(&c_tx.pos) { + cache.insert(txid, false); + continue; + } + if does_taint(&c_tx) { + // Directly tainted + cache.insert(txid, true); + continue; + } + pending.insert(txid); + stack.push(Frame::Exit(txid)); + for txin in &c_tx.tx.input { + let parent_txid = txin.previous_output.txid; + if !cache.contains_key(&parent_txid) && !pending.contains(&parent_txid) { + stack.push(Frame::Enter(parent_txid)); + } + } + } + Frame::Exit(txid) => { + let c_tx = self.tx(txid).expect("present, checked in Enter"); + let tainted = c_tx.tx.input.iter().any(|txin| { + cache + .get(&txin.previous_output.txid) + .copied() + .unwrap_or(false) + }); + cache.insert(txid, tainted); + pending.remove(&txid); + } + } + } + + cache[&seed_txid] + } + /// Calculate the total balance of the given outpoints. /// /// This method computes a detailed balance breakdown for a set of outpoints, categorizing From 93a80cea2640d6f7f6d4e19ac7ffc0ed4a57ee03 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=BF=97=E5=AE=87?= Date: Wed, 22 Jul 2026 18:35:37 +0200 Subject: [PATCH 2/4] feat(chain)!: derive balance trust from output ancestry balance becomes a fold over classify_outpoints. A pending output's trust is now taken from its unsettled ancestry (untrusted if any unsettled ancestor taints), not from a per-output flag. BREAKING: balance takes does_taint and is_settled instead of trust_predicate and min_confirmations, and plain OutPoints instead of (identifier, outpoint) pairs. Co-authored-by: Dmenec 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 | 106 +++++------------- crates/chain/tests/test_canonical_view.rs | 59 ++++++---- crates/chain/tests/test_indexed_tx_graph.rs | 48 ++++---- crates/chain/tests/test_tx_graph_conflicts.rs | 10 +- crates/electrum/tests/test_electrum.rs | 6 +- 7 files changed, 109 insertions(+), 132 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 f19222ee5..99df85e9b 100644 --- a/crates/chain/src/canonical.rs +++ b/crates/chain/src/canonical.rs @@ -28,9 +28,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}; @@ -553,33 +551,17 @@ impl CanonicalView { cache[&seed_txid] } - /// Calculate the total balance of the given outpoints. + /// Calculate the total [`Balance`] of the given outpoints. /// - /// 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 a fold over [`classify_outpoints`](Self::classify_outpoints): each output's value is + /// added to the bucket matching its [`Eligibility`]. /// - /// # 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. - /// - /// # Minimum Confirmations - /// - /// 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). - /// - /// Outputs with fewer than `min_confirmations` are categorized as pending (trusted or - /// untrusted based on the trust predicate). + /// See `classify_outpoints` for `does_taint` and `is_settled` meaning. /// /// # 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(); @@ -587,64 +569,34 @@ 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, trusting every pending output. /// 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, + does_taint: impl FnMut(&CanonicalTx>) -> bool, + is_settled: impl Fn(&ChainPosition) -> bool, ) -> Balance { - 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; - } - } - } - } - - Balance { - immature, - trusted_pending, - untrusted_pending, - confirmed, + 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.confirmed, + 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 2cbe02103..8ac586298 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, ChainPosition, 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(&ChainPosition) -> bool { + let min_confirmations = min_confirmations.max(1); // 0 and 1 behave identically + move |pos| { + pos.confirmation_height_upper_bound() + .is_some_and(|h| tip_height.saturating_sub(h).saturating_add(1) >= min_confirmations) + } +} + #[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], + |_tx| false, // never taint + 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], + |_tx| false, // never taint + 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], + |_tx| false, // never taint + 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], + |_tx| false, // never taint + 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,12 +156,13 @@ 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 and everything tainted let balance = canonical_view.balance( - [((), outpoint)], - |_, _| false, // don't trust - 5, + [outpoint], + |_tx| true, // taint everything + settled(tip_height, 5), ); // Should be untrusted pending (not enough confirmations and not trusted) @@ -209,7 +223,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 +247,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 +271,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(), |_tx| false, settled(tip_height, 5)); assert_eq!( balance.confirmed, @@ -282,7 +297,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, |_tx| false, settled(tip_height, 10)); assert_eq!( balance_high.confirmed, 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..916405af2 100644 --- a/crates/chain/tests/test_tx_graph_conflicts.rs +++ b/crates/chain/tests/test_tx_graph_conflicts.rs @@ -1028,13 +1028,9 @@ 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), + |_| 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 3f106dff7c2debe2da5de98239f66c7c3ef8629a Mon Sep 17 00:00:00 2001 From: Dmenec Date: Wed, 22 Jul 2026 18:43:47 +0200 Subject: [PATCH 3/4] test(chain): cover taint propagation and eligibility classification MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - taint propagates through a pending output's unsettled ancestry - is_settled alone decides the settled boundary, even for unconfirmed outputs - taint never crosses a settled ancestor - an immature coinbase is classified apart from a settled output - two UTXOs sharing a tainting ancestor are both untrusted (shared taint cache) - classify_outpoints skips spent and out-of-view outpoints Co-authored-by: 志宇 Co-Authored-By: Claude Opus 4.8 (1M context) --- crates/chain/tests/test_canonical_view.rs | 487 +++++++++++++++++++++- 1 file changed, 486 insertions(+), 1 deletion(-) diff --git a/crates/chain/tests/test_canonical_view.rs b/crates/chain/tests/test_canonical_view.rs index 8ac586298..5152908f7 100644 --- a/crates/chain/tests/test_canonical_view.rs +++ b/crates/chain/tests/test_canonical_view.rs @@ -2,7 +2,9 @@ use std::collections::BTreeMap; -use bdk_chain::{local_chain::LocalChain, ChainPosition, ConfirmationBlockTime, TxGraph}; +use bdk_chain::{ + local_chain::LocalChain, ChainPosition, ConfirmationBlockTime, Eligibility, TxGraph, +}; use bdk_testenv::{hash, utils::new_tx}; use bitcoin::{Amount, BlockHash, OutPoint, ScriptBuf, Transaction, TxIn, TxOut}; @@ -309,3 +311,486 @@ fn test_min_confirmations_multiple_transactions() { ); assert_eq!(balance_high.untrusted_pending, Amount::ZERO); } + +#[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)); + + // `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.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); +} + +/// 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 settled transactions +/// and never taints them. +#[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.confirmed, Amount::from_sat(30_000)); +} + +/// Two UTXOs that share one tainting ancestor are both untrusted. Because the taint cache is shared +/// across outpoints, that common ancestor is only walked once. +#[test] +fn test_balance_taint_shared_ancestor() { + use std::collections::HashSet; + + 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 owned_spk = ScriptBuf::new(); + + // Unconfirmed, funded by a third party -> taints itself. Two outputs. + 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(), + }, + TxOut { + value: Amount::from_sat(20_000), + script_pubkey: owned_spk.clone(), + }, + ], + ..new_tx(0) + }; + let foreign_txid = foreign.compute_txid(); + + // Two children, each spending one of `foreign`'s outputs, so both share the tainting ancestor. + let child_a = Transaction { + input: vec![TxIn { + previous_output: OutPoint::new(foreign_txid, 0), + ..Default::default() + }], + output: vec![TxOut { + value: Amount::from_sat(29_000), + script_pubkey: owned_spk.clone(), + }], + ..new_tx(1) + }; + let child_a_txid = child_a.compute_txid(); + let child_b = Transaction { + input: vec![TxIn { + previous_output: OutPoint::new(foreign_txid, 1), + ..Default::default() + }], + output: vec![TxOut { + value: Amount::from_sat(19_000), + script_pubkey: owned_spk.clone(), + }], + ..new_tx(2) + }; + let child_b_txid = child_b.compute_txid(); + + for tx in [&foreign, &child_a, &child_b] { + let _ = tx_graph.insert_tx(tx.clone()); + let _ = tx_graph.insert_seen_at(tx.compute_txid(), 1000); + } + + let owned = [ + OutPoint::new(foreign_txid, 0), + OutPoint::new(foreign_txid, 1), + OutPoint::new(child_a_txid, 0), + OutPoint::new(child_b_txid, 0), + ] + .into_iter() + .collect::>(); + + let view = chain.canonical_view(&tx_graph, chain.tip().block_id(), Default::default()); + + // Both children descend from the same tainting `foreign`, so both are untrusted. + let balance = view.balance( + [ + OutPoint::new(child_a_txid, 0), + OutPoint::new(child_b_txid, 0), + ], + |c_tx| { + c_tx.tx + .input + .iter() + .any(|txin| !owned.contains(&txin.previous_output)) + }, + |pos| pos.is_confirmed(), + ); + assert_eq!(balance.trusted_pending, Amount::ZERO); + assert_eq!(balance.untrusted_pending, Amount::from_sat(29_000 + 19_000)); +} + +/// `classify_outpoints` skips outpoints that are already spent or absent from the view. +#[test] +fn test_classify_skips_spent_and_unknown() { + 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 spk = ScriptBuf::new(); + + // `parent`'s output is spent by `child`. + let parent = Transaction { + input: vec![TxIn { + previous_output: OutPoint::new(hash!("ext"), 0), + ..Default::default() + }], + output: vec![TxOut { + value: Amount::from_sat(50_000), + script_pubkey: spk.clone(), + }], + ..new_tx(0) + }; + let parent_txid = parent.compute_txid(); + let child = Transaction { + input: vec![TxIn { + previous_output: OutPoint::new(parent_txid, 0), + ..Default::default() + }], + output: vec![TxOut { + value: Amount::from_sat(40_000), + script_pubkey: spk.clone(), + }], + ..new_tx(1) + }; + let child_txid = child.compute_txid(); + for tx in [&parent, &child] { + let _ = tx_graph.insert_tx(tx.clone()); + let _ = tx_graph.insert_seen_at(tx.compute_txid(), 1000); + } + + let view = chain.canonical_view(&tx_graph, chain.tip().block_id(), Default::default()); + + let classified = view + .classify_outpoints( + [ + OutPoint::new(parent_txid, 0), // spent by `child` -> skipped + OutPoint::new(hash!("unknown"), 0), // not in the view -> skipped + OutPoint::new(child_txid, 0), // the only real UTXO + ], + |_| false, + |pos| pos.is_confirmed(), + ) + .collect::>(); + + assert_eq!(classified.len(), 1); + assert_eq!(classified[0].0.outpoint, OutPoint::new(child_txid, 0)); +} From 88975799a34a147d6c3490e2413c635d3947520f Mon Sep 17 00:00:00 2001 From: Dmenec Date: Wed, 22 Jul 2026 19:05:11 +0200 Subject: [PATCH 4/4] bench(chain): benchmark classify_outpoints across canonical topologies Times the memoized classification over fan-in, disjoint, untrusted, diamond and deep-taint-mid-chain graphs at a range of widths and depths. --- crates/chain/Cargo.toml | 4 + crates/chain/benches/trust_classification.rs | 485 +++++++++++++++++++ 2 files changed, 489 insertions(+) create mode 100644 crates/chain/benches/trust_classification.rs diff --git a/crates/chain/Cargo.toml b/crates/chain/Cargo.toml index 949f177c8..4a8df66d2 100644 --- a/crates/chain/Cargo.toml +++ b/crates/chain/Cargo.toml @@ -44,3 +44,7 @@ harness = false [[bench]] name = "indexer" harness = false + +[[bench]] +name = "trust_classification" +harness = false diff --git a/crates/chain/benches/trust_classification.rs b/crates/chain/benches/trust_classification.rs new file mode 100644 index 000000000..39c541ec8 --- /dev/null +++ b/crates/chain/benches/trust_classification.rs @@ -0,0 +1,485 @@ +//! Benchmarks for `classify_outpoints` (the classifier `balance` folds over). +//! +//! Each group builds a synthetic canonical graph and times classifying every UTXO across a range +//! of sizes. What we care about is how the cost grows with the unconfirmed ancestry, so the +//! confirmed part is kept minimal and everything interesting lives in the mempool. + +use std::collections::BTreeMap; + +use bdk_chain::{ + local_chain::LocalChain, CanonicalView, ChainPosition, ConfirmationBlockTime, TxGraph, +}; +use bdk_testenv::{hash, utils::new_tx}; +use bitcoin::{Amount, BlockHash, OutPoint, Script, ScriptBuf, Transaction, TxIn, TxOut, Txid}; +use criterion::{criterion_group, criterion_main, BenchmarkId, Criterion}; + +// Minimal 2-block chain. Confirmed txs just anchor at height 1. +fn make_chain() -> LocalChain { + let blocks: BTreeMap = [(0, hash!("genesis")), (1, hash!("b1"))] + .into_iter() + .collect(); + LocalChain::from_blocks(blocks).unwrap() +} + +// The two predicates `classify_outpoints` takes, faking what a wallet would pass: +// `does_taint` = the tx pulls in coins that aren't ours, `is_settled` = the tx is confirmed. +#[allow(clippy::type_complexity)] +fn does_taint_and_is_settled<'a>( + view: &'a CanonicalView, + owned: &ScriptBuf, +) -> ( + impl FnMut(&bdk_chain::CanonicalTx>) -> bool + 'a, + impl Fn(&ChainPosition) -> bool, +) { + let owned = owned.clone(); + let is_mine = move |spk: &Script| spk == owned.as_script(); + let does_taint = move |c_tx: &bdk_chain::CanonicalTx>| { + c_tx.tx + .input + .iter() + .any(|txin| match view.tx(txin.previous_output.txid) { + Some(prev) => { + !is_mine(&prev.tx.output[txin.previous_output.vout as usize].script_pubkey) + } + // input from a tx we don't have, treat it as foreign + None => true, + }) + }; + let is_settled = + |pos: &ChainPosition| matches!(pos, ChainPosition::Confirmed { .. }); + (does_taint, is_settled) +} + +// Per-UTXO memoized classification (`classify_outpoints`). +fn run_classify( + utxo_txids: &[Txid], + view: &CanonicalView, + owned: &ScriptBuf, +) { + let outpoints = utxo_txids.iter().map(|&txid| OutPoint::new(txid, 0)); + let (does_taint, is_settled) = does_taint_and_is_settled(view, owned); + for item in view.classify_outpoints(outpoints, does_taint, is_settled) { + std::hint::black_box(item); + } +} + +// One confirmed owned root with `width` outputs, each starting a `depth`-deep chain of unconfirmed +// owned txs. Nothing taints. Walking back, every chain converges on the shared root. +fn setup_fan_in( + width: usize, + depth: usize, +) -> ( + LocalChain, + TxGraph, + Vec, + ScriptBuf, +) { + let chain = make_chain(); + let mut tx_graph = TxGraph::default(); + let owned = ScriptBuf::from(vec![1u8]); + + let root_tx = Transaction { + input: vec![TxIn { + previous_output: OutPoint::new(hash!("external"), 0), + ..Default::default() + }], + output: (0..width) + .map(|_| TxOut { + value: Amount::from_sat(100_000), + script_pubkey: owned.clone(), + }) + .collect(), + ..new_tx(0) + }; + let root_txid = root_tx.compute_txid(); + let _ = tx_graph.insert_tx(root_tx); + let _ = tx_graph.insert_anchor( + root_txid, + ConfirmationBlockTime { + block_id: chain.get(1).unwrap().block_id(), + confirmation_time: 1, + }, + ); + + let mut utxo_txids = vec![]; + for chain_i in 0..width { + let mut prev = OutPoint::new(root_txid, chain_i as u32); + for layer in 0..depth { + let tx = Transaction { + input: vec![TxIn { + previous_output: prev, + ..Default::default() + }], + output: vec![TxOut { + value: Amount::from_sat(90_000), + script_pubkey: owned.clone(), + }], + ..new_tx((chain_i * depth + layer + 1) as u32) + }; + let txid = tx.compute_txid(); + let _ = tx_graph.insert_tx(tx); + let _ = tx_graph.insert_seen_at(txid, 100); + prev = OutPoint::new(txid, 0); + if layer == depth - 1 { + utxo_txids.push(txid); + } + } + } + (chain, tx_graph, utxo_txids, owned) +} + +// Like fan_in but each chain gets its own confirmed root, so there is no shared ancestor. +// Nothing taints. +fn setup_disjoint( + width: usize, + depth: usize, +) -> ( + LocalChain, + TxGraph, + Vec, + ScriptBuf, +) { + let chain = make_chain(); + let mut tx_graph = TxGraph::default(); + let owned = ScriptBuf::from(vec![1u8]); + + let mut utxo_txids = vec![]; + for chain_i in 0..width { + let root_tx = Transaction { + input: vec![TxIn { + previous_output: OutPoint::new(hash!("external"), chain_i as u32), + ..Default::default() + }], + output: vec![TxOut { + value: Amount::from_sat(100_000), + script_pubkey: owned.clone(), + }], + ..new_tx(chain_i as u32) + }; + let root_txid = root_tx.compute_txid(); + let _ = tx_graph.insert_tx(root_tx); + let _ = tx_graph.insert_anchor( + root_txid, + ConfirmationBlockTime { + block_id: chain.get(1).unwrap().block_id(), + confirmation_time: 1, + }, + ); + + let mut prev = OutPoint::new(root_txid, 0); + for layer in 0..depth { + let tx = Transaction { + input: vec![TxIn { + previous_output: prev, + ..Default::default() + }], + output: vec![TxOut { + value: Amount::from_sat(90_000), + script_pubkey: owned.clone(), + }], + ..new_tx((width + chain_i * depth + layer) as u32) + }; + let txid = tx.compute_txid(); + let _ = tx_graph.insert_tx(tx); + let _ = tx_graph.insert_seen_at(txid, 100); + prev = OutPoint::new(txid, 0); + if layer == depth - 1 { + utxo_txids.push(txid); + } + } + } + (chain, tx_graph, utxo_txids, owned) +} + +// An unconfirmed root paying foreign scripts. Each chain's first tx spends it, so it taints, and +// the taint carries down the chain. Every UTXO ends up untrusted. +fn setup_untrusted_fan_in( + width: usize, + depth: usize, +) -> ( + LocalChain, + TxGraph, + Vec, + ScriptBuf, +) { + let chain = make_chain(); + let mut tx_graph = TxGraph::default(); + let owned = ScriptBuf::from(vec![1u8]); + let foreign = ScriptBuf::from(vec![2u8]); // not owned → untrusted + + // Unconfirmed root with a foreign input + let root_tx = Transaction { + input: vec![TxIn { + previous_output: OutPoint::new(hash!("external"), 0), + ..Default::default() + }], + output: (0..width) + .map(|_| TxOut { + value: Amount::from_sat(100_000), + script_pubkey: foreign.clone(), + }) + .collect(), + ..new_tx(0) + }; + let root_txid = root_tx.compute_txid(); + let _ = tx_graph.insert_tx(root_tx); + let _ = tx_graph.insert_seen_at(root_txid, 50); + + let mut utxo_txids = vec![]; + for chain_i in 0..width { + let mut prev = OutPoint::new(root_txid, chain_i as u32); + for layer in 0..depth { + let tx = Transaction { + input: vec![TxIn { + previous_output: prev, + ..Default::default() + }], + output: vec![TxOut { + value: Amount::from_sat(90_000), + script_pubkey: owned.clone(), + }], + ..new_tx((chain_i * depth + layer + 1) as u32) + }; + let txid = tx.compute_txid(); + let _ = tx_graph.insert_tx(tx); + let _ = tx_graph.insert_seen_at(txid, 100); + prev = OutPoint::new(txid, 0); + if layer == depth - 1 { + utxo_txids.push(txid); + } + } + } + (chain, tx_graph, utxo_txids, owned) +} + +// `shared` confirmed base txs, and `utxos` outputs that each spend all of the base txs (many +// inputs converging on the same ancestors). Given importance on shared ancestry and nothing taints. +fn setup_diamond( + shared: usize, + utxos: usize, +) -> ( + LocalChain, + TxGraph, + Vec, + ScriptBuf, +) { + let chain = make_chain(); + let mut tx_graph = TxGraph::default(); + let owned = ScriptBuf::from(vec![1u8]); + + let root_tx = Transaction { + input: vec![TxIn { + previous_output: OutPoint::new(hash!("external"), 0), + ..Default::default() + }], + output: (0..shared) + .map(|_| TxOut { + value: Amount::from_sat(1_000_000), + script_pubkey: owned.clone(), + }) + .collect(), + ..new_tx(0) + }; + let root_txid = root_tx.compute_txid(); + let _ = tx_graph.insert_tx(root_tx); + let _ = tx_graph.insert_anchor( + root_txid, + ConfirmationBlockTime { + block_id: chain.get(1).unwrap().block_id(), + confirmation_time: 1, + }, + ); + + let mut base_txids = vec![]; + for i in 0..shared { + let base_tx = Transaction { + input: vec![TxIn { + previous_output: OutPoint::new(root_txid, i as u32), + ..Default::default() + }], + output: (0..utxos) + .map(|_| TxOut { + value: Amount::from_sat(90_000), + script_pubkey: owned.clone(), + }) + .collect(), + ..new_tx((i + 1) as u32) + }; + let base_txid = base_tx.compute_txid(); + let _ = tx_graph.insert_tx(base_tx); + let _ = tx_graph.insert_seen_at(base_txid, 50); + base_txids.push(base_txid); + } + + let mut utxo_txids = vec![]; + for j in 0..utxos { + let utxo_tx = Transaction { + input: base_txids + .iter() + .map(|&base_txid| TxIn { + previous_output: OutPoint::new(base_txid, j as u32), + ..Default::default() + }) + .collect(), + output: vec![TxOut { + value: Amount::from_sat(10_000), + script_pubkey: owned.clone(), + }], + ..new_tx((shared + 1 + j) as u32) + }; + let utxo_txid = utxo_tx.compute_txid(); + let _ = tx_graph.insert_tx(utxo_tx); + let _ = tx_graph.insert_seen_at(utxo_txid, 100); + utxo_txids.push(utxo_txid); + } + (chain, tx_graph, utxo_txids, owned) +} + +// Each UTXO sits below a short owned tail, a single tainting tx, and a deep unconfirmed foreign +// ancestry above it. The taint is found right above the UTXO, so the classifier can stop there +// instead of walking the whole foreign chain. Every UTXO ends up untrusted. +fn setup_tainted_midchain( + width: usize, + depth: usize, +) -> ( + LocalChain, + TxGraph, + Vec, + ScriptBuf, +) { + let chain = make_chain(); + let mut tx_graph = TxGraph::default(); + let owned = ScriptBuf::from(vec![1u8]); + let foreign = ScriptBuf::from(vec![2u8]); + + let mut n: u32 = 0; + let mut utxo_txids = vec![]; + for chain_i in 0..width { + let mut prev = OutPoint::new(hash!("external"), chain_i as u32); + for _ in 0..depth { + let tx = Transaction { + input: vec![TxIn { + previous_output: prev, + ..Default::default() + }], + output: vec![TxOut { + value: Amount::from_sat(100_000), + script_pubkey: foreign.clone(), + }], + ..new_tx(n) + }; + n += 1; + let txid = tx.compute_txid(); + let _ = tx_graph.insert_tx(tx); + let _ = tx_graph.insert_seen_at(txid, 100); + prev = OutPoint::new(txid, 0); + } + let taint_tx = Transaction { + input: vec![TxIn { + previous_output: prev, + ..Default::default() + }], + output: vec![TxOut { + value: Amount::from_sat(90_000), + script_pubkey: owned.clone(), + }], + ..new_tx(n) + }; + n += 1; + let taint_txid = taint_tx.compute_txid(); + let _ = tx_graph.insert_tx(taint_tx); + let _ = tx_graph.insert_seen_at(taint_txid, 100); + let utxo_tx = Transaction { + input: vec![TxIn { + previous_output: OutPoint::new(taint_txid, 0), + ..Default::default() + }], + output: vec![TxOut { + value: Amount::from_sat(80_000), + script_pubkey: owned.clone(), + }], + ..new_tx(n) + }; + n += 1; + let utxo_txid = utxo_tx.compute_txid(); + let _ = tx_graph.insert_tx(utxo_tx); + let _ = tx_graph.insert_seen_at(utxo_txid, 100); + utxo_txids.push(utxo_txid); + } + (chain, tx_graph, utxo_txids, owned) +} + +fn bench_fan_in(c: &mut Criterion) { + let mut group = c.benchmark_group("fan_in"); + for (width, depth) in [(10, 3), (50, 5), (100, 10), (500, 20)] { + let label = format!("{width}w×{depth}d"); + let (chain, tx_graph, utxo_txids, owned) = setup_fan_in(width, depth); + let view = chain.canonical_view(&tx_graph, chain.tip().block_id(), Default::default()); + group.bench_with_input(BenchmarkId::new("classify", &label), &(), |b, _| { + b.iter(|| run_classify(&utxo_txids, &view, &owned)); + }); + } + group.finish(); +} + +fn bench_disjoint(c: &mut Criterion) { + let mut group = c.benchmark_group("disjoint"); + for (width, depth) in [(10, 3), (50, 5), (100, 10), (500, 20)] { + let label = format!("{width}w×{depth}d"); + let (chain, tx_graph, utxo_txids, owned) = setup_disjoint(width, depth); + let view = chain.canonical_view(&tx_graph, chain.tip().block_id(), Default::default()); + group.bench_with_input(BenchmarkId::new("classify", &label), &(), |b, _| { + b.iter(|| run_classify(&utxo_txids, &view, &owned)); + }); + } + group.finish(); +} + +fn bench_untrusted_fan_in(c: &mut Criterion) { + let mut group = c.benchmark_group("untrusted_fan_in"); + for (width, depth) in [(10, 3), (50, 5), (100, 10), (500, 20)] { + let label = format!("{width}w×{depth}d"); + let (chain, tx_graph, utxo_txids, owned) = setup_untrusted_fan_in(width, depth); + let view = chain.canonical_view(&tx_graph, chain.tip().block_id(), Default::default()); + group.bench_with_input(BenchmarkId::new("classify", &label), &(), |b, _| { + b.iter(|| run_classify(&utxo_txids, &view, &owned)); + }); + } + group.finish(); +} + +fn bench_diamond(c: &mut Criterion) { + let mut group = c.benchmark_group("diamond"); + for (shared, utxos) in [(5, 20), (10, 50), (20, 100), (50, 500)] { + let label = format!("{shared}s×{utxos}u"); + let (chain, tx_graph, utxo_txids, owned) = setup_diamond(shared, utxos); + let view = chain.canonical_view(&tx_graph, chain.tip().block_id(), Default::default()); + group.bench_with_input(BenchmarkId::new("classify", &label), &(), |b, _| { + b.iter(|| run_classify(&utxo_txids, &view, &owned)); + }); + } + group.finish(); +} + +fn bench_tainted_midchain(c: &mut Criterion) { + let mut group = c.benchmark_group("tainted_midchain"); + for (width, depth) in [(10, 3), (50, 5), (100, 10), (500, 20)] { + let label = format!("{width}w×{depth}d"); + let (chain, tx_graph, utxo_txids, owned) = setup_tainted_midchain(width, depth); + let view = chain.canonical_view(&tx_graph, chain.tip().block_id(), Default::default()); + group.bench_with_input(BenchmarkId::new("classify", &label), &(), |b, _| { + b.iter(|| run_classify(&utxo_txids, &view, &owned)); + }); + } + group.finish(); +} + +criterion_group!( + benches, + bench_fan_in, + bench_disjoint, + bench_untrusted_fan_in, + bench_diamond, + bench_tainted_midchain +); +criterion_main!(benches);