diff --git a/crates/bitcoind_rpc/tests/test_emitter.rs b/crates/bitcoind_rpc/tests/test_emitter.rs index 2aeede628..989d1e806 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) } @@ -395,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", @@ -410,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/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/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 c2aecb756..e6f59ac7f 100644 --- a/crates/chain/src/canonical.rs +++ b/crates/chain/src/canonical.rs @@ -22,15 +22,14 @@ //! } //! ``` -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}; 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}; @@ -393,34 +392,170 @@ 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 `(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 classify. + /// * `does_taint` - Function that returns `true` for transactions that should *taint* their + /// 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 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. /// - /// # 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 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: /// - /// 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 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 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, 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 view = chain.canonical_view(&tx_graph, chain.tip().block_id(), CanonicalParams::default()); + /// # let indexer = KeychainTxOutIndex::<&str>::default(); + /// // 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(), + /// ) { + /// match eligibility { + /// Eligibility::Settled | Eligibility::TrustedPending => candidates.push(txout.outpoint), + /// Eligibility::Immature | Eligibility::UntrustedPending => {} + /// } + /// } + /// ``` + 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::>(); + + // 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 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. + if txout.is_mature(tip) { + Eligibility::Settled + } else { + Eligibility::Immature + } + } else if tainted.contains(&txout.outpoint.txid) { + Eligibility::UntrustedPending + } else { + Eligibility::TrustedPending + }; + (txout, eligibility) + }) + } + + /// 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(); @@ -428,64 +563,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, 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, + 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.settled, + Eligibility::TrustedPending => &mut balance.trusted_pending, + Eligibility::UntrustedPending => &mut balance.untrusted_pending, + }; + *bucket += txout.txout.value; } + balance } } diff --git a/crates/chain/src/canonical_ancestors.rs b/crates/chain/src/canonical_ancestors.rs new file mode 100644 index 000000000..4bc9cbe6a --- /dev/null +++ b/crates/chain/src/canonical_ancestors.rs @@ -0,0 +1,252 @@ +//! 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. + /// + /// 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. + /// + /// See [`CanonicalAncestors`] for the traversal order and accumulation semantics. + 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, 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`] / [`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. +/// +/// # 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 struct CanonicalAncestors<'c, A, P, T> { + canonical: &'c Canonical, + /// 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. + 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 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, + include_seeds: bool, + ) -> 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 = if include_seeds { + reachable.len() + } else { + reachable.len() - seeds_set.len() + }; + + Self { + canonical, + seeds: seeds_set, + include_seeds, + 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 propagate their accumulation but are only yielded when requested. + if !self.include_seeds && 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..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() }, );