From 0a55710ea41d4122a7b15481502cfd8a6e24f068 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=BF=97=E5=AE=87?= Date: Thu, 11 Sep 2025 03:48:27 +0000 Subject: [PATCH 01/12] feat(chain)!: Introduce `CanonicalView` and migrate API MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add `CanonicalView` structure with canonical transaction methods - Move methods from `TxGraph` to `CanonicalView` (txs, filter_outpoints, balance, etc.) - Add canonical view methods to `IndexedTxGraph` - Update all tests and examples to use new API - Optimize examples to reuse canonical view instances 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --- crates/bitcoind_rpc/examples/filter_iter.rs | 29 +- crates/bitcoind_rpc/tests/test_emitter.rs | 19 +- crates/chain/benches/canonicalization.rs | 11 +- crates/chain/benches/indexer.rs | 10 +- crates/chain/src/canonical_view.rs | 275 +++++++++++ crates/chain/src/indexed_tx_graph.rs | 61 +-- crates/chain/src/lib.rs | 2 + crates/chain/src/tx_graph.rs | 440 +----------------- crates/chain/tests/test_indexed_tx_graph.rs | 39 +- crates/chain/tests/test_tx_graph.rs | 29 +- crates/chain/tests/test_tx_graph_conflicts.rs | 35 +- crates/electrum/tests/test_electrum.rs | 22 +- crates/esplora/tests/async_ext.rs | 12 +- crates/esplora/tests/blocking_ext.rs | 12 +- .../example_bitcoind_rpc_polling/src/main.rs | 46 +- examples/example_cli/src/lib.rs | 34 +- examples/example_electrum/src/main.rs | 32 +- examples/example_esplora/src/main.rs | 32 +- 18 files changed, 469 insertions(+), 671 deletions(-) create mode 100644 crates/chain/src/canonical_view.rs diff --git a/crates/bitcoind_rpc/examples/filter_iter.rs b/crates/bitcoind_rpc/examples/filter_iter.rs index c0e755f9c..5a3dc2974 100644 --- a/crates/bitcoind_rpc/examples/filter_iter.rs +++ b/crates/bitcoind_rpc/examples/filter_iter.rs @@ -69,13 +69,8 @@ fn main() -> anyhow::Result<()> { println!("\ntook: {}s", start.elapsed().as_secs()); println!("Local tip: {}", chain.tip().height()); let unspent: Vec<_> = graph - .graph() - .filter_chain_unspents( - &chain, - chain.tip().block_id(), - Default::default(), - graph.index.outpoints().clone(), - ) + .canonical_view(&chain, chain.tip().block_id(), Default::default()) + .filter_unspent_outpoints(graph.index.outpoints().clone()) .collect(); if !unspent.is_empty() { println!("\nUnspent"); @@ -85,16 +80,16 @@ fn main() -> anyhow::Result<()> { } } - for canon_tx in graph.graph().list_canonical_txs( - &chain, - chain.tip().block_id(), - bdk_chain::CanonicalizationParams::default(), - ) { - if !canon_tx.chain_position.is_confirmed() { - eprintln!( - "ERROR: canonical tx should be confirmed {}", - canon_tx.tx_node.txid - ); + for canon_tx in graph + .canonical_view( + &chain, + chain.tip().block_id(), + bdk_chain::CanonicalizationParams::default(), + ) + .txs() + { + if !canon_tx.pos.is_confirmed() { + eprintln!("ERROR: canonical tx should be confirmed {}", canon_tx.txid); } } diff --git a/crates/bitcoind_rpc/tests/test_emitter.rs b/crates/bitcoind_rpc/tests/test_emitter.rs index 079551bf0..79b44b00a 100644 --- a/crates/bitcoind_rpc/tests/test_emitter.rs +++ b/crates/bitcoind_rpc/tests/test_emitter.rs @@ -310,13 +310,9 @@ fn get_balance( ) -> anyhow::Result { let chain_tip = recv_chain.tip().block_id(); let outpoints = recv_graph.index.outpoints().clone(); - let balance = recv_graph.graph().balance( - recv_chain, - chain_tip, - CanonicalizationParams::default(), - outpoints, - |_, _| true, - ); + let balance = recv_graph + .canonical_view(recv_chain, chain_tip, CanonicalizationParams::default()) + .balance(outpoints, |_, _| true); Ok(balance) } @@ -621,7 +617,8 @@ fn test_expect_tx_evicted() -> anyhow::Result<()> { // Retrieve the expected unconfirmed txids and spks from the graph. let exp_spk_txids = graph - .list_expected_spk_txids(&chain, chain_tip, ..) + .canonical_view(&chain, chain_tip, Default::default()) + .list_expected_spk_txids(&graph.index, ..) .collect::>(); assert_eq!(exp_spk_txids, vec![(spk, txid_1)]); @@ -636,9 +633,9 @@ fn test_expect_tx_evicted() -> anyhow::Result<()> { let _ = graph.batch_insert_relevant_evicted_at(mempool_event.evicted); let canonical_txids = graph - .graph() - .list_canonical_txs(&chain, chain_tip, CanonicalizationParams::default()) - .map(|tx| tx.tx_node.compute_txid()) + .canonical_view(&chain, chain_tip, CanonicalizationParams::default()) + .txs() + .map(|tx| tx.txid) .collect::>(); // tx1 should no longer be canonical. assert!(!canonical_txids.contains(&txid_1)); diff --git a/crates/chain/benches/canonicalization.rs b/crates/chain/benches/canonicalization.rs index bf11e1ebc..3d8d8b295 100644 --- a/crates/chain/benches/canonicalization.rs +++ b/crates/chain/benches/canonicalization.rs @@ -95,31 +95,32 @@ fn setup(f: F) -> (KeychainTxGraph, Lo } fn run_list_canonical_txs(tx_graph: &KeychainTxGraph, chain: &LocalChain, exp_txs: usize) { - let txs = tx_graph.graph().list_canonical_txs( + let view = tx_graph.canonical_view( chain, chain.tip().block_id(), CanonicalizationParams::default(), ); + let txs = view.txs(); assert_eq!(txs.count(), exp_txs); } fn run_filter_chain_txouts(tx_graph: &KeychainTxGraph, chain: &LocalChain, exp_txos: usize) { - let utxos = tx_graph.graph().filter_chain_txouts( + let view = tx_graph.canonical_view( chain, chain.tip().block_id(), CanonicalizationParams::default(), - tx_graph.index.outpoints().clone(), ); + let utxos = view.filter_outpoints(tx_graph.index.outpoints().clone()); assert_eq!(utxos.count(), exp_txos); } fn run_filter_chain_unspents(tx_graph: &KeychainTxGraph, chain: &LocalChain, exp_utxos: usize) { - let utxos = tx_graph.graph().filter_chain_unspents( + let view = tx_graph.canonical_view( chain, chain.tip().block_id(), CanonicalizationParams::default(), - tx_graph.index.outpoints().clone(), ); + let utxos = view.filter_unspent_outpoints(tx_graph.index.outpoints().clone()); assert_eq!(utxos.count(), exp_utxos); } diff --git a/crates/chain/benches/indexer.rs b/crates/chain/benches/indexer.rs index c1786a627..df4e3f361 100644 --- a/crates/chain/benches/indexer.rs +++ b/crates/chain/benches/indexer.rs @@ -84,13 +84,9 @@ fn do_bench(indexed_tx_graph: &KeychainTxGraph, chain: &LocalChain) { // Check balance let chain_tip = chain.tip().block_id(); let op = graph.index.outpoints().clone(); - let bal = graph.graph().balance( - chain, - chain_tip, - CanonicalizationParams::default(), - op, - |_, _| false, - ); + let bal = graph + .canonical_view(chain, chain_tip, CanonicalizationParams::default()) + .balance(op, |_, _| false); assert_eq!(bal.total(), AMOUNT * TX_CT as u64); } diff --git a/crates/chain/src/canonical_view.rs b/crates/chain/src/canonical_view.rs new file mode 100644 index 000000000..2685344e9 --- /dev/null +++ b/crates/chain/src/canonical_view.rs @@ -0,0 +1,275 @@ +//! Canonical view. + +use crate::collections::HashMap; +use alloc::sync::Arc; +use core::{fmt, ops::RangeBounds}; + +use alloc::vec::Vec; + +use bdk_core::BlockId; +use bitcoin::{Amount, OutPoint, ScriptBuf, Transaction, Txid}; + +use crate::{ + spk_txout::SpkTxOutIndex, tx_graph::TxNode, Anchor, Balance, CanonicalIter, CanonicalReason, + CanonicalizationParams, ChainOracle, ChainPosition, FullTxOut, ObservedIn, TxGraph, +}; + +/// A single canonical transaction. +#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord)] +pub struct CanonicalViewTx { + /// Chain position. + pub pos: ChainPosition, + /// Transaction ID. + pub txid: Txid, + /// The actual transaction. + pub tx: Arc, +} + +/// A view of canonical transactions. +#[derive(Debug)] +pub struct CanonicalView { + order: Vec, + txs: HashMap, ChainPosition)>, + spends: HashMap, + tip: BlockId, +} + +impl CanonicalView { + /// Create a canonical view. + pub fn new<'g, C>( + tx_graph: &'g TxGraph, + chain: &'g C, + chain_tip: BlockId, + params: CanonicalizationParams, + ) -> Result + where + C: ChainOracle, + { + fn find_direct_anchor<'g, A: Anchor, C: ChainOracle>( + tx_node: &TxNode<'g, Arc, A>, + chain: &C, + chain_tip: BlockId, + ) -> Result, C::Error> { + tx_node + .anchors + .iter() + .find_map(|a| -> Option> { + match chain.is_block_in_chain(a.anchor_block(), chain_tip) { + Ok(Some(true)) => Some(Ok(a.clone())), + Ok(Some(false)) | Ok(None) => None, + Err(err) => Some(Err(err)), + } + }) + .transpose() + } + + let mut view = Self { + tip: chain_tip, + order: vec![], + txs: HashMap::new(), + spends: HashMap::new(), + }; + + for r in CanonicalIter::new(tx_graph, chain, chain_tip, params) { + let (txid, tx, why) = r?; + + let tx_node = match tx_graph.get_tx_node(txid) { + Some(tx_node) => tx_node, + None => { + // TODO: Have the `CanonicalIter` return `TxNode`s. + debug_assert!(false, "tx node must exist!"); + continue; + } + }; + + view.order.push(txid); + + if !tx.is_coinbase() { + view.spends + .extend(tx.input.iter().map(|txin| (txin.previous_output, txid))); + } + + let pos = match why { + CanonicalReason::Assumed { descendant } => match descendant { + Some(_) => match find_direct_anchor(&tx_node, chain, chain_tip)? { + Some(anchor) => ChainPosition::Confirmed { + anchor, + transitively: None, + }, + None => ChainPosition::Unconfirmed { + first_seen: tx_node.first_seen, + last_seen: tx_node.last_seen, + }, + }, + None => ChainPosition::Unconfirmed { + first_seen: tx_node.first_seen, + last_seen: tx_node.last_seen, + }, + }, + CanonicalReason::Anchor { anchor, descendant } => match descendant { + Some(_) => match find_direct_anchor(&tx_node, chain, chain_tip)? { + Some(anchor) => ChainPosition::Confirmed { + anchor, + transitively: None, + }, + None => ChainPosition::Confirmed { + anchor, + transitively: descendant, + }, + }, + None => ChainPosition::Confirmed { + anchor, + transitively: None, + }, + }, + CanonicalReason::ObservedIn { observed_in, .. } => match observed_in { + ObservedIn::Mempool(last_seen) => ChainPosition::Unconfirmed { + first_seen: tx_node.first_seen, + last_seen: Some(last_seen), + }, + ObservedIn::Block(_) => ChainPosition::Unconfirmed { + first_seen: tx_node.first_seen, + last_seen: None, + }, + }, + }; + view.txs.insert(txid, (tx_node.tx, pos)); + } + + Ok(view) + } + + /// Get a single canonical transaction. + pub fn tx(&self, txid: Txid) -> Option> { + self.txs + .get(&txid) + .cloned() + .map(|(tx, pos)| CanonicalViewTx { pos, txid, tx }) + } + + /// Get a single canonical txout. + pub fn txout(&self, op: OutPoint) -> Option> { + let (tx, pos) = self.txs.get(&op.txid)?; + let vout: usize = op.vout.try_into().ok()?; + let txout = tx.output.get(vout)?; + let spent_by = self.spends.get(&op).map(|spent_by_txid| { + let (_, spent_by_pos) = &self.txs[spent_by_txid]; + (spent_by_pos.clone(), *spent_by_txid) + }); + Some(FullTxOut { + chain_position: pos.clone(), + outpoint: op, + txout: txout.clone(), + spent_by, + is_on_coinbase: tx.is_coinbase(), + }) + } + + /// Ordered transactions. + pub fn txs( + &self, + ) -> impl ExactSizeIterator> + DoubleEndedIterator + '_ { + self.order.iter().map(|&txid| { + let (tx, pos) = self.txs[&txid].clone(); + CanonicalViewTx { pos, txid, tx } + }) + } + + /// Get a filtered list of outputs from the given `outpoints`. + /// + /// `outpoints` is a list of outpoints we are interested in, coupled with an outpoint identifier + /// (`O`) for convenience. If `O` is not necessary, the caller can use `()`, or + /// [`Iterator::enumerate`] over a list of [`OutPoint`]s. + pub fn filter_outpoints<'v, O: Clone + 'v>( + &'v self, + outpoints: impl IntoIterator + 'v, + ) -> impl Iterator)> + 'v { + outpoints + .into_iter() + .filter_map(|(op_i, op)| Some((op_i, self.txout(op)?))) + } + + /// Get a filtered list of unspent outputs (UTXOs) from the given `outpoints` + /// + /// `outpoints` is a list of outpoints we are interested in, coupled with an outpoint identifier + /// (`O`) for convenience. If `O` is not necessary, the caller can use `()`, or + /// [`Iterator::enumerate`] over a list of [`OutPoint`]s. + pub fn filter_unspent_outpoints<'v, O: Clone + 'v>( + &'v self, + outpoints: impl IntoIterator + 'v, + ) -> impl Iterator)> + 'v { + self.filter_outpoints(outpoints) + .filter(|(_, txo)| txo.spent_by.is_none()) + } + + /// Get the total balance of `outpoints`. + /// + /// The output of `trust_predicate` should return `true` for scripts that we trust. + /// + /// `outpoints` is a list of outpoints we are interested in, coupled with an outpoint identifier + /// (`O`) for convenience. If `O` is not necessary, the caller can use `()`, or + /// [`Iterator::enumerate`] over a list of [`OutPoint`]s. + pub fn balance<'v, O: Clone + 'v>( + &'v self, + outpoints: impl IntoIterator + 'v, + mut trust_predicate: impl FnMut(&O, ScriptBuf) -> 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.chain_position { + ChainPosition::Confirmed { .. } => { + 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.txout.script_pubkey) { + trusted_pending += txout.txout.value; + } else { + untrusted_pending += txout.txout.value; + } + } + } + } + + Balance { + immature, + trusted_pending, + untrusted_pending, + confirmed, + } + } + + /// List txids that are expected to exist under the given spks. + /// + /// This is used to fill + /// [`SyncRequestBuilder::expected_spk_txids`](bdk_core::spk_client::SyncRequestBuilder::expected_spk_txids). + /// + /// + /// The spk index range can be constrained with `range`. + pub fn list_expected_spk_txids<'v, I>( + &'v self, + indexer: &'v impl AsRef>, + spk_index_range: impl RangeBounds + 'v, + ) -> impl Iterator + 'v + where + I: fmt::Debug + Clone + Ord + 'v, + { + let indexer = indexer.as_ref(); + self.txs().flat_map(move |c_tx| -> Vec<_> { + let range = &spk_index_range; + let relevant_spks = indexer.relevant_spks_of_tx(&c_tx.tx); + relevant_spks + .into_iter() + .filter(|(i, _)| range.contains(i)) + .map(|(_, spk)| (spk, c_tx.txid)) + .collect() + }) + } +} diff --git a/crates/chain/src/indexed_tx_graph.rs b/crates/chain/src/indexed_tx_graph.rs index f0c1d121d..9adf7ed93 100644 --- a/crates/chain/src/indexed_tx_graph.rs +++ b/crates/chain/src/indexed_tx_graph.rs @@ -1,18 +1,14 @@ //! Contains the [`IndexedTxGraph`] and associated types. Refer to the //! [`IndexedTxGraph`] documentation for more. -use core::{ - convert::Infallible, - fmt::{self, Debug}, - ops::RangeBounds, -}; +use core::{convert::Infallible, fmt::Debug}; use alloc::{sync::Arc, vec::Vec}; -use bitcoin::{Block, OutPoint, ScriptBuf, Transaction, TxOut, Txid}; +use bitcoin::{Block, OutPoint, Transaction, TxOut, Txid}; use crate::{ - spk_txout::SpkTxOutIndex, tx_graph::{self, TxGraph}, - Anchor, BlockId, ChainOracle, Indexer, Merge, TxPosInBlock, + Anchor, BlockId, CanonicalView, CanonicalizationParams, ChainOracle, Indexer, Merge, + TxPosInBlock, }; /// A [`TxGraph`] paired with an indexer `I`, enforcing that every insertion into the graph is @@ -431,53 +427,26 @@ impl IndexedTxGraph where A: Anchor, { - /// List txids that are expected to exist under the given spks. - /// - /// This is used to fill - /// [`SyncRequestBuilder::expected_spk_txids`](bdk_core::spk_client::SyncRequestBuilder::expected_spk_txids). - /// - /// - /// The spk index range can be contrained with `range`. - /// - /// # Error - /// - /// If the [`ChainOracle`] implementation (`chain`) fails, an error will be returned with the - /// returned item. - /// - /// If the [`ChainOracle`] is infallible, - /// [`list_expected_spk_txids`](Self::list_expected_spk_txids) can be used instead. - pub fn try_list_expected_spk_txids<'a, C, I>( + /// Returns a [`CanonicalView`]. + pub fn try_canonical_view<'a, C: ChainOracle>( &'a self, chain: &'a C, chain_tip: BlockId, - spk_index_range: impl RangeBounds + 'a, - ) -> impl Iterator> + 'a - where - C: ChainOracle, - X: AsRef> + 'a, - I: fmt::Debug + Clone + Ord + 'a, - { - self.graph - .try_list_expected_spk_txids(chain, chain_tip, &self.index, spk_index_range) + params: CanonicalizationParams, + ) -> Result, C::Error> { + self.graph.try_canonical_view(chain, chain_tip, params) } - /// List txids that are expected to exist under the given spks. + /// Returns a [`CanonicalView`]. /// - /// This is the infallible version of - /// [`try_list_expected_spk_txids`](Self::try_list_expected_spk_txids). - pub fn list_expected_spk_txids<'a, C, I>( + /// This is the infallible version of [`try_canonical_view`](Self::try_canonical_view). + pub fn canonical_view<'a, C: ChainOracle>( &'a self, chain: &'a C, chain_tip: BlockId, - spk_index_range: impl RangeBounds + 'a, - ) -> impl Iterator + 'a - where - C: ChainOracle, - X: AsRef> + 'a, - I: fmt::Debug + Clone + Ord + 'a, - { - self.try_list_expected_spk_txids(chain, chain_tip, spk_index_range) - .map(|r| r.expect("infallible")) + params: CanonicalizationParams, + ) -> CanonicalView { + self.graph.canonical_view(chain, chain_tip, params) } } diff --git a/crates/chain/src/lib.rs b/crates/chain/src/lib.rs index 0cb5b48d3..be9170b1a 100644 --- a/crates/chain/src/lib.rs +++ b/crates/chain/src/lib.rs @@ -46,6 +46,8 @@ mod chain_oracle; pub use chain_oracle::*; mod canonical_iter; pub use canonical_iter::*; +mod canonical_view; +pub use canonical_view::*; #[doc(hidden)] pub mod example_utils; diff --git a/crates/chain/src/tx_graph.rs b/crates/chain/src/tx_graph.rs index 6b9a4cf96..3416f0df9 100644 --- a/crates/chain/src/tx_graph.rs +++ b/crates/chain/src/tx_graph.rs @@ -120,21 +120,18 @@ //! [`insert_txout`]: TxGraph::insert_txout use crate::collections::*; -use crate::spk_txout::SpkTxOutIndex; use crate::BlockId; use crate::CanonicalIter; -use crate::CanonicalReason; +use crate::CanonicalView; use crate::CanonicalizationParams; -use crate::ObservedIn; -use crate::{Anchor, Balance, ChainOracle, ChainPosition, FullTxOut, Merge}; +use crate::{Anchor, ChainOracle, ChainPosition, Merge}; use alloc::collections::vec_deque::VecDeque; use alloc::sync::Arc; use alloc::vec::Vec; use bdk_core::ConfirmationBlockTime; pub use bdk_core::TxUpdate; -use bitcoin::{Amount, OutPoint, ScriptBuf, SignedAmount, Transaction, TxOut, Txid}; +use bitcoin::{Amount, OutPoint, SignedAmount, Transaction, TxOut, Txid}; use core::fmt::{self, Formatter}; -use core::ops::RangeBounds; use core::{ convert::Infallible, ops::{Deref, RangeInclusive}, @@ -980,183 +977,6 @@ impl TxGraph { } impl TxGraph { - /// List graph transactions that are in `chain` with `chain_tip`. - /// - /// Each transaction is represented as a [`CanonicalTx`] that contains where the transaction is - /// observed in-chain, and the [`TxNode`]. - /// - /// # Error - /// - /// If the [`ChainOracle`] implementation (`chain`) fails, an error will be returned with the - /// returned item. - /// - /// If the [`ChainOracle`] is infallible, [`list_canonical_txs`] can be used instead. - /// - /// [`list_canonical_txs`]: Self::list_canonical_txs - pub fn try_list_canonical_txs<'a, C: ChainOracle + 'a>( - &'a self, - chain: &'a C, - chain_tip: BlockId, - params: CanonicalizationParams, - ) -> impl Iterator, A>, C::Error>> { - fn find_direct_anchor( - tx_node: &TxNode<'_, Arc, A>, - chain: &C, - chain_tip: BlockId, - ) -> Result, C::Error> { - tx_node - .anchors - .iter() - .find_map(|a| -> Option> { - match chain.is_block_in_chain(a.anchor_block(), chain_tip) { - Ok(Some(true)) => Some(Ok(a.clone())), - Ok(Some(false)) | Ok(None) => None, - Err(err) => Some(Err(err)), - } - }) - .transpose() - } - self.canonical_iter(chain, chain_tip, params) - .flat_map(move |res| { - res.map(|(txid, _, canonical_reason)| { - let tx_node = self.get_tx_node(txid).expect("must contain tx"); - let chain_position = match canonical_reason { - CanonicalReason::Assumed { descendant } => match descendant { - Some(_) => match find_direct_anchor(&tx_node, chain, chain_tip)? { - Some(anchor) => ChainPosition::Confirmed { - anchor, - transitively: None, - }, - None => ChainPosition::Unconfirmed { - first_seen: tx_node.first_seen, - last_seen: tx_node.last_seen, - }, - }, - None => ChainPosition::Unconfirmed { - first_seen: tx_node.first_seen, - last_seen: tx_node.last_seen, - }, - }, - CanonicalReason::Anchor { anchor, descendant } => match descendant { - Some(_) => match find_direct_anchor(&tx_node, chain, chain_tip)? { - Some(anchor) => ChainPosition::Confirmed { - anchor, - transitively: None, - }, - None => ChainPosition::Confirmed { - anchor, - transitively: descendant, - }, - }, - None => ChainPosition::Confirmed { - anchor, - transitively: None, - }, - }, - CanonicalReason::ObservedIn { observed_in, .. } => match observed_in { - ObservedIn::Mempool(last_seen) => ChainPosition::Unconfirmed { - first_seen: tx_node.first_seen, - last_seen: Some(last_seen), - }, - ObservedIn::Block(_) => ChainPosition::Unconfirmed { - first_seen: tx_node.first_seen, - last_seen: None, - }, - }, - }; - Ok(CanonicalTx { - chain_position, - tx_node, - }) - }) - }) - } - - /// List graph transactions that are in `chain` with `chain_tip`. - /// - /// This is the infallible version of [`try_list_canonical_txs`]. - /// - /// [`try_list_canonical_txs`]: Self::try_list_canonical_txs - pub fn list_canonical_txs<'a, C: ChainOracle + 'a>( - &'a self, - chain: &'a C, - chain_tip: BlockId, - params: CanonicalizationParams, - ) -> impl Iterator, A>> { - self.try_list_canonical_txs(chain, chain_tip, params) - .map(|res| res.expect("infallible")) - } - - /// Get a filtered list of outputs from the given `outpoints` that are in `chain` with - /// `chain_tip`. - /// - /// `outpoints` is a list of outpoints we are interested in, coupled with an outpoint identifier - /// (`OI`) for convenience. If `OI` is not necessary, the caller can use `()`, or - /// [`Iterator::enumerate`] over a list of [`OutPoint`]s. - /// - /// Floating outputs (i.e., outputs for which we don't have the full transaction in the graph) - /// are ignored. - /// - /// # Error - /// - /// An [`Iterator::Item`] can be an [`Err`] if the [`ChainOracle`] implementation (`chain`) - /// fails. - /// - /// If the [`ChainOracle`] implementation is infallible, [`filter_chain_txouts`] can be used - /// instead. - /// - /// [`filter_chain_txouts`]: Self::filter_chain_txouts - pub fn try_filter_chain_txouts<'a, C: ChainOracle + 'a, OI: Clone + 'a>( - &'a self, - chain: &'a C, - chain_tip: BlockId, - params: CanonicalizationParams, - outpoints: impl IntoIterator + 'a, - ) -> Result)> + 'a, C::Error> { - let mut canon_txs = HashMap::, A>>::new(); - let mut canon_spends = HashMap::::new(); - for r in self.try_list_canonical_txs(chain, chain_tip, params) { - let canonical_tx = r?; - let txid = canonical_tx.tx_node.txid; - - if !canonical_tx.tx_node.tx.is_coinbase() { - for txin in &canonical_tx.tx_node.tx.input { - let _res = canon_spends.insert(txin.previous_output, txid); - assert!(_res.is_none(), "tried to replace {_res:?} with {txid:?}",); - } - } - canon_txs.insert(txid, canonical_tx); - } - Ok(outpoints.into_iter().filter_map(move |(spk_i, outpoint)| { - let canon_tx = canon_txs.get(&outpoint.txid)?; - let txout = canon_tx - .tx_node - .tx - .output - .get(outpoint.vout as usize) - .cloned()?; - let chain_position = canon_tx.chain_position.clone(); - let spent_by = canon_spends.get(&outpoint).map(|spend_txid| { - let spend_tx = canon_txs - .get(spend_txid) - .cloned() - .expect("must be canonical"); - (spend_tx.chain_position, *spend_txid) - }); - let is_on_coinbase = canon_tx.tx_node.is_coinbase(); - Some(( - spk_i, - FullTxOut { - outpoint, - txout, - chain_position, - spent_by, - is_on_coinbase, - }, - )) - })) - } - /// List txids by descending anchor height order. /// /// If multiple anchors exist for a txid, the highest anchor height will be used. Transactions @@ -1192,262 +1012,24 @@ impl TxGraph { CanonicalIter::new(self, chain, chain_tip, params) } - /// Get a filtered list of outputs from the given `outpoints` that are in `chain` with - /// `chain_tip`. - /// - /// This is the infallible version of [`try_filter_chain_txouts`]. - /// - /// [`try_filter_chain_txouts`]: Self::try_filter_chain_txouts - pub fn filter_chain_txouts<'a, C: ChainOracle + 'a, OI: Clone + 'a>( - &'a self, - chain: &'a C, - chain_tip: BlockId, - params: CanonicalizationParams, - outpoints: impl IntoIterator + 'a, - ) -> impl Iterator)> + 'a { - self.try_filter_chain_txouts(chain, chain_tip, params, outpoints) - .expect("oracle is infallible") - } - - /// Get a filtered list of unspent outputs (UTXOs) from the given `outpoints` that are in - /// `chain` with `chain_tip`. - /// - /// `outpoints` is a list of outpoints we are interested in, coupled with an outpoint identifier - /// (`OI`) for convenience. If `OI` is not necessary, the caller can use `()`, or - /// [`Iterator::enumerate`] over a list of [`OutPoint`]s. - /// - /// Floating outputs are ignored. - /// - /// # Error - /// - /// An [`Iterator::Item`] can be an [`Err`] if the [`ChainOracle`] implementation (`chain`) - /// fails. - /// - /// If the [`ChainOracle`] implementation is infallible, [`filter_chain_unspents`] can be used - /// instead. - /// - /// [`filter_chain_unspents`]: Self::filter_chain_unspents - pub fn try_filter_chain_unspents<'a, C: ChainOracle + 'a, OI: Clone + 'a>( + /// Returns a [`CanonicalView`]. + pub fn try_canonical_view<'a, C: ChainOracle>( &'a self, chain: &'a C, chain_tip: BlockId, params: CanonicalizationParams, - outpoints: impl IntoIterator + 'a, - ) -> Result)> + 'a, C::Error> { - Ok(self - .try_filter_chain_txouts(chain, chain_tip, params, outpoints)? - .filter(|(_, full_txo)| full_txo.spent_by.is_none())) + ) -> Result, C::Error> { + CanonicalView::new(self, chain, chain_tip, params) } - /// Get a filtered list of unspent outputs (UTXOs) from the given `outpoints` that are in - /// `chain` with `chain_tip`. - /// - /// This is the infallible version of [`try_filter_chain_unspents`]. - /// - /// [`try_filter_chain_unspents`]: Self::try_filter_chain_unspents - pub fn filter_chain_unspents<'a, C: ChainOracle + 'a, OI: Clone + 'a>( + /// Returns a [`CanonicalView`]. + pub fn canonical_view<'a, C: ChainOracle>( &'a self, chain: &'a C, chain_tip: BlockId, params: CanonicalizationParams, - txouts: impl IntoIterator + 'a, - ) -> impl Iterator)> + 'a { - self.try_filter_chain_unspents(chain, chain_tip, params, txouts) - .expect("oracle is infallible") - } - - /// Get the total balance of `outpoints` that are in `chain` of `chain_tip`. - /// - /// The output of `trust_predicate` should return `true` for scripts that we trust. - /// - /// `outpoints` is a list of outpoints we are interested in, coupled with an outpoint identifier - /// (`OI`) for convenience. If `OI` is not necessary, the caller can use `()`, or - /// [`Iterator::enumerate`] over a list of [`OutPoint`]s. - /// - /// If the provided [`ChainOracle`] implementation (`chain`) is infallible, [`balance`] can be - /// used instead. - /// - /// [`balance`]: Self::balance - pub fn try_balance( - &self, - chain: &C, - chain_tip: BlockId, - params: CanonicalizationParams, - outpoints: impl IntoIterator, - mut trust_predicate: impl FnMut(&OI, ScriptBuf) -> bool, - ) -> Result { - 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.try_filter_chain_unspents(chain, chain_tip, params, outpoints)? { - match &txout.chain_position { - ChainPosition::Confirmed { .. } => { - if txout.is_confirmed_and_spendable(chain_tip.height) { - confirmed += txout.txout.value; - } else if !txout.is_mature(chain_tip.height) { - immature += txout.txout.value; - } - } - ChainPosition::Unconfirmed { .. } => { - if trust_predicate(&spk_i, txout.txout.script_pubkey) { - trusted_pending += txout.txout.value; - } else { - untrusted_pending += txout.txout.value; - } - } - } - } - - Ok(Balance { - immature, - trusted_pending, - untrusted_pending, - confirmed, - }) - } - - /// Get the total balance of `outpoints` that are in `chain` of `chain_tip`. - /// - /// This is the infallible version of [`try_balance`]. - /// - /// ### Minimum confirmations - /// - /// To filter for transactions with at least `N` confirmations, pass a `chain_tip` that is - /// `N - 1` blocks below the actual tip. This ensures that only transactions with at least `N` - /// confirmations are counted as confirmed in the returned [`Balance`]. - /// - /// ``` - /// # use bdk_chain::tx_graph::TxGraph; - /// # use bdk_chain::{local_chain::LocalChain, CanonicalizationParams, ConfirmationBlockTime}; - /// # use bdk_testenv::{hash, utils::new_tx}; - /// # use bitcoin::{Amount, BlockHash, OutPoint, ScriptBuf, Transaction, TxIn, TxOut}; - /// - /// # let spk = ScriptBuf::from_hex("0014c692ecf13534982a9a2834565cbd37add8027140").unwrap(); - /// # let chain = - /// # LocalChain::::from_blocks((0..=15).map(|i| (i as u32, hash!("h"))).collect()).unwrap(); - /// # let mut graph: TxGraph = TxGraph::default(); - /// # let coinbase_tx = Transaction { - /// # input: vec![TxIn { - /// # previous_output: OutPoint::null(), - /// # ..Default::default() - /// # }], - /// # output: vec![TxOut { - /// # value: Amount::from_sat(70000), - /// # script_pubkey: spk.clone(), - /// # }], - /// # ..new_tx(0) - /// # }; - /// # let tx = Transaction { - /// # input: vec![TxIn { - /// # previous_output: OutPoint::new(coinbase_tx.compute_txid(), 0), - /// # ..Default::default() - /// # }], - /// # output: vec![TxOut { - /// # value: Amount::from_sat(42_000), - /// # script_pubkey: spk.clone(), - /// # }], - /// # ..new_tx(1) - /// # }; - /// # let txid = tx.compute_txid(); - /// # let _ = graph.insert_tx(tx.clone()); - /// # let _ = graph.insert_anchor( - /// # txid, - /// # ConfirmationBlockTime { - /// # block_id: chain.get(10).unwrap().block_id(), - /// # confirmation_time: 123456, - /// # }, - /// # ); - /// - /// let minimum_confirmations = 6; - /// let target_tip = chain - /// .tip() - /// .floor_below(minimum_confirmations - 1) - /// .expect("checkpoint from local chain must have genesis"); - /// let balance = graph.balance( - /// &chain, - /// target_tip.block_id(), - /// CanonicalizationParams::default(), - /// std::iter::once(((), OutPoint::new(txid, 0))), - /// |_: &(), _| true, - /// ); - /// assert_eq!(balance.confirmed, Amount::from_sat(42_000)); - /// ``` - /// - /// [`try_balance`]: Self::try_balance - pub fn balance, OI: Clone>( - &self, - chain: &C, - chain_tip: BlockId, - params: CanonicalizationParams, - outpoints: impl IntoIterator, - trust_predicate: impl FnMut(&OI, ScriptBuf) -> bool, - ) -> Balance { - self.try_balance(chain, chain_tip, params, outpoints, trust_predicate) - .expect("oracle is infallible") - } - - /// List txids that are expected to exist under the given spks. - /// - /// This is used to fill - /// [`SyncRequestBuilder::expected_spk_txids`](bdk_core::spk_client::SyncRequestBuilder::expected_spk_txids). - /// - /// - /// The spk index range can be constrained with `range`. - /// - /// # Error - /// - /// If the [`ChainOracle`] implementation (`chain`) fails, an error will be returned with the - /// returned item. - /// - /// If the [`ChainOracle`] is infallible, - /// [`list_expected_spk_txids`](Self::list_expected_spk_txids) can be used instead. - pub fn try_list_expected_spk_txids<'a, C, I>( - &'a self, - chain: &'a C, - chain_tip: BlockId, - indexer: &'a impl AsRef>, - spk_index_range: impl RangeBounds + 'a, - ) -> impl Iterator> + 'a - where - C: ChainOracle, - I: fmt::Debug + Clone + Ord + 'a, - { - let indexer = indexer.as_ref(); - self.try_list_canonical_txs(chain, chain_tip, CanonicalizationParams::default()) - .flat_map(move |res| -> Vec> { - let range = &spk_index_range; - let c_tx = match res { - Ok(c_tx) => c_tx, - Err(err) => return vec![Err(err)], - }; - let relevant_spks = indexer.relevant_spks_of_tx(&c_tx.tx_node); - relevant_spks - .into_iter() - .filter(|(i, _)| range.contains(i)) - .map(|(_, spk)| Ok((spk, c_tx.tx_node.txid))) - .collect() - }) - } - - /// List txids that are expected to exist under the given spks. - /// - /// This is the infallible version of - /// [`try_list_expected_spk_txids`](Self::try_list_expected_spk_txids). - pub fn list_expected_spk_txids<'a, C, I>( - &'a self, - chain: &'a C, - chain_tip: BlockId, - indexer: &'a impl AsRef>, - spk_index_range: impl RangeBounds + 'a, - ) -> impl Iterator + 'a - where - C: ChainOracle, - I: fmt::Debug + Clone + Ord + 'a, - { - self.try_list_expected_spk_txids(chain, chain_tip, indexer, spk_index_range) - .map(|r| r.expect("infallible")) + ) -> CanonicalView { + CanonicalView::new(self, chain, chain_tip, params).expect("infallible") } /// Construct a `TxGraph` from a `changeset`. diff --git a/crates/chain/tests/test_indexed_tx_graph.rs b/crates/chain/tests/test_indexed_tx_graph.rs index db91a34b3..13a3ab0ba 100644 --- a/crates/chain/tests/test_indexed_tx_graph.rs +++ b/crates/chain/tests/test_indexed_tx_graph.rs @@ -460,32 +460,21 @@ fn test_list_owned_txouts() { .map(|cp| cp.block_id()) .unwrap_or_else(|| panic!("block must exist at {height}")); let txouts = graph - .graph() - .filter_chain_txouts( - &local_chain, - chain_tip, - CanonicalizationParams::default(), - graph.index.outpoints().iter().cloned(), - ) + .canonical_view(&local_chain, chain_tip, CanonicalizationParams::default()) + .filter_outpoints(graph.index.outpoints().iter().cloned()) .collect::>(); let utxos = graph - .graph() - .filter_chain_unspents( - &local_chain, - chain_tip, - CanonicalizationParams::default(), - graph.index.outpoints().iter().cloned(), - ) + .canonical_view(&local_chain, chain_tip, CanonicalizationParams::default()) + .filter_unspent_outpoints(graph.index.outpoints().iter().cloned()) .collect::>(); - let balance = graph.graph().balance( - &local_chain, - chain_tip, - CanonicalizationParams::default(), - graph.index.outpoints().iter().cloned(), - |_, spk: ScriptBuf| trusted_spks.contains(&spk), - ); + let balance = graph + .canonical_view(&local_chain, chain_tip, CanonicalizationParams::default()) + .balance( + graph.index.outpoints().iter().cloned(), + |_, spk: ScriptBuf| trusted_spks.contains(&spk), + ); let confirmed_txouts_txid = txouts .iter() @@ -789,15 +778,15 @@ fn test_get_chain_position() { // check chain position let chain_pos = graph - .graph() - .list_canonical_txs( + .canonical_view( chain, chain.tip().block_id(), CanonicalizationParams::default(), ) + .txs() .find_map(|canon_tx| { - if canon_tx.tx_node.txid == txid { - Some(canon_tx.chain_position) + if canon_tx.txid == txid { + Some(canon_tx.pos) } else { None } diff --git a/crates/chain/tests/test_tx_graph.rs b/crates/chain/tests/test_tx_graph.rs index 685b62c6e..b2a359608 100644 --- a/crates/chain/tests/test_tx_graph.rs +++ b/crates/chain/tests/test_tx_graph.rs @@ -1015,12 +1015,8 @@ fn test_chain_spends() { let build_canonical_spends = |chain: &LocalChain, tx_graph: &TxGraph| -> HashMap { tx_graph - .filter_chain_txouts( - chain, - tip.block_id(), - CanonicalizationParams::default(), - tx_graph.all_txouts().map(|(op, _)| ((), op)), - ) + .canonical_view(chain, tip.block_id(), CanonicalizationParams::default()) + .filter_outpoints(tx_graph.all_txouts().map(|(op, _)| ((), op))) .filter_map(|(_, full_txo)| Some((full_txo.outpoint, full_txo.spent_by?))) .collect() }; @@ -1028,8 +1024,9 @@ fn test_chain_spends() { tx_graph: &TxGraph| -> HashMap> { tx_graph - .list_canonical_txs(chain, tip.block_id(), CanonicalizationParams::default()) - .map(|canon_tx| (canon_tx.tx_node.txid, canon_tx.chain_position)) + .canonical_view(chain, tip.block_id(), CanonicalizationParams::default()) + .txs() + .map(|canon_tx| (canon_tx.txid, canon_tx.pos)) .collect() }; @@ -1201,36 +1198,36 @@ fn transactions_inserted_into_tx_graph_are_not_canonical_until_they_have_an_anch .collect(); let chain = LocalChain::from_blocks(blocks).unwrap(); let canonical_txs: Vec<_> = graph - .list_canonical_txs( + .canonical_view( &chain, chain.tip().block_id(), CanonicalizationParams::default(), ) + .txs() .collect(); assert!(canonical_txs.is_empty()); // tx0 with seen_at should be returned by canonical txs let _ = graph.insert_seen_at(txids[0], 2); - let mut canonical_txs = graph.list_canonical_txs( + let canonical_view = graph.canonical_view( &chain, chain.tip().block_id(), CanonicalizationParams::default(), ); - assert_eq!( - canonical_txs.next().map(|tx| tx.tx_node.txid).unwrap(), - txids[0] - ); + let mut canonical_txs = canonical_view.txs(); + assert_eq!(canonical_txs.next().map(|tx| tx.txid).unwrap(), txids[0]); drop(canonical_txs); // tx1 with anchor is also canonical let _ = graph.insert_anchor(txids[1], block_id!(2, "B")); let canonical_txids: Vec<_> = graph - .list_canonical_txs( + .canonical_view( &chain, chain.tip().block_id(), CanonicalizationParams::default(), ) - .map(|tx| tx.tx_node.txid) + .txs() + .map(|tx| tx.txid) .collect(); assert!(canonical_txids.contains(&txids[1])); assert!(graph.txs_with_no_anchor_or_last_seen().next().is_none()); diff --git a/crates/chain/tests/test_tx_graph_conflicts.rs b/crates/chain/tests/test_tx_graph_conflicts.rs index cbd5d5417..1c413e4e9 100644 --- a/crates/chain/tests/test_tx_graph_conflicts.rs +++ b/crates/chain/tests/test_tx_graph_conflicts.rs @@ -972,8 +972,9 @@ fn test_tx_conflict_handling() { let txs = env .tx_graph - .list_canonical_txs(&local_chain, chain_tip, env.canonicalization_params.clone()) - .map(|tx| tx.tx_node.txid) + .canonical_view(&local_chain, chain_tip, env.canonicalization_params.clone()) + .txs() + .map(|tx| tx.txid) .collect::>(); let exp_txs = scenario .exp_chain_txs @@ -988,12 +989,8 @@ fn test_tx_conflict_handling() { let txouts = env .tx_graph - .filter_chain_txouts( - &local_chain, - chain_tip, - env.canonicalization_params.clone(), - env.indexer.outpoints().iter().cloned(), - ) + .canonical_view(&local_chain, chain_tip, env.canonicalization_params.clone()) + .filter_outpoints(env.indexer.outpoints().iter().cloned()) .map(|(_, full_txout)| full_txout.outpoint) .collect::>(); let exp_txouts = scenario @@ -1012,12 +1009,8 @@ fn test_tx_conflict_handling() { let utxos = env .tx_graph - .filter_chain_unspents( - &local_chain, - chain_tip, - env.canonicalization_params.clone(), - env.indexer.outpoints().iter().cloned(), - ) + .canonical_view(&local_chain, chain_tip, env.canonicalization_params.clone()) + .filter_unspent_outpoints(env.indexer.outpoints().iter().cloned()) .map(|(_, full_txout)| full_txout.outpoint) .collect::>(); let exp_utxos = scenario @@ -1034,13 +1027,13 @@ fn test_tx_conflict_handling() { scenario.name ); - let balance = env.tx_graph.balance( - &local_chain, - chain_tip, - env.canonicalization_params.clone(), - env.indexer.outpoints().iter().cloned(), - |_, spk: ScriptBuf| env.indexer.index_of_spk(spk).is_some(), - ); + let balance = env + .tx_graph + .canonical_view(&local_chain, chain_tip, env.canonicalization_params.clone()) + .balance( + env.indexer.outpoints().iter().cloned(), + |_, spk: ScriptBuf| env.indexer.index_of_spk(spk).is_some(), + ); assert_eq!( balance, scenario.exp_balance, "\n[{}] 'balance' failed", diff --git a/crates/electrum/tests/test_electrum.rs b/crates/electrum/tests/test_electrum.rs index 7fcf5d801..8c7580f97 100644 --- a/crates/electrum/tests/test_electrum.rs +++ b/crates/electrum/tests/test_electrum.rs @@ -40,13 +40,9 @@ fn get_balance( ) -> anyhow::Result { let chain_tip = recv_chain.tip().block_id(); let outpoints = recv_graph.index.outpoints().clone(); - let balance = recv_graph.graph().balance( - recv_chain, - chain_tip, - CanonicalizationParams::default(), - outpoints, - |_, _| true, - ); + let balance = recv_graph + .canonical_view(recv_chain, chain_tip, CanonicalizationParams::default()) + .balance(outpoints, |_, _| true); Ok(balance) } @@ -150,7 +146,11 @@ pub fn detect_receive_tx_cancel() -> anyhow::Result<()> { let sync_request = SyncRequest::builder() .chain_tip(chain.tip()) .spks_with_indexes(graph.index.all_spks().clone()) - .expected_spk_txids(graph.list_expected_spk_txids(&chain, chain.tip().block_id(), ..)); + .expected_spk_txids( + graph + .canonical_view(&chain, chain.tip().block_id(), Default::default()) + .list_expected_spk_txids(&graph.index, ..), + ); let sync_response = client.sync(sync_request, BATCH_SIZE, true)?; assert!( sync_response @@ -175,7 +175,11 @@ pub fn detect_receive_tx_cancel() -> anyhow::Result<()> { let sync_request = SyncRequest::builder() .chain_tip(chain.tip()) .spks_with_indexes(graph.index.all_spks().clone()) - .expected_spk_txids(graph.list_expected_spk_txids(&chain, chain.tip().block_id(), ..)); + .expected_spk_txids( + graph + .canonical_view(&chain, chain.tip().block_id(), Default::default()) + .list_expected_spk_txids(&graph.index, ..), + ); let sync_response = client.sync(sync_request, BATCH_SIZE, true)?; assert!( sync_response diff --git a/crates/esplora/tests/async_ext.rs b/crates/esplora/tests/async_ext.rs index c90c33112..3c628c20d 100644 --- a/crates/esplora/tests/async_ext.rs +++ b/crates/esplora/tests/async_ext.rs @@ -87,7 +87,11 @@ pub async fn detect_receive_tx_cancel() -> anyhow::Result<()> { let sync_request = SyncRequest::builder() .chain_tip(chain.tip()) .spks_with_indexes(graph.index.all_spks().clone()) - .expected_spk_txids(graph.list_expected_spk_txids(&chain, chain.tip().block_id(), ..)); + .expected_spk_txids( + graph + .canonical_view(&chain, chain.tip().block_id(), Default::default()) + .list_expected_spk_txids(&graph.index, ..), + ); let sync_response = client.sync(sync_request, 1).await?; assert!( sync_response @@ -112,7 +116,11 @@ pub async fn detect_receive_tx_cancel() -> anyhow::Result<()> { let sync_request = SyncRequest::builder() .chain_tip(chain.tip()) .spks_with_indexes(graph.index.all_spks().clone()) - .expected_spk_txids(graph.list_expected_spk_txids(&chain, chain.tip().block_id(), ..)); + .expected_spk_txids( + graph + .canonical_view(&chain, chain.tip().block_id(), Default::default()) + .list_expected_spk_txids(&graph.index, ..), + ); let sync_response = client.sync(sync_request, 1).await?; assert!( sync_response diff --git a/crates/esplora/tests/blocking_ext.rs b/crates/esplora/tests/blocking_ext.rs index a09b3ccce..4d5683e8b 100644 --- a/crates/esplora/tests/blocking_ext.rs +++ b/crates/esplora/tests/blocking_ext.rs @@ -87,7 +87,11 @@ pub fn detect_receive_tx_cancel() -> anyhow::Result<()> { let sync_request = SyncRequest::builder() .chain_tip(chain.tip()) .spks_with_indexes(graph.index.all_spks().clone()) - .expected_spk_txids(graph.list_expected_spk_txids(&chain, chain.tip().block_id(), ..)); + .expected_spk_txids( + graph + .canonical_view(&chain, chain.tip().block_id(), Default::default()) + .list_expected_spk_txids(&graph.index, ..), + ); let sync_response = client.sync(sync_request, 1)?; assert!( sync_response @@ -112,7 +116,11 @@ pub fn detect_receive_tx_cancel() -> anyhow::Result<()> { let sync_request = SyncRequest::builder() .chain_tip(chain.tip()) .spks_with_indexes(graph.index.all_spks().clone()) - .expected_spk_txids(graph.list_expected_spk_txids(&chain, chain.tip().block_id(), ..)); + .expected_spk_txids( + graph + .canonical_view(&chain, chain.tip().block_id(), Default::default()) + .list_expected_spk_txids(&graph.index, ..), + ); let sync_response = client.sync(sync_request, 1)?; assert!( sync_response diff --git a/examples/example_bitcoind_rpc_polling/src/main.rs b/examples/example_bitcoind_rpc_polling/src/main.rs index cb710151a..8c5483bf4 100644 --- a/examples/example_bitcoind_rpc_polling/src/main.rs +++ b/examples/example_bitcoind_rpc_polling/src/main.rs @@ -145,13 +145,14 @@ fn main() -> anyhow::Result<()> { chain.tip(), fallback_height, graph - .graph() - .list_canonical_txs( + .canonical_view( &*chain, chain.tip().block_id(), CanonicalizationParams::default(), ) - .filter(|tx| tx.chain_position.is_unconfirmed()), + .txs() + .filter(|tx| tx.pos.is_unconfirmed()) + .map(|tx| tx.tx), ) }; let mut db_stage = ChangeSet::default(); @@ -195,13 +196,15 @@ fn main() -> anyhow::Result<()> { last_print = Instant::now(); let synced_to = chain.tip(); let balance = { - graph.graph().balance( - &*chain, - synced_to.block_id(), - CanonicalizationParams::default(), - graph.index.outpoints().iter().cloned(), - |(k, _), _| k == &Keychain::Internal, - ) + graph + .canonical_view( + &*chain, + synced_to.block_id(), + CanonicalizationParams::default(), + ) + .balance(graph.index.outpoints().iter().cloned(), |(k, _), _| { + k == &Keychain::Internal + }) }; println!( "[{:>10}s] synced to {} @ {} | total: {}", @@ -245,13 +248,14 @@ fn main() -> anyhow::Result<()> { chain.tip(), fallback_height, graph - .graph() - .list_canonical_txs( + .canonical_view( &*chain, chain.tip().block_id(), CanonicalizationParams::default(), ) - .filter(|tx| tx.chain_position.is_unconfirmed()), + .txs() + .filter(|tx| tx.pos.is_unconfirmed()) + .map(|tx| tx.tx), ) }; @@ -350,13 +354,15 @@ fn main() -> anyhow::Result<()> { last_print = Some(Instant::now()); let synced_to = chain.tip(); let balance = { - graph.graph().balance( - &*chain, - synced_to.block_id(), - CanonicalizationParams::default(), - graph.index.outpoints().iter().cloned(), - |(k, _), _| k == &Keychain::Internal, - ) + graph + .canonical_view( + &*chain, + synced_to.block_id(), + CanonicalizationParams::default(), + ) + .balance(graph.index.outpoints().iter().cloned(), |(k, _), _| { + k == &Keychain::Internal + }) }; println!( "[{:>10}s] synced to {} @ {} / {} | total: {}", diff --git a/examples/example_cli/src/lib.rs b/examples/example_cli/src/lib.rs index 96a41802f..5f642581a 100644 --- a/examples/example_cli/src/lib.rs +++ b/examples/example_cli/src/lib.rs @@ -432,13 +432,8 @@ pub fn planned_utxos( let chain_tip = chain.get_chain_tip()?; let outpoints = graph.index.outpoints(); graph - .graph() - .try_filter_chain_unspents( - chain, - chain_tip, - CanonicalizationParams::default(), - outpoints.iter().cloned(), - )? + .try_canonical_view(chain, chain_tip, CanonicalizationParams::default())? + .filter_unspent_outpoints(outpoints.iter().cloned()) .filter_map(|((k, i), full_txo)| -> Option> { let desc = graph .index @@ -529,13 +524,15 @@ pub fn handle_commands( } } - let balance = graph.graph().try_balance( - chain, - chain.get_chain_tip()?, - CanonicalizationParams::default(), - graph.index.outpoints().iter().cloned(), - |(k, _), _| k == &Keychain::Internal, - )?; + let balance = graph + .try_canonical_view( + chain, + chain.get_chain_tip()?, + CanonicalizationParams::default(), + )? + .balance(graph.index.outpoints().iter().cloned(), |(k, _), _| { + k == &Keychain::Internal + }); let confirmed_total = balance.confirmed + balance.immature; let unconfirmed_total = balance.untrusted_pending + balance.trusted_pending; @@ -573,13 +570,8 @@ pub fn handle_commands( unconfirmed, } => { let txouts = graph - .graph() - .try_filter_chain_txouts( - chain, - chain_tip, - CanonicalizationParams::default(), - outpoints.iter().cloned(), - )? + .try_canonical_view(chain, chain_tip, CanonicalizationParams::default())? + .filter_outpoints(outpoints.iter().cloned()) .filter(|(_, full_txo)| match (spent, unspent) { (true, false) => full_txo.spent_by.is_some(), (false, true) => full_txo.spent_by.is_none(), diff --git a/examples/example_electrum/src/main.rs b/examples/example_electrum/src/main.rs index bc76776a6..aa89f07e1 100644 --- a/examples/example_electrum/src/main.rs +++ b/examples/example_electrum/src/main.rs @@ -213,11 +213,14 @@ fn main() -> anyhow::Result<()> { eprintln!("[ SCANNING {pc:03.0}% ] {item}"); }); - request = request.expected_spk_txids(graph.list_expected_spk_txids( + let canonical_view = graph.canonical_view( &*chain, chain_tip.block_id(), - .., - )); + CanonicalizationParams::default(), + ); + + request = request + .expected_spk_txids(canonical_view.list_expected_spk_txids(&graph.index, ..)); if all_spks { request = request.spks_with_indexes(graph.index.revealed_spks(..)); } @@ -227,28 +230,17 @@ fn main() -> anyhow::Result<()> { if utxos { let init_outpoints = graph.index.outpoints(); request = request.outpoints( - graph - .graph() - .filter_chain_unspents( - &*chain, - chain_tip.block_id(), - CanonicalizationParams::default(), - init_outpoints.iter().cloned(), - ) + canonical_view + .filter_unspent_outpoints(init_outpoints.iter().cloned()) .map(|(_, utxo)| utxo.outpoint), ); }; if unconfirmed { request = request.txids( - graph - .graph() - .list_canonical_txs( - &*chain, - chain_tip.block_id(), - CanonicalizationParams::default(), - ) - .filter(|canonical_tx| !canonical_tx.chain_position.is_confirmed()) - .map(|canonical_tx| canonical_tx.tx_node.txid), + canonical_view + .txs() + .filter(|canonical_tx| !canonical_tx.pos.is_confirmed()) + .map(|canonical_tx| canonical_tx.txid), ); } diff --git a/examples/example_esplora/src/main.rs b/examples/example_esplora/src/main.rs index f41d2536e..99f72391c 100644 --- a/examples/example_esplora/src/main.rs +++ b/examples/example_esplora/src/main.rs @@ -225,11 +225,14 @@ fn main() -> anyhow::Result<()> { { let graph = graph.lock().unwrap(); let chain = chain.lock().unwrap(); - request = request.expected_spk_txids(graph.list_expected_spk_txids( + let canonical_view = graph.canonical_view( &*chain, local_tip.block_id(), - .., - )); + CanonicalizationParams::default(), + ); + + request = request + .expected_spk_txids(canonical_view.list_expected_spk_txids(&graph.index, ..)); if *all_spks { request = request.spks_with_indexes(graph.index.revealed_spks(..)); } @@ -242,14 +245,8 @@ fn main() -> anyhow::Result<()> { // `EsploraExt::update_tx_graph_without_keychain`. let init_outpoints = graph.index.outpoints(); request = request.outpoints( - graph - .graph() - .filter_chain_unspents( - &*chain, - local_tip.block_id(), - CanonicalizationParams::default(), - init_outpoints.iter().cloned(), - ) + canonical_view + .filter_unspent_outpoints(init_outpoints.iter().cloned()) .map(|(_, utxo)| utxo.outpoint), ); }; @@ -258,15 +255,10 @@ fn main() -> anyhow::Result<()> { // We provide the unconfirmed txids to // `EsploraExt::update_tx_graph_without_keychain`. request = request.txids( - graph - .graph() - .list_canonical_txs( - &*chain, - local_tip.block_id(), - CanonicalizationParams::default(), - ) - .filter(|canonical_tx| !canonical_tx.chain_position.is_confirmed()) - .map(|canonical_tx| canonical_tx.tx_node.txid), + canonical_view + .txs() + .filter(|canonical_tx| !canonical_tx.pos.is_confirmed()) + .map(|canonical_tx| canonical_tx.txid), ); } } From 45249457aa2f1205f5944e73c8d14468712648ab Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=BF=97=E5=AE=87?= Date: Thu, 11 Sep 2025 03:48:27 +0000 Subject: [PATCH 02/12] feat(chain): Add min_confirmations parameter to CanonicalView::balance MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add min_confirmations parameter to control confirmation depth requirements: - min_confirmations = 0: Include all confirmed transactions (same as 1) - min_confirmations = 1: Standard behavior - require at least 1 confirmation - min_confirmations = 6: High security - require at least 6 confirmations Transactions with fewer than min_confirmations are treated as trusted/untrusted pending based on the trust_predicate. This restores the minimum confirmation functionality that was available in the old TxGraph::balance doctest but with a more intuitive API since CanonicalView has the tip internally. 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --- crates/bitcoind_rpc/tests/test_emitter.rs | 2 +- crates/chain/benches/indexer.rs | 2 +- crates/chain/src/canonical_view.rs | 34 +++++++++++++++++-- crates/chain/tests/test_indexed_tx_graph.rs | 1 + crates/chain/tests/test_tx_graph_conflicts.rs | 1 + crates/electrum/tests/test_electrum.rs | 2 +- .../example_bitcoind_rpc_polling/src/main.rs | 16 +++++---- examples/example_cli/src/lib.rs | 8 +++-- 8 files changed, 52 insertions(+), 14 deletions(-) diff --git a/crates/bitcoind_rpc/tests/test_emitter.rs b/crates/bitcoind_rpc/tests/test_emitter.rs index 79b44b00a..6453037e6 100644 --- a/crates/bitcoind_rpc/tests/test_emitter.rs +++ b/crates/bitcoind_rpc/tests/test_emitter.rs @@ -312,7 +312,7 @@ fn get_balance( let outpoints = recv_graph.index.outpoints().clone(); let balance = recv_graph .canonical_view(recv_chain, chain_tip, CanonicalizationParams::default()) - .balance(outpoints, |_, _| true); + .balance(outpoints, |_, _| true, 1); Ok(balance) } diff --git a/crates/chain/benches/indexer.rs b/crates/chain/benches/indexer.rs index df4e3f361..3caea42d2 100644 --- a/crates/chain/benches/indexer.rs +++ b/crates/chain/benches/indexer.rs @@ -86,7 +86,7 @@ fn do_bench(indexed_tx_graph: &KeychainTxGraph, chain: &LocalChain) { let op = graph.index.outpoints().clone(); let bal = graph .canonical_view(chain, chain_tip, CanonicalizationParams::default()) - .balance(op, |_, _| false); + .balance(op, |_, _| false, 1); assert_eq!(bal.total(), AMOUNT * TX_CT as u64); } diff --git a/crates/chain/src/canonical_view.rs b/crates/chain/src/canonical_view.rs index 2685344e9..32adc456e 100644 --- a/crates/chain/src/canonical_view.rs +++ b/crates/chain/src/canonical_view.rs @@ -209,10 +209,25 @@ impl CanonicalView { /// `outpoints` is a list of outpoints we are interested in, coupled with an outpoint identifier /// (`O`) for convenience. If `O` is not necessary, the caller can use `()`, or /// [`Iterator::enumerate`] over a list of [`OutPoint`]s. + /// + /// ### Minimum confirmations + /// + /// `min_confirmations` specifies the minimum number of confirmations required for a transaction + /// to be counted as confirmed in the returned [`Balance`]. Transactions with fewer than + /// `min_confirmations` will be treated as trusted pending (assuming the `trust_predicate` + /// returns `true`). + /// + /// - `min_confirmations = 0`: Include all confirmed transactions (same as `1`) + /// - `min_confirmations = 1`: Standard behavior - require at least 1 confirmation + /// - `min_confirmations = 6`: High security - require at least 6 confirmations + /// + /// Note: `0` and `1` behave identically since confirmed transactions always have ≥1 + /// confirmation. pub fn balance<'v, O: Clone + 'v>( &'v self, outpoints: impl IntoIterator + 'v, mut trust_predicate: impl FnMut(&O, ScriptBuf) -> bool, + min_confirmations: u32, ) -> Balance { let mut immature = Amount::ZERO; let mut trusted_pending = Amount::ZERO; @@ -221,8 +236,23 @@ impl CanonicalView { for (spk_i, txout) in self.filter_unspent_outpoints(outpoints) { match &txout.chain_position { - ChainPosition::Confirmed { .. } => { - if txout.is_confirmed_and_spendable(self.tip.height) { + 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.txout.script_pubkey) { + 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; diff --git a/crates/chain/tests/test_indexed_tx_graph.rs b/crates/chain/tests/test_indexed_tx_graph.rs index 13a3ab0ba..5b44cb163 100644 --- a/crates/chain/tests/test_indexed_tx_graph.rs +++ b/crates/chain/tests/test_indexed_tx_graph.rs @@ -474,6 +474,7 @@ fn test_list_owned_txouts() { .balance( graph.index.outpoints().iter().cloned(), |_, spk: ScriptBuf| trusted_spks.contains(&spk), + 1, ); let confirmed_txouts_txid = txouts diff --git a/crates/chain/tests/test_tx_graph_conflicts.rs b/crates/chain/tests/test_tx_graph_conflicts.rs index 1c413e4e9..f91a3a8d3 100644 --- a/crates/chain/tests/test_tx_graph_conflicts.rs +++ b/crates/chain/tests/test_tx_graph_conflicts.rs @@ -1033,6 +1033,7 @@ fn test_tx_conflict_handling() { .balance( env.indexer.outpoints().iter().cloned(), |_, spk: ScriptBuf| env.indexer.index_of_spk(spk).is_some(), + 1, ); assert_eq!( balance, scenario.exp_balance, diff --git a/crates/electrum/tests/test_electrum.rs b/crates/electrum/tests/test_electrum.rs index 8c7580f97..0663061e4 100644 --- a/crates/electrum/tests/test_electrum.rs +++ b/crates/electrum/tests/test_electrum.rs @@ -42,7 +42,7 @@ fn get_balance( let outpoints = recv_graph.index.outpoints().clone(); let balance = recv_graph .canonical_view(recv_chain, chain_tip, CanonicalizationParams::default()) - .balance(outpoints, |_, _| true); + .balance(outpoints, |_, _| true, 1); Ok(balance) } diff --git a/examples/example_bitcoind_rpc_polling/src/main.rs b/examples/example_bitcoind_rpc_polling/src/main.rs index 8c5483bf4..0263c5b0b 100644 --- a/examples/example_bitcoind_rpc_polling/src/main.rs +++ b/examples/example_bitcoind_rpc_polling/src/main.rs @@ -202,9 +202,11 @@ fn main() -> anyhow::Result<()> { synced_to.block_id(), CanonicalizationParams::default(), ) - .balance(graph.index.outpoints().iter().cloned(), |(k, _), _| { - k == &Keychain::Internal - }) + .balance( + graph.index.outpoints().iter().cloned(), + |(k, _), _| k == &Keychain::Internal, + 1, + ) }; println!( "[{:>10}s] synced to {} @ {} | total: {}", @@ -360,9 +362,11 @@ fn main() -> anyhow::Result<()> { synced_to.block_id(), CanonicalizationParams::default(), ) - .balance(graph.index.outpoints().iter().cloned(), |(k, _), _| { - k == &Keychain::Internal - }) + .balance( + graph.index.outpoints().iter().cloned(), + |(k, _), _| k == &Keychain::Internal, + 1, + ) }; println!( "[{:>10}s] synced to {} @ {} / {} | total: {}", diff --git a/examples/example_cli/src/lib.rs b/examples/example_cli/src/lib.rs index 5f642581a..5ef4130f7 100644 --- a/examples/example_cli/src/lib.rs +++ b/examples/example_cli/src/lib.rs @@ -530,9 +530,11 @@ pub fn handle_commands( chain.get_chain_tip()?, CanonicalizationParams::default(), )? - .balance(graph.index.outpoints().iter().cloned(), |(k, _), _| { - k == &Keychain::Internal - }); + .balance( + graph.index.outpoints().iter().cloned(), + |(k, _), _| k == &Keychain::Internal, + 1, + ); let confirmed_total = balance.confirmed + balance.immature; let unconfirmed_total = balance.untrusted_pending + balance.trusted_pending; From 54409bc4aac05fb25d02909398008204eee70634 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=BF=97=E5=AE=87?= Date: Thu, 11 Sep 2025 03:48:27 +0000 Subject: [PATCH 03/12] test(chain): Add comprehensive tests for min_confirmations parameter MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add test file `tests/test_canonical_view.rs` with three comprehensive test cases: 1. `test_min_confirmations_parameter`: Tests basic min_confirmations functionality - Verifies min_confirmations = 0 and 1 behave identically - Tests edge case where transaction has exactly required confirmations - Tests case where transaction has insufficient confirmations 2. `test_min_confirmations_with_untrusted_tx`: Tests trust predicate interaction - Verifies insufficient confirmations + untrusted predicate = untrusted_pending - Ensures trust predicate is respected when confirmations are insufficient 3. `test_min_confirmations_multiple_transactions`: Tests complex scenarios - Multiple transactions with different confirmation counts - Verifies correct categorization based on min_confirmations threshold - Tests both min_confirmations = 5 and min_confirmations = 10 scenarios These tests validate that the min_confirmations parameter correctly controls when transactions are treated as confirmed vs trusted/untrusted pending. 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --- crates/chain/tests/test_canonical_view.rs | 304 ++++++++++++++++++++++ 1 file changed, 304 insertions(+) create mode 100644 crates/chain/tests/test_canonical_view.rs diff --git a/crates/chain/tests/test_canonical_view.rs b/crates/chain/tests/test_canonical_view.rs new file mode 100644 index 000000000..5d6740ac5 --- /dev/null +++ b/crates/chain/tests/test_canonical_view.rs @@ -0,0 +1,304 @@ +#![cfg(feature = "miniscript")] + +use bdk_chain::{local_chain::LocalChain, CanonicalizationParams, ConfirmationBlockTime, TxGraph}; +use bdk_testenv::{hash, utils::new_tx}; +use bitcoin::{Amount, OutPoint, ScriptBuf, Transaction, TxIn, TxOut}; + +#[test] +fn test_min_confirmations_parameter() { + // Create a local chain with several blocks + let chain = LocalChain::from_blocks( + [ + (0, hash!("block0")), + (1, hash!("block1")), + (2, hash!("block2")), + (3, hash!("block3")), + (4, hash!("block4")), + (5, hash!("block5")), + (6, hash!("block6")), + (7, hash!("block7")), + (8, hash!("block8")), + (9, hash!("block9")), + (10, hash!("block10")), + ] + .into(), + ) + .unwrap(); + + let mut tx_graph = TxGraph::default(); + + // Create a non-coinbase transaction + 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 outpoint = OutPoint::new(txid, 0); + + // Insert transaction into graph + let _ = tx_graph.insert_tx(tx.clone()); + + // Test 1: Transaction confirmed at height 5, tip at height 10 (6 confirmations) + let anchor_height_5 = ConfirmationBlockTime { + block_id: chain.get(5).unwrap().block_id(), + confirmation_time: 123456, + }; + let _ = tx_graph.insert_anchor(txid, anchor_height_5); + + let chain_tip = chain.tip().block_id(); + let canonical_view = + tx_graph.canonical_view(&chain, chain_tip, CanonicalizationParams::default()); + + // Test min_confirmations = 1: Should be confirmed (has 6 confirmations) + let balance_1_conf = canonical_view.balance( + [((), outpoint)], + |_, _| true, // trust all + 1, + ); + + assert_eq!(balance_1_conf.confirmed, 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, + ); + 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, + ); + 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, + ); + assert_eq!(balance_0_conf.confirmed, Amount::from_sat(50_000)); + assert_eq!(balance_0_conf.trusted_pending, Amount::ZERO); + assert_eq!(balance_0_conf, balance_1_conf); +} + +#[test] +fn test_min_confirmations_with_untrusted_tx() { + // Create a local chain + let chain = LocalChain::from_blocks( + [ + (0, hash!("genesis")), + (1, hash!("b1")), + (2, hash!("b2")), + (3, hash!("b3")), + (4, hash!("b4")), + (5, hash!("b5")), + (6, hash!("b6")), + (7, hash!("b7")), + (8, hash!("b8")), + (9, hash!("b9")), + (10, hash!("tip")), + ] + .into(), + ) + .unwrap(); + + let mut tx_graph = TxGraph::default(); + + // Create a transaction + let tx = Transaction { + input: vec![TxIn { + previous_output: OutPoint::new(hash!("parent"), 0), + ..Default::default() + }], + output: vec![TxOut { + value: Amount::from_sat(25_000), + script_pubkey: ScriptBuf::new(), + }], + ..new_tx(1) + }; + let txid = tx.compute_txid(); + let outpoint = OutPoint::new(txid, 0); + + let _ = tx_graph.insert_tx(tx.clone()); + + // Anchor at height 8, tip at height 10 (3 confirmations) + let anchor = ConfirmationBlockTime { + block_id: chain.get(8).unwrap().block_id(), + confirmation_time: 123456, + }; + let _ = tx_graph.insert_anchor(txid, anchor); + + let canonical_view = tx_graph.canonical_view( + &chain, + chain.tip().block_id(), + CanonicalizationParams::default(), + ); + + // Test with min_confirmations = 5 and untrusted predicate + let balance = canonical_view.balance( + [((), outpoint)], + |_, _| false, // don't trust + 5, + ); + + // Should be untrusted pending (not enough confirmations and not trusted) + assert_eq!(balance.confirmed, Amount::ZERO); + assert_eq!(balance.trusted_pending, Amount::ZERO); + assert_eq!(balance.untrusted_pending, Amount::from_sat(25_000)); +} + +#[test] +fn test_min_confirmations_multiple_transactions() { + // Create a local chain + let chain = LocalChain::from_blocks( + [ + (0, hash!("genesis")), + (1, hash!("b1")), + (2, hash!("b2")), + (3, hash!("b3")), + (4, hash!("b4")), + (5, hash!("b5")), + (6, hash!("b6")), + (7, hash!("b7")), + (8, hash!("b8")), + (9, hash!("b9")), + (10, hash!("b10")), + (11, hash!("b11")), + (12, hash!("b12")), + (13, hash!("b13")), + (14, hash!("b14")), + (15, hash!("tip")), + ] + .into(), + ) + .unwrap(); + + let mut tx_graph = TxGraph::default(); + + // Create multiple transactions at different heights + let mut outpoints = vec![]; + + // Transaction 0: anchored at height 5, has 11 confirmations (tip-5+1 = 15-5+1 = 11) + let tx0 = Transaction { + input: vec![TxIn { + previous_output: OutPoint::new(hash!("parent0"), 0), + ..Default::default() + }], + output: vec![TxOut { + value: Amount::from_sat(10_000), + script_pubkey: ScriptBuf::new(), + }], + ..new_tx(1) + }; + let txid0 = tx0.compute_txid(); + let outpoint0 = OutPoint::new(txid0, 0); + let _ = tx_graph.insert_tx(tx0); + let _ = tx_graph.insert_anchor( + txid0, + ConfirmationBlockTime { + block_id: chain.get(5).unwrap().block_id(), + confirmation_time: 123456, + }, + ); + outpoints.push(((), outpoint0)); + + // Transaction 1: anchored at height 10, has 6 confirmations (15-10+1 = 6) + let tx1 = Transaction { + input: vec![TxIn { + previous_output: OutPoint::new(hash!("parent1"), 0), + ..Default::default() + }], + output: vec![TxOut { + value: Amount::from_sat(20_000), + script_pubkey: ScriptBuf::new(), + }], + ..new_tx(2) + }; + let txid1 = tx1.compute_txid(); + let outpoint1 = OutPoint::new(txid1, 0); + let _ = tx_graph.insert_tx(tx1); + let _ = tx_graph.insert_anchor( + txid1, + ConfirmationBlockTime { + block_id: chain.get(10).unwrap().block_id(), + confirmation_time: 123457, + }, + ); + outpoints.push(((), outpoint1)); + + // Transaction 2: anchored at height 13, has 3 confirmations (15-13+1 = 3) + let tx2 = Transaction { + input: vec![TxIn { + previous_output: OutPoint::new(hash!("parent2"), 0), + ..Default::default() + }], + output: vec![TxOut { + value: Amount::from_sat(30_000), + script_pubkey: ScriptBuf::new(), + }], + ..new_tx(3) + }; + let txid2 = tx2.compute_txid(); + let outpoint2 = OutPoint::new(txid2, 0); + let _ = tx_graph.insert_tx(tx2); + let _ = tx_graph.insert_anchor( + txid2, + ConfirmationBlockTime { + block_id: chain.get(13).unwrap().block_id(), + confirmation_time: 123458, + }, + ); + outpoints.push(((), outpoint2)); + + let canonical_view = tx_graph.canonical_view( + &chain, + chain.tip().block_id(), + CanonicalizationParams::default(), + ); + + // 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); + + assert_eq!( + balance.confirmed, + Amount::from_sat(10_000 + 20_000) // tx0 + tx1 + ); + assert_eq!( + balance.trusted_pending, + Amount::from_sat(30_000) // tx2 + ); + assert_eq!(balance.untrusted_pending, Amount::ZERO); + + // Test with min_confirmations = 10 + // tx0: 11 confirmations -> confirmed + // tx1: 6 confirmations -> trusted pending + // tx2: 3 confirmations -> trusted pending + let balance_high = canonical_view.balance(outpoints, |_, _| true, 10); + + assert_eq!( + balance_high.confirmed, + Amount::from_sat(10_000) // only tx0 + ); + assert_eq!( + balance_high.trusted_pending, + Amount::from_sat(20_000 + 30_000) // tx1 + tx2 + ); + assert_eq!(balance_high.untrusted_pending, Amount::ZERO); +} From beb16a146b774eac3451f0ca0062b4ccee04f6c3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=BF=97=E5=AE=87?= Date: Thu, 11 Sep 2025 08:30:33 +0000 Subject: [PATCH 04/12] feat(chain)!: Remove `CanonicalTx` BREAKING: Remove `CanonicalTx` as it's no longer needed. --- crates/chain/src/tx_graph.rs | 23 +---------------------- 1 file changed, 1 insertion(+), 22 deletions(-) diff --git a/crates/chain/src/tx_graph.rs b/crates/chain/src/tx_graph.rs index 3416f0df9..012810293 100644 --- a/crates/chain/src/tx_graph.rs +++ b/crates/chain/src/tx_graph.rs @@ -124,7 +124,7 @@ use crate::BlockId; use crate::CanonicalIter; use crate::CanonicalView; use crate::CanonicalizationParams; -use crate::{Anchor, ChainOracle, ChainPosition, Merge}; +use crate::{Anchor, ChainOracle, Merge}; use alloc::collections::vec_deque::VecDeque; use alloc::sync::Arc; use alloc::vec::Vec; @@ -245,27 +245,6 @@ impl Default for TxNodeInternal { } } -/// A transaction that is deemed to be part of the canonical history. -#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord)] -pub struct CanonicalTx<'a, T, A> { - /// How the transaction is observed in the canonical chain (confirmed or unconfirmed). - pub chain_position: ChainPosition, - /// The transaction node (as part of the graph). - pub tx_node: TxNode<'a, T, A>, -} - -impl<'a, T, A> From> for Txid { - fn from(tx: CanonicalTx<'a, T, A>) -> Self { - tx.tx_node.txid - } -} - -impl<'a, A> From, A>> for Arc { - fn from(tx: CanonicalTx<'a, Arc, A>) -> Self { - tx.tx_node.tx - } -} - /// Errors returned by `TxGraph::calculate_fee`. #[derive(Debug, PartialEq, Eq)] pub enum CalculateFeeError { From 8ad138d022398fc4dc3f4af9db7d9e103e58be56 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=BF=97=E5=AE=87?= Date: Fri, 12 Sep 2025 14:45:58 +1000 Subject: [PATCH 05/12] docs(chain): Improve CanonicalView documentation Add comprehensive docs for CanonicalView module, structs, and methods. Fix all doctests to compile and pass with proper imports. --- crates/chain/src/canonical_view.rs | 324 +++++++++++++++++++++++++---- 1 file changed, 287 insertions(+), 37 deletions(-) diff --git a/crates/chain/src/canonical_view.rs b/crates/chain/src/canonical_view.rs index 32adc456e..83dfd4215 100644 --- a/crates/chain/src/canonical_view.rs +++ b/crates/chain/src/canonical_view.rs @@ -1,4 +1,25 @@ -//! Canonical view. +//! Canonical view of transactions and unspent outputs. +//! +//! This module provides [`CanonicalView`], a utility for obtaining a canonical (ordered and +//! conflict-resolved) view of transactions from a [`TxGraph`]. +//! +//! ## Example +//! +//! ``` +//! # use bdk_chain::{CanonicalView, TxGraph, CanonicalizationParams, local_chain::LocalChain}; +//! # use bdk_core::BlockId; +//! # use bitcoin::hashes::Hash; +//! # let tx_graph = TxGraph::::default(); +//! # let chain = LocalChain::from_blocks([(0, bitcoin::BlockHash::all_zeros())].into_iter().collect()).unwrap(); +//! # let chain_tip = chain.tip().block_id(); +//! let params = CanonicalizationParams::default(); +//! let view = CanonicalView::new(&tx_graph, &chain, chain_tip, params).unwrap(); +//! +//! // Iterate over canonical transactions +//! for tx in view.txs() { +//! println!("Transaction {}: {:?}", tx.txid, tx.pos); +//! } +//! ``` use crate::collections::HashMap; use alloc::sync::Arc; @@ -14,28 +35,77 @@ use crate::{ CanonicalizationParams, ChainOracle, ChainPosition, FullTxOut, ObservedIn, TxGraph, }; -/// A single canonical transaction. +/// A single canonical transaction with its chain position. +/// +/// This struct represents a transaction that has been determined to be canonical (not +/// conflicted). It includes the transaction itself along with its position in the chain (confirmed +/// or unconfirmed). #[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord)] pub struct CanonicalViewTx { - /// Chain position. + /// The position of this transaction in the chain. + /// + /// This indicates whether the transaction is confirmed (and at what height) or + /// unconfirmed (most likely pending in the mempool). pub pos: ChainPosition, - /// Transaction ID. + /// The transaction ID (hash) of this transaction. pub txid: Txid, - /// The actual transaction. + /// The full transaction. pub tx: Arc, } -/// A view of canonical transactions. +/// A view of canonical transactions from a [`TxGraph`]. +/// +/// `CanonicalView` provides an ordered, conflict-resolved view of transactions. It determines +/// which transactions are canonical (non-conflicted) based on the current chain state and +/// provides methods to query transaction data, unspent outputs, and balances. +/// +/// The view maintains: +/// - An ordered list of canonical transactions (WIP) +/// - A mapping of outpoints to the transactions that spend them +/// - The chain tip used for canonicalization #[derive(Debug)] pub struct CanonicalView { + /// Ordered list of transaction IDs in canonical order. order: Vec, + /// Map of transaction IDs to their transaction data and chain position. txs: HashMap, ChainPosition)>, + /// Map of outpoints to the transaction ID that spends them. spends: HashMap, + /// The chain tip at the time this view was created. tip: BlockId, } impl CanonicalView { - /// Create a canonical view. + /// Create a new canonical view from a transaction graph. + /// + /// This constructor analyzes the given [`TxGraph`] and creates a canonical view of all + /// transactions, resolving conflicts and ordering them according to their chain position. + /// + /// # Arguments + /// + /// * `tx_graph` - The transaction graph containing all known transactions + /// * `chain` - A chain oracle for determining block inclusion + /// * `chain_tip` - The current chain tip to use for canonicalization + /// * `params` - Parameters controlling the canonicalization process + /// + /// # Returns + /// + /// Returns `Ok(CanonicalView)` on success, or an error if the chain oracle fails. + /// + /// # Example + /// + /// ``` + /// # use bdk_chain::{CanonicalView, TxGraph, CanonicalizationParams, local_chain::LocalChain}; + /// # use bdk_core::BlockId; + /// # use bitcoin::hashes::Hash; + /// # let tx_graph = TxGraph::::default(); + /// # let chain = LocalChain::from_blocks([(0, bitcoin::BlockHash::all_zeros())].into_iter().collect()).unwrap(); + /// let chain_tip = chain.tip().block_id(); + /// let params = CanonicalizationParams::default(); + /// + /// let view = CanonicalView::new(&tx_graph, &chain, chain_tip, params)?; + /// # Ok::<_, Box>(()) + /// ``` pub fn new<'g, C>( tx_graph: &'g TxGraph, chain: &'g C, @@ -139,7 +209,25 @@ impl CanonicalView { Ok(view) } - /// Get a single canonical transaction. + /// Get a single canonical transaction by its transaction ID. + /// + /// Returns `Some(CanonicalViewTx)` if the transaction exists in the canonical view, + /// or `None` if the transaction doesn't exist or was excluded due to conflicts. + /// + /// # Example + /// + /// ``` + /// # use bdk_chain::{CanonicalView, TxGraph, local_chain::LocalChain}; + /// # 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 = CanonicalView::new(&tx_graph, &chain, chain.tip().block_id(), Default::default()).unwrap(); + /// # let txid = bitcoin::Txid::all_zeros(); + /// if let Some(canonical_tx) = view.tx(txid) { + /// println!("Found tx {} at position {:?}", canonical_tx.txid, canonical_tx.pos); + /// } + /// ``` pub fn tx(&self, txid: Txid) -> Option> { self.txs .get(&txid) @@ -147,7 +235,34 @@ impl CanonicalView { .map(|(tx, pos)| CanonicalViewTx { pos, txid, tx }) } - /// Get a single canonical txout. + /// Get a single canonical transaction output. + /// + /// Returns detailed information about a transaction output, including whether it has been + /// spent and by which transaction. + /// + /// Returns `None` if: + /// - The transaction doesn't exist in the canonical view + /// - The output index is out of bounds + /// - The transaction was excluded due to conflicts + /// + /// # Example + /// + /// ``` + /// # use bdk_chain::{CanonicalView, TxGraph, local_chain::LocalChain}; + /// # use bdk_core::BlockId; + /// # use bitcoin::{OutPoint, hashes::Hash}; + /// # let tx_graph = TxGraph::::default(); + /// # let chain = LocalChain::from_blocks([(0, bitcoin::BlockHash::all_zeros())].into_iter().collect()).unwrap(); + /// # let view = CanonicalView::new(&tx_graph, &chain, chain.tip().block_id(), Default::default()).unwrap(); + /// # let outpoint = OutPoint::default(); + /// if let Some(txout) = view.txout(outpoint) { + /// if txout.spent_by.is_some() { + /// println!("Output is spent"); + /// } else { + /// println!("Output is unspent with value: {}", txout.txout.value); + /// } + /// } + /// ``` pub fn txout(&self, op: OutPoint) -> Option> { let (tx, pos) = self.txs.get(&op.txid)?; let vout: usize = op.vout.try_into().ok()?; @@ -165,7 +280,28 @@ impl CanonicalView { }) } - /// Ordered transactions. + /// Get an iterator over all canonical transactions in order. + /// + /// Transactions are returned in canonical order, with confirmed transactions ordered by + /// block height and position, followed by unconfirmed transactions. + /// + /// # Example + /// + /// ``` + /// # use bdk_chain::{CanonicalView, TxGraph, local_chain::LocalChain}; + /// # 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 = CanonicalView::new(&tx_graph, &chain, chain.tip().block_id(), Default::default()).unwrap(); + /// // Iterate over all canonical transactions + /// for tx in view.txs() { + /// println!("TX {}: {:?}", tx.txid, tx.pos); + /// } + /// + /// // Get the total number of canonical transactions + /// println!("Total canonical transactions: {}", view.txs().len()); + /// ``` pub fn txs( &self, ) -> impl ExactSizeIterator> + DoubleEndedIterator + '_ { @@ -175,11 +311,34 @@ impl CanonicalView { }) } - /// Get a filtered list of outputs from the given `outpoints`. + /// Get a filtered list of outputs from the given outpoints. + /// + /// This method takes an iterator of `(identifier, outpoint)` pairs and returns an iterator + /// of `(identifier, full_txout)` pairs for outpoints that exist in the canonical view. + /// Non-existent outpoints are silently filtered out. /// - /// `outpoints` is a list of outpoints we are interested in, coupled with an outpoint identifier - /// (`O`) for convenience. If `O` is not necessary, the caller can use `()`, or - /// [`Iterator::enumerate`] over a list of [`OutPoint`]s. + /// The identifier type `O` can be any cloneable type and is passed through unchanged. + /// This is useful for tracking which outpoints correspond to which addresses or keys. + /// + /// # Arguments + /// + /// * `outpoints` - An iterator of `(identifier, outpoint)` pairs to look up + /// + /// # Example + /// + /// ``` + /// # use bdk_chain::{CanonicalView, 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 = CanonicalView::new(&tx_graph, &chain, chain.tip().block_id(), Default::default()).unwrap(); + /// # let indexer = KeychainTxOutIndex::<&str>::default(); + /// // Get all outputs from an indexer + /// for (keychain, txout) in view.filter_outpoints(indexer.outpoints().into_iter().map(|(k, op)| (k.clone(), *op))) { + /// println!("{}: {} sats", keychain.0, txout.txout.value); + /// } + /// ``` pub fn filter_outpoints<'v, O: Clone + 'v>( &'v self, outpoints: impl IntoIterator + 'v, @@ -189,11 +348,30 @@ impl CanonicalView { .filter_map(|(op_i, op)| Some((op_i, self.txout(op)?))) } - /// Get a filtered list of unspent outputs (UTXOs) from the given `outpoints` + /// Get a filtered list of unspent outputs (UTXOs) from the given outpoints. + /// + /// Similar to [`filter_outpoints`](Self::filter_outpoints), but only returns outputs that + /// have not been spent. This is useful for finding available UTXOs for spending. /// - /// `outpoints` is a list of outpoints we are interested in, coupled with an outpoint identifier - /// (`O`) for convenience. If `O` is not necessary, the caller can use `()`, or - /// [`Iterator::enumerate`] over a list of [`OutPoint`]s. + /// # Arguments + /// + /// * `outpoints` - An iterator of `(identifier, outpoint)` pairs to look up + /// + /// # Example + /// + /// ``` + /// # use bdk_chain::{CanonicalView, 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 = CanonicalView::new(&tx_graph, &chain, chain.tip().block_id(), Default::default()).unwrap(); + /// # let indexer = KeychainTxOutIndex::<&str>::default(); + /// // Get unspent outputs (UTXOs) from an indexer + /// for (keychain, utxo) in view.filter_unspent_outpoints(indexer.outpoints().into_iter().map(|(k, op)| (k.clone(), *op))) { + /// println!("{} UTXO: {} sats", keychain.0, utxo.txout.value); + /// } + /// ``` pub fn filter_unspent_outpoints<'v, O: Clone + 'v>( &'v self, outpoints: impl IntoIterator + 'v, @@ -202,27 +380,66 @@ impl CanonicalView { .filter(|(_, txo)| txo.spent_by.is_none()) } - /// Get the total balance of `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. + /// + /// # Arguments /// - /// The output of `trust_predicate` should return `true` for scripts that we trust. + /// * `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` is a list of outpoints we are interested in, coupled with an outpoint identifier - /// (`O`) for convenience. If `O` is not necessary, the caller can use `()`, or - /// [`Iterator::enumerate`] over a list of [`OutPoint`]s. + /// # Minimum Confirmations /// - /// ### Minimum confirmations + /// The `min_confirmations` parameter controls when outputs are considered confirmed: /// - /// `min_confirmations` specifies the minimum number of confirmations required for a transaction - /// to be counted as confirmed in the returned [`Balance`]. Transactions with fewer than - /// `min_confirmations` will be treated as trusted pending (assuming the `trust_predicate` - /// returns `true`). + /// - `0` or `1`: Standard behavior - require at least 1 confirmation + /// - `6`: Conservative - require 6 confirmations (often used for high-value transactions) + /// - `100+`: May be used for coinbase outputs which require 100 confirmations /// - /// - `min_confirmations = 0`: Include all confirmed transactions (same as `1`) - /// - `min_confirmations = 1`: Standard behavior - require at least 1 confirmation - /// - `min_confirmations = 6`: High security - require at least 6 confirmations + /// Outputs with fewer than `min_confirmations` are categorized as pending (trusted or + /// untrusted based on the trust predicate). /// - /// Note: `0` and `1` behave identically since confirmed transactions always have ≥1 - /// confirmation. + /// # Balance Categories + /// + /// The returned [`Balance`] contains four categories: + /// + /// - `confirmed`: Outputs with ≥ `min_confirmations` and spendable + /// - `trusted_pending`: Unconfirmed or insufficiently confirmed outputs from trusted scripts + /// - `untrusted_pending`: Unconfirmed or insufficiently confirmed outputs from untrusted + /// scripts + /// - `immature`: Coinbase outputs that haven't reached maturity (100 confirmations) + /// + /// # Example + /// + /// ``` + /// # use bdk_chain::{CanonicalView, 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 = CanonicalView::new(&tx_graph, &chain, chain.tip().block_id(), Default::default()).unwrap(); + /// # let indexer = KeychainTxOutIndex::<&str>::default(); + /// // Calculate balance with 6 confirmations, trusting all outputs + /// let balance = view.balance( + /// indexer.outpoints().into_iter().map(|(k, op)| (k.clone(), *op)), + /// |_keychain, _script| true, // Trust all outputs + /// 6, // Require 6 confirmations + /// ); + /// + /// // Or calculate balance trusting no outputs + /// let untrusted_balance = view.balance( + /// indexer.outpoints().into_iter().map(|(k, op)| (k.clone(), *op)), + /// |_keychain, _script| false, // Trust no outputs + /// 1, + /// ); + /// ``` pub fn balance<'v, O: Clone + 'v>( &'v self, outpoints: impl IntoIterator + 'v, @@ -276,13 +493,46 @@ impl CanonicalView { } } - /// List txids that are expected to exist under the given spks. + /// List transaction IDs that are expected to exist for the given script pubkeys. + /// + /// This method is primarily used for synchronization with external sources, helping to + /// identify which transactions are expected to exist for a set of script pubkeys. It's + /// commonly used with + /// [`SyncRequestBuilder::expected_spk_txids`](bdk_core::spk_client::SyncRequestBuilder::expected_spk_txids) + /// to inform sync operations about known transactions. + /// + /// # Arguments + /// + /// * `indexer` - A script pubkey indexer (e.g., `KeychainTxOutIndex`) that tracks which scripts + /// are relevant + /// * `spk_index_range` - A range bound to constrain which script indices to include. Use `..` + /// for all indices. + /// + /// # Returns + /// + /// An iterator of `(script_pubkey, txid)` pairs for all canonical transactions that involve + /// the specified scripts. /// - /// This is used to fill - /// [`SyncRequestBuilder::expected_spk_txids`](bdk_core::spk_client::SyncRequestBuilder::expected_spk_txids). + /// # Example /// + /// ``` + /// # use bdk_chain::{CanonicalView, TxGraph, local_chain::LocalChain, spk_txout::SpkTxOutIndex}; + /// # 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 = CanonicalView::new(&tx_graph, &chain, chain.tip().block_id(), Default::default()).unwrap(); + /// # let indexer = SpkTxOutIndex::::default(); + /// // List all expected transactions for script indices 0-100 + /// for (script, txid) in view.list_expected_spk_txids(&indexer, 0..100) { + /// println!("Script {:?} appears in transaction {}", script, txid); + /// } /// - /// The spk index range can be constrained with `range`. + /// // List all expected transactions (no range constraint) + /// for (script, txid) in view.list_expected_spk_txids(&indexer, ..) { + /// println!("Found transaction {} for script", txid); + /// } + /// ``` pub fn list_expected_spk_txids<'v, I>( &'v self, indexer: &'v impl AsRef>, From f444a8de391abb673b11ebd03e1a9b1cce570602 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=BF=97=E5=AE=87?= Date: Fri, 12 Sep 2025 06:11:28 +0000 Subject: [PATCH 06/12] refactor(chain)!: Rename `CanonicalViewTx` to `CanonicalTx` Also update submodule-level docs for `tx_graph`'s Canonicalization section. --- crates/chain/src/canonical_view.rs | 12 +++++------- crates/chain/src/tx_graph.rs | 17 ++++++++--------- 2 files changed, 13 insertions(+), 16 deletions(-) diff --git a/crates/chain/src/canonical_view.rs b/crates/chain/src/canonical_view.rs index 83dfd4215..c6b985e6a 100644 --- a/crates/chain/src/canonical_view.rs +++ b/crates/chain/src/canonical_view.rs @@ -41,7 +41,7 @@ use crate::{ /// conflicted). It includes the transaction itself along with its position in the chain (confirmed /// or unconfirmed). #[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord)] -pub struct CanonicalViewTx { +pub struct CanonicalTx { /// The position of this transaction in the chain. /// /// This indicates whether the transaction is confirmed (and at what height) or @@ -228,11 +228,11 @@ impl CanonicalView { /// println!("Found tx {} at position {:?}", canonical_tx.txid, canonical_tx.pos); /// } /// ``` - pub fn tx(&self, txid: Txid) -> Option> { + pub fn tx(&self, txid: Txid) -> Option> { self.txs .get(&txid) .cloned() - .map(|(tx, pos)| CanonicalViewTx { pos, txid, tx }) + .map(|(tx, pos)| CanonicalTx { pos, txid, tx }) } /// Get a single canonical transaction output. @@ -302,12 +302,10 @@ impl CanonicalView { /// // Get the total number of canonical transactions /// println!("Total canonical transactions: {}", view.txs().len()); /// ``` - pub fn txs( - &self, - ) -> impl ExactSizeIterator> + DoubleEndedIterator + '_ { + pub fn txs(&self) -> impl ExactSizeIterator> + DoubleEndedIterator + '_ { self.order.iter().map(|&txid| { let (tx, pos) = self.txs[&txid].clone(); - CanonicalViewTx { pos, txid, tx } + CanonicalTx { pos, txid, tx } }) } diff --git a/crates/chain/src/tx_graph.rs b/crates/chain/src/tx_graph.rs index 012810293..97d4ecc02 100644 --- a/crates/chain/src/tx_graph.rs +++ b/crates/chain/src/tx_graph.rs @@ -21,15 +21,14 @@ //! Conflicting transactions are allowed to coexist within a [`TxGraph`]. A process called //! canonicalization is required to get a conflict-free view of transactions. //! -//! * [`list_canonical_txs`](TxGraph::list_canonical_txs) lists canonical transactions. -//! * [`filter_chain_txouts`](TxGraph::filter_chain_txouts) filters out canonical outputs from a -//! list of outpoints. -//! * [`filter_chain_unspents`](TxGraph::filter_chain_unspents) filters out canonical unspent -//! outputs from a list of outpoints. -//! * [`balance`](TxGraph::balance) gets the total sum of unspent outputs filtered from a list of -//! outpoints. -//! * [`canonical_iter`](TxGraph::canonical_iter) returns the [`CanonicalIter`] which contains all -//! of the canonicalization logic. +//! * [`canonical_iter`](TxGraph::canonical_iter) returns a [`CanonicalIter`] which performs +//! incremental canonicalization. This is useful when you only need to check specific transactions +//! (e.g., verifying whether a few unconfirmed transactions are canonical) without computing the +//! entire canonical view. +//! * [`canonical_view`](TxGraph::canonical_view) returns a [`CanonicalView`] which provides a +//! complete canonical view of the graph. This is required for typical wallet operations like +//! querying balances, listing outputs, transactions, and UTXOs. You must construct this first +//! before performing these operations. //! //! All these methods require a `chain` and `chain_tip` argument. The `chain` must be a //! [`ChainOracle`] implementation (such as [`LocalChain`](crate::local_chain::LocalChain)) which From 40790d00600c8601f5e15b51ec39fccbc8a539bd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=BF=97=E5=AE=87?= Date: Fri, 12 Sep 2025 06:11:28 +0000 Subject: [PATCH 07/12] docs(chain): Tighten `CanonicalView` documentation --- crates/chain/src/canonical_view.rs | 129 ++--------------------------- 1 file changed, 6 insertions(+), 123 deletions(-) diff --git a/crates/chain/src/canonical_view.rs b/crates/chain/src/canonical_view.rs index c6b985e6a..b24dbb2d8 100644 --- a/crates/chain/src/canonical_view.rs +++ b/crates/chain/src/canonical_view.rs @@ -81,31 +81,9 @@ impl CanonicalView { /// This constructor analyzes the given [`TxGraph`] and creates a canonical view of all /// transactions, resolving conflicts and ordering them according to their chain position. /// - /// # Arguments - /// - /// * `tx_graph` - The transaction graph containing all known transactions - /// * `chain` - A chain oracle for determining block inclusion - /// * `chain_tip` - The current chain tip to use for canonicalization - /// * `params` - Parameters controlling the canonicalization process - /// /// # Returns /// /// Returns `Ok(CanonicalView)` on success, or an error if the chain oracle fails. - /// - /// # Example - /// - /// ``` - /// # use bdk_chain::{CanonicalView, TxGraph, CanonicalizationParams, local_chain::LocalChain}; - /// # use bdk_core::BlockId; - /// # use bitcoin::hashes::Hash; - /// # let tx_graph = TxGraph::::default(); - /// # let chain = LocalChain::from_blocks([(0, bitcoin::BlockHash::all_zeros())].into_iter().collect()).unwrap(); - /// let chain_tip = chain.tip().block_id(); - /// let params = CanonicalizationParams::default(); - /// - /// let view = CanonicalView::new(&tx_graph, &chain, chain_tip, params)?; - /// # Ok::<_, Box>(()) - /// ``` pub fn new<'g, C>( tx_graph: &'g TxGraph, chain: &'g C, @@ -213,21 +191,6 @@ impl CanonicalView { /// /// Returns `Some(CanonicalViewTx)` if the transaction exists in the canonical view, /// or `None` if the transaction doesn't exist or was excluded due to conflicts. - /// - /// # Example - /// - /// ``` - /// # use bdk_chain::{CanonicalView, TxGraph, local_chain::LocalChain}; - /// # 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 = CanonicalView::new(&tx_graph, &chain, chain.tip().block_id(), Default::default()).unwrap(); - /// # let txid = bitcoin::Txid::all_zeros(); - /// if let Some(canonical_tx) = view.tx(txid) { - /// println!("Found tx {} at position {:?}", canonical_tx.txid, canonical_tx.pos); - /// } - /// ``` pub fn tx(&self, txid: Txid) -> Option> { self.txs .get(&txid) @@ -244,25 +207,6 @@ impl CanonicalView { /// - The transaction doesn't exist in the canonical view /// - The output index is out of bounds /// - The transaction was excluded due to conflicts - /// - /// # Example - /// - /// ``` - /// # use bdk_chain::{CanonicalView, TxGraph, local_chain::LocalChain}; - /// # use bdk_core::BlockId; - /// # use bitcoin::{OutPoint, hashes::Hash}; - /// # let tx_graph = TxGraph::::default(); - /// # let chain = LocalChain::from_blocks([(0, bitcoin::BlockHash::all_zeros())].into_iter().collect()).unwrap(); - /// # let view = CanonicalView::new(&tx_graph, &chain, chain.tip().block_id(), Default::default()).unwrap(); - /// # let outpoint = OutPoint::default(); - /// if let Some(txout) = view.txout(outpoint) { - /// if txout.spent_by.is_some() { - /// println!("Output is spent"); - /// } else { - /// println!("Output is unspent with value: {}", txout.txout.value); - /// } - /// } - /// ``` pub fn txout(&self, op: OutPoint) -> Option> { let (tx, pos) = self.txs.get(&op.txid)?; let vout: usize = op.vout.try_into().ok()?; @@ -315,12 +259,8 @@ impl CanonicalView { /// of `(identifier, full_txout)` pairs for outpoints that exist in the canonical view. /// Non-existent outpoints are silently filtered out. /// - /// The identifier type `O` can be any cloneable type and is passed through unchanged. - /// This is useful for tracking which outpoints correspond to which addresses or keys. - /// - /// # Arguments - /// - /// * `outpoints` - An iterator of `(identifier, outpoint)` pairs to look up + /// The identifier type `O` is useful for tracking which outpoints correspond to which addresses + /// or keys. /// /// # Example /// @@ -333,7 +273,7 @@ impl CanonicalView { /// # let view = CanonicalView::new(&tx_graph, &chain, chain.tip().block_id(), Default::default()).unwrap(); /// # let indexer = KeychainTxOutIndex::<&str>::default(); /// // Get all outputs from an indexer - /// for (keychain, txout) in view.filter_outpoints(indexer.outpoints().into_iter().map(|(k, op)| (k.clone(), *op))) { + /// for (keychain, txout) in view.filter_outpoints(indexer.outpoints().clone()) { /// println!("{}: {} sats", keychain.0, txout.txout.value); /// } /// ``` @@ -351,10 +291,6 @@ impl CanonicalView { /// Similar to [`filter_outpoints`](Self::filter_outpoints), but only returns outputs that /// have not been spent. This is useful for finding available UTXOs for spending. /// - /// # Arguments - /// - /// * `outpoints` - An iterator of `(identifier, outpoint)` pairs to look up - /// /// # Example /// /// ``` @@ -366,7 +302,7 @@ impl CanonicalView { /// # let view = CanonicalView::new(&tx_graph, &chain, chain.tip().block_id(), Default::default()).unwrap(); /// # let indexer = KeychainTxOutIndex::<&str>::default(); /// // Get unspent outputs (UTXOs) from an indexer - /// for (keychain, utxo) in view.filter_unspent_outpoints(indexer.outpoints().into_iter().map(|(k, op)| (k.clone(), *op))) { + /// for (keychain, utxo) in view.filter_unspent_outpoints(indexer.outpoints().clone()) { /// println!("{} UTXO: {} sats", keychain.0, utxo.txout.value); /// } /// ``` @@ -395,25 +331,12 @@ impl CanonicalView { /// /// # Minimum Confirmations /// - /// The `min_confirmations` parameter controls when outputs are considered confirmed: - /// - /// - `0` or `1`: Standard behavior - require at least 1 confirmation - /// - `6`: Conservative - require 6 confirmations (often used for high-value transactions) - /// - `100+`: May be used for coinbase outputs which require 100 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). /// - /// # Balance Categories - /// - /// The returned [`Balance`] contains four categories: - /// - /// - `confirmed`: Outputs with ≥ `min_confirmations` and spendable - /// - `trusted_pending`: Unconfirmed or insufficiently confirmed outputs from trusted scripts - /// - `untrusted_pending`: Unconfirmed or insufficiently confirmed outputs from untrusted - /// scripts - /// - `immature`: Coinbase outputs that haven't reached maturity (100 confirmations) - /// /// # Example /// /// ``` @@ -430,13 +353,6 @@ impl CanonicalView { /// |_keychain, _script| true, // Trust all outputs /// 6, // Require 6 confirmations /// ); - /// - /// // Or calculate balance trusting no outputs - /// let untrusted_balance = view.balance( - /// indexer.outpoints().into_iter().map(|(k, op)| (k.clone(), *op)), - /// |_keychain, _script| false, // Trust no outputs - /// 1, - /// ); /// ``` pub fn balance<'v, O: Clone + 'v>( &'v self, @@ -498,39 +414,6 @@ impl CanonicalView { /// commonly used with /// [`SyncRequestBuilder::expected_spk_txids`](bdk_core::spk_client::SyncRequestBuilder::expected_spk_txids) /// to inform sync operations about known transactions. - /// - /// # Arguments - /// - /// * `indexer` - A script pubkey indexer (e.g., `KeychainTxOutIndex`) that tracks which scripts - /// are relevant - /// * `spk_index_range` - A range bound to constrain which script indices to include. Use `..` - /// for all indices. - /// - /// # Returns - /// - /// An iterator of `(script_pubkey, txid)` pairs for all canonical transactions that involve - /// the specified scripts. - /// - /// # Example - /// - /// ``` - /// # use bdk_chain::{CanonicalView, TxGraph, local_chain::LocalChain, spk_txout::SpkTxOutIndex}; - /// # 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 = CanonicalView::new(&tx_graph, &chain, chain.tip().block_id(), Default::default()).unwrap(); - /// # let indexer = SpkTxOutIndex::::default(); - /// // List all expected transactions for script indices 0-100 - /// for (script, txid) in view.list_expected_spk_txids(&indexer, 0..100) { - /// println!("Script {:?} appears in transaction {}", script, txid); - /// } - /// - /// // List all expected transactions (no range constraint) - /// for (script, txid) in view.list_expected_spk_txids(&indexer, ..) { - /// println!("Found transaction {} for script", txid); - /// } - /// ``` pub fn list_expected_spk_txids<'v, I>( &'v self, indexer: &'v impl AsRef>, From 3f9eec54e7c132764d6582cd51c2681bc3e14eee Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=BF=97=E5=AE=87?= Date: Fri, 12 Sep 2025 06:11:28 +0000 Subject: [PATCH 08/12] refactor(example): Reuse `CanonicalView` in filter iter example --- crates/bitcoind_rpc/examples/filter_iter.rs | 15 +++++---------- 1 file changed, 5 insertions(+), 10 deletions(-) diff --git a/crates/bitcoind_rpc/examples/filter_iter.rs b/crates/bitcoind_rpc/examples/filter_iter.rs index 5a3dc2974..e79bde672 100644 --- a/crates/bitcoind_rpc/examples/filter_iter.rs +++ b/crates/bitcoind_rpc/examples/filter_iter.rs @@ -68,8 +68,10 @@ fn main() -> anyhow::Result<()> { println!("\ntook: {}s", start.elapsed().as_secs()); println!("Local tip: {}", chain.tip().height()); - let unspent: Vec<_> = graph - .canonical_view(&chain, chain.tip().block_id(), Default::default()) + + let canonical_view = graph.canonical_view(&chain, chain.tip().block_id(), Default::default()); + + let unspent: Vec<_> = canonical_view .filter_unspent_outpoints(graph.index.outpoints().clone()) .collect(); if !unspent.is_empty() { @@ -80,14 +82,7 @@ fn main() -> anyhow::Result<()> { } } - for canon_tx in graph - .canonical_view( - &chain, - chain.tip().block_id(), - bdk_chain::CanonicalizationParams::default(), - ) - .txs() - { + for canon_tx in canonical_view.txs() { if !canon_tx.pos.is_confirmed() { eprintln!("ERROR: canonical tx should be confirmed {}", canon_tx.txid); } From 7245631bfb0f11679ad953e0233be60f9cb84a44 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=BF=97=E5=AE=87?= Date: Wed, 17 Sep 2025 23:46:29 +0000 Subject: [PATCH 09/12] refactor(chain)!: Rename min_confirmations to additional_confirmations MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit BREAKING CHANGE: `CanonicalView::balance()` now takes `additional_confirmations` instead of `min_confirmations`. The new parameter represents confirmations beyond the first (e.g., 5 means 6 total confirmations required). - Rename `why` to `reason` in CanonicalView for clarity - Update docs to clarify topological-spending order - Simplify docs by removing redundant conflict mentions 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --- crates/bitcoind_rpc/tests/test_emitter.rs | 2 +- crates/chain/src/canonical_view.rs | 40 +++-- crates/chain/tests/test_canonical_view.rs | 145 +++++++++--------- crates/chain/tests/test_indexed_tx_graph.rs | 2 +- crates/electrum/tests/test_electrum.rs | 2 +- .../example_bitcoind_rpc_polling/src/main.rs | 4 +- examples/example_cli/src/lib.rs | 2 +- 7 files changed, 96 insertions(+), 101 deletions(-) diff --git a/crates/bitcoind_rpc/tests/test_emitter.rs b/crates/bitcoind_rpc/tests/test_emitter.rs index 6453037e6..0923bfbd7 100644 --- a/crates/bitcoind_rpc/tests/test_emitter.rs +++ b/crates/bitcoind_rpc/tests/test_emitter.rs @@ -312,7 +312,7 @@ fn get_balance( let outpoints = recv_graph.index.outpoints().clone(); let balance = recv_graph .canonical_view(recv_chain, chain_tip, CanonicalizationParams::default()) - .balance(outpoints, |_, _| true, 1); + .balance(outpoints, |_, _| true, 0); Ok(balance) } diff --git a/crates/chain/src/canonical_view.rs b/crates/chain/src/canonical_view.rs index b24dbb2d8..91424bec7 100644 --- a/crates/chain/src/canonical_view.rs +++ b/crates/chain/src/canonical_view.rs @@ -60,12 +60,12 @@ pub struct CanonicalTx { /// provides methods to query transaction data, unspent outputs, and balances. /// /// The view maintains: -/// - An ordered list of canonical transactions (WIP) +/// - An ordered list of canonical transactions in topological-spending order /// - A mapping of outpoints to the transactions that spend them /// - The chain tip used for canonicalization #[derive(Debug)] pub struct CanonicalView { - /// Ordered list of transaction IDs in canonical order. + /// Ordered list of transaction IDs in topological-spending order. order: Vec, /// Map of transaction IDs to their transaction data and chain position. txs: HashMap, ChainPosition)>, @@ -119,7 +119,7 @@ impl CanonicalView { }; for r in CanonicalIter::new(tx_graph, chain, chain_tip, params) { - let (txid, tx, why) = r?; + let (txid, tx, reason) = r?; let tx_node = match tx_graph.get_tx_node(txid) { Some(tx_node) => tx_node, @@ -137,7 +137,7 @@ impl CanonicalView { .extend(tx.input.iter().map(|txin| (txin.previous_output, txid))); } - let pos = match why { + let pos = match reason { CanonicalReason::Assumed { descendant } => match descendant { Some(_) => match find_direct_anchor(&tx_node, chain, chain_tip)? { Some(anchor) => ChainPosition::Confirmed { @@ -189,8 +189,8 @@ impl CanonicalView { /// Get a single canonical transaction by its transaction ID. /// - /// Returns `Some(CanonicalViewTx)` if the transaction exists in the canonical view, - /// or `None` if the transaction doesn't exist or was excluded due to conflicts. + /// Returns `Some(CanonicalTx)` if the transaction exists in the canonical view, + /// or `None` if it doesn't exist. pub fn tx(&self, txid: Txid) -> Option> { self.txs .get(&txid) @@ -206,7 +206,6 @@ impl CanonicalView { /// Returns `None` if: /// - The transaction doesn't exist in the canonical view /// - The output index is out of bounds - /// - The transaction was excluded due to conflicts pub fn txout(&self, op: OutPoint) -> Option> { let (tx, pos) = self.txs.get(&op.txid)?; let vout: usize = op.vout.try_into().ok()?; @@ -226,8 +225,9 @@ impl CanonicalView { /// Get an iterator over all canonical transactions in order. /// - /// Transactions are returned in canonical order, with confirmed transactions ordered by - /// block height and position, followed by unconfirmed transactions. + /// Transactions are returned in topological-spending order, where ancestors appear before + /// their descendants (i.e., transactions that spend outputs come after the transactions + /// that create those outputs). /// /// # Example /// @@ -326,16 +326,14 @@ impl CanonicalView { /// * `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. + /// * `additional_confirmations` - Additional confirmations required beyond the first one. + /// Outputs with fewer than (1 + additional_confirmations) are treated as pending. /// - /// # Minimum Confirmations + /// # Additional 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). + /// The `additional_confirmations` parameter specifies how many confirmations beyond the + /// first one are required. For example, `additional_confirmations = 5` means 6 total + /// confirmations are required (1 + 5). /// /// # Example /// @@ -351,14 +349,14 @@ impl CanonicalView { /// let balance = view.balance( /// indexer.outpoints().into_iter().map(|(k, op)| (k.clone(), *op)), /// |_keychain, _script| true, // Trust all outputs - /// 6, // Require 6 confirmations + /// 5, // Require 6 confirmations (1 + 5) /// ); /// ``` pub fn balance<'v, O: Clone + 'v>( &'v self, outpoints: impl IntoIterator + 'v, mut trust_predicate: impl FnMut(&O, ScriptBuf) -> bool, - min_confirmations: u32, + additional_confirmations: u32, ) -> Balance { let mut immature = Amount::ZERO; let mut trusted_pending = Amount::ZERO; @@ -374,9 +372,9 @@ impl CanonicalView { .height .saturating_sub(confirmation_height) .saturating_add(1); - let min_confirmations = min_confirmations.max(1); // 0 and 1 behave identically + let required_confirmations = 1 + additional_confirmations; - if confirmations < min_confirmations { + if confirmations < required_confirmations { // Not enough confirmations, treat as trusted/untrusted pending if trust_predicate(&spk_i, txout.txout.script_pubkey) { trusted_pending += txout.txout.value; diff --git a/crates/chain/tests/test_canonical_view.rs b/crates/chain/tests/test_canonical_view.rs index 5d6740ac5..86adb35ce 100644 --- a/crates/chain/tests/test_canonical_view.rs +++ b/crates/chain/tests/test_canonical_view.rs @@ -2,28 +2,28 @@ use bdk_chain::{local_chain::LocalChain, CanonicalizationParams, ConfirmationBlockTime, TxGraph}; use bdk_testenv::{hash, utils::new_tx}; -use bitcoin::{Amount, OutPoint, ScriptBuf, Transaction, TxIn, TxOut}; +use bitcoin::{Amount, BlockHash, OutPoint, ScriptBuf, Transaction, TxIn, TxOut}; +use std::collections::BTreeMap; #[test] -fn test_min_confirmations_parameter() { +fn test_additional_confirmations_parameter() { // Create a local chain with several blocks - let chain = LocalChain::from_blocks( - [ - (0, hash!("block0")), - (1, hash!("block1")), - (2, hash!("block2")), - (3, hash!("block3")), - (4, hash!("block4")), - (5, hash!("block5")), - (6, hash!("block6")), - (7, hash!("block7")), - (8, hash!("block8")), - (9, hash!("block9")), - (10, hash!("block10")), - ] - .into(), - ) - .unwrap(); + let blocks: BTreeMap = [ + (0, hash!("block0")), + (1, hash!("block1")), + (2, hash!("block2")), + (3, hash!("block3")), + (4, hash!("block4")), + (5, hash!("block5")), + (6, hash!("block6")), + (7, hash!("block7")), + (8, hash!("block8")), + (9, hash!("block9")), + (10, hash!("block10")), + ] + .into_iter() + .collect(); + let chain = LocalChain::from_blocks(blocks).unwrap(); let mut tx_graph = TxGraph::default(); @@ -56,35 +56,35 @@ fn test_min_confirmations_parameter() { let canonical_view = tx_graph.canonical_view(&chain, chain_tip, CanonicalizationParams::default()); - // Test min_confirmations = 1: Should be confirmed (has 6 confirmations) + // Test additional_confirmations = 0: Should be confirmed (has 6 confirmations, needs 1) let balance_1_conf = canonical_view.balance( [((), outpoint)], |_, _| true, // trust all - 1, + 0, ); assert_eq!(balance_1_conf.confirmed, 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) + // Test additional_confirmations = 5: Should be confirmed (has 6 confirmations, needs 6) let balance_6_conf = canonical_view.balance( [((), outpoint)], |_, _| true, // trust all - 6, + 5, ); 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) + // Test additional_confirmations = 6: Should be trusted pending (has 6 confirmations, needs 7) let balance_7_conf = canonical_view.balance( [((), outpoint)], |_, _| true, // trust all - 7, + 6, ); 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) + // Test additional_confirmations = 0: Should be confirmed let balance_0_conf = canonical_view.balance( [((), outpoint)], |_, _| true, // trust all @@ -92,29 +92,27 @@ fn test_min_confirmations_parameter() { ); assert_eq!(balance_0_conf.confirmed, Amount::from_sat(50_000)); assert_eq!(balance_0_conf.trusted_pending, Amount::ZERO); - assert_eq!(balance_0_conf, balance_1_conf); } #[test] -fn test_min_confirmations_with_untrusted_tx() { +fn test_additional_confirmations_with_untrusted_tx() { // Create a local chain - let chain = LocalChain::from_blocks( - [ - (0, hash!("genesis")), - (1, hash!("b1")), - (2, hash!("b2")), - (3, hash!("b3")), - (4, hash!("b4")), - (5, hash!("b5")), - (6, hash!("b6")), - (7, hash!("b7")), - (8, hash!("b8")), - (9, hash!("b9")), - (10, hash!("tip")), - ] - .into(), - ) - .unwrap(); + let blocks: BTreeMap = [ + (0, hash!("genesis")), + (1, hash!("b1")), + (2, hash!("b2")), + (3, hash!("b3")), + (4, hash!("b4")), + (5, hash!("b5")), + (6, hash!("b6")), + (7, hash!("b7")), + (8, hash!("b8")), + (9, hash!("b9")), + (10, hash!("tip")), + ] + .into_iter() + .collect(); + let chain = LocalChain::from_blocks(blocks).unwrap(); let mut tx_graph = TxGraph::default(); @@ -148,11 +146,11 @@ fn test_min_confirmations_with_untrusted_tx() { CanonicalizationParams::default(), ); - // Test with min_confirmations = 5 and untrusted predicate + // Test with additional_confirmations = 4 and untrusted predicate (requires 5 total) let balance = canonical_view.balance( [((), outpoint)], |_, _| false, // don't trust - 5, + 4, ); // Should be untrusted pending (not enough confirmations and not trusted) @@ -162,30 +160,29 @@ fn test_min_confirmations_with_untrusted_tx() { } #[test] -fn test_min_confirmations_multiple_transactions() { +fn test_additional_confirmations_multiple_transactions() { // Create a local chain - let chain = LocalChain::from_blocks( - [ - (0, hash!("genesis")), - (1, hash!("b1")), - (2, hash!("b2")), - (3, hash!("b3")), - (4, hash!("b4")), - (5, hash!("b5")), - (6, hash!("b6")), - (7, hash!("b7")), - (8, hash!("b8")), - (9, hash!("b9")), - (10, hash!("b10")), - (11, hash!("b11")), - (12, hash!("b12")), - (13, hash!("b13")), - (14, hash!("b14")), - (15, hash!("tip")), - ] - .into(), - ) - .unwrap(); + let blocks: BTreeMap = [ + (0, hash!("genesis")), + (1, hash!("b1")), + (2, hash!("b2")), + (3, hash!("b3")), + (4, hash!("b4")), + (5, hash!("b5")), + (6, hash!("b6")), + (7, hash!("b7")), + (8, hash!("b8")), + (9, hash!("b9")), + (10, hash!("b10")), + (11, hash!("b11")), + (12, hash!("b12")), + (13, hash!("b13")), + (14, hash!("b14")), + (15, hash!("tip")), + ] + .into_iter() + .collect(); + let chain = LocalChain::from_blocks(blocks).unwrap(); let mut tx_graph = TxGraph::default(); @@ -270,11 +267,11 @@ fn test_min_confirmations_multiple_transactions() { CanonicalizationParams::default(), ); - // Test with min_confirmations = 5 + // Test with additional_confirmations = 4 (requires 5 total) // 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(), |_, _| true, 4); assert_eq!( balance.confirmed, @@ -286,11 +283,11 @@ fn test_min_confirmations_multiple_transactions() { ); assert_eq!(balance.untrusted_pending, Amount::ZERO); - // Test with min_confirmations = 10 + // Test with additional_confirmations = 9 (requires 10 total) // 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, |_, _| true, 9); 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 5b44cb163..fafc565b3 100644 --- a/crates/chain/tests/test_indexed_tx_graph.rs +++ b/crates/chain/tests/test_indexed_tx_graph.rs @@ -474,7 +474,7 @@ fn test_list_owned_txouts() { .balance( graph.index.outpoints().iter().cloned(), |_, spk: ScriptBuf| trusted_spks.contains(&spk), - 1, + 0, ); let confirmed_txouts_txid = txouts diff --git a/crates/electrum/tests/test_electrum.rs b/crates/electrum/tests/test_electrum.rs index 0663061e4..608dae39f 100644 --- a/crates/electrum/tests/test_electrum.rs +++ b/crates/electrum/tests/test_electrum.rs @@ -42,7 +42,7 @@ fn get_balance( let outpoints = recv_graph.index.outpoints().clone(); let balance = recv_graph .canonical_view(recv_chain, chain_tip, CanonicalizationParams::default()) - .balance(outpoints, |_, _| true, 1); + .balance(outpoints, |_, _| true, 0); Ok(balance) } diff --git a/examples/example_bitcoind_rpc_polling/src/main.rs b/examples/example_bitcoind_rpc_polling/src/main.rs index 0263c5b0b..d67959392 100644 --- a/examples/example_bitcoind_rpc_polling/src/main.rs +++ b/examples/example_bitcoind_rpc_polling/src/main.rs @@ -205,7 +205,7 @@ fn main() -> anyhow::Result<()> { .balance( graph.index.outpoints().iter().cloned(), |(k, _), _| k == &Keychain::Internal, - 1, + 0, ) }; println!( @@ -365,7 +365,7 @@ fn main() -> anyhow::Result<()> { .balance( graph.index.outpoints().iter().cloned(), |(k, _), _| k == &Keychain::Internal, - 1, + 0, ) }; println!( diff --git a/examples/example_cli/src/lib.rs b/examples/example_cli/src/lib.rs index 5ef4130f7..9904bc47e 100644 --- a/examples/example_cli/src/lib.rs +++ b/examples/example_cli/src/lib.rs @@ -533,7 +533,7 @@ pub fn handle_commands( .balance( graph.index.outpoints().iter().cloned(), |(k, _), _| k == &Keychain::Internal, - 1, + 0, ); let confirmed_total = balance.confirmed + balance.immature; From cb72f4ae9d807b2c5376cc84297501961d36ae53 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=BF=97=E5=AE=87?= Date: Wed, 17 Sep 2025 23:46:29 +0000 Subject: [PATCH 10/12] refactor(chain)!: Change trust_predicate to accept FullTxOut MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit BREAKING CHANGE: The trust_predicate parameter in CanonicalView::balance() now takes &FullTxOut instead of ScriptBuf as its second argument. This provides more context to the predicate function. 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --- crates/chain/src/canonical_view.rs | 6 +++--- crates/chain/tests/test_indexed_tx_graph.rs | 2 +- crates/chain/tests/test_tx_graph_conflicts.rs | 10 +++++++--- 3 files changed, 11 insertions(+), 7 deletions(-) diff --git a/crates/chain/src/canonical_view.rs b/crates/chain/src/canonical_view.rs index 91424bec7..a8c5d3bb7 100644 --- a/crates/chain/src/canonical_view.rs +++ b/crates/chain/src/canonical_view.rs @@ -355,7 +355,7 @@ impl CanonicalView { pub fn balance<'v, O: Clone + 'v>( &'v self, outpoints: impl IntoIterator + 'v, - mut trust_predicate: impl FnMut(&O, ScriptBuf) -> bool, + mut trust_predicate: impl FnMut(&O, &FullTxOut) -> bool, additional_confirmations: u32, ) -> Balance { let mut immature = Amount::ZERO; @@ -376,7 +376,7 @@ impl CanonicalView { if confirmations < required_confirmations { // Not enough confirmations, treat as trusted/untrusted pending - if trust_predicate(&spk_i, txout.txout.script_pubkey) { + if trust_predicate(&spk_i, &txout) { trusted_pending += txout.txout.value; } else { untrusted_pending += txout.txout.value; @@ -388,7 +388,7 @@ impl CanonicalView { } } ChainPosition::Unconfirmed { .. } => { - if trust_predicate(&spk_i, txout.txout.script_pubkey) { + if trust_predicate(&spk_i, &txout) { trusted_pending += txout.txout.value; } else { untrusted_pending += txout.txout.value; diff --git a/crates/chain/tests/test_indexed_tx_graph.rs b/crates/chain/tests/test_indexed_tx_graph.rs index fafc565b3..4595769c9 100644 --- a/crates/chain/tests/test_indexed_tx_graph.rs +++ b/crates/chain/tests/test_indexed_tx_graph.rs @@ -473,7 +473,7 @@ fn test_list_owned_txouts() { .canonical_view(&local_chain, chain_tip, CanonicalizationParams::default()) .balance( graph.index.outpoints().iter().cloned(), - |_, spk: ScriptBuf| trusted_spks.contains(&spk), + |_, txout| trusted_spks.contains(&txout.txout.script_pubkey), 0, ); diff --git a/crates/chain/tests/test_tx_graph_conflicts.rs b/crates/chain/tests/test_tx_graph_conflicts.rs index f91a3a8d3..70dc01884 100644 --- a/crates/chain/tests/test_tx_graph_conflicts.rs +++ b/crates/chain/tests/test_tx_graph_conflicts.rs @@ -5,7 +5,7 @@ mod common; use bdk_chain::{local_chain::LocalChain, Balance, BlockId}; use bdk_testenv::{block_id, hash, local_chain}; -use bitcoin::{Amount, BlockHash, OutPoint, ScriptBuf}; +use bitcoin::{Amount, BlockHash, OutPoint}; use common::*; use std::collections::{BTreeSet, HashSet}; @@ -1032,8 +1032,12 @@ fn test_tx_conflict_handling() { .canonical_view(&local_chain, chain_tip, env.canonicalization_params.clone()) .balance( env.indexer.outpoints().iter().cloned(), - |_, spk: ScriptBuf| env.indexer.index_of_spk(spk).is_some(), - 1, + |_, txout| { + env.indexer + .index_of_spk(txout.txout.script_pubkey.clone()) + .is_some() + }, + 0, ); assert_eq!( balance, scenario.exp_balance, From 22ab0106d14f874e95e08f90f565851240b865a0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=BF=97=E5=AE=87?= Date: Fri, 19 Sep 2025 05:32:01 +0000 Subject: [PATCH 11/12] feat(chain): Add `extract_subgraph()` & `roots()` to `CanonicalView` These methods are intended to help implement RBF logic. --- crates/chain/src/canonical_view.rs | 89 +++++++++++++++++++++++++++++- 1 file changed, 88 insertions(+), 1 deletion(-) diff --git a/crates/chain/src/canonical_view.rs b/crates/chain/src/canonical_view.rs index a8c5d3bb7..3794877b6 100644 --- a/crates/chain/src/canonical_view.rs +++ b/crates/chain/src/canonical_view.rs @@ -21,7 +21,7 @@ //! } //! ``` -use crate::collections::HashMap; +use crate::collections::{BTreeSet, HashMap}; use alloc::sync::Arc; use core::{fmt, ops::RangeBounds}; @@ -431,4 +431,91 @@ impl CanonicalView { .collect() }) } + + /// Extracts a subgraph containing the specified transactions and all their descendants. + /// + /// Takes transaction IDs and returns a new `CanonicalView` containing those transactions + /// plus any transactions that spend their outputs (recursively). The extracted transactions + /// are removed from this view. + pub fn extract_subgraph(&mut self, txids: impl Iterator) -> Self { + use crate::collections::hash_map::Entry; + + let mut to_vist = txids.collect::>(); + + let mut spends = HashMap::::new(); + let mut txs = HashMap::, ChainPosition)>::new(); + + while let Some(txid) = to_vist.pop() { + let tx_entry = match txs.entry(txid) { + Entry::Occupied(_) => continue, // Already visited. + Entry::Vacant(entry) => entry, + }; + + let (tx, pos) = match self.txs.remove(&txid) { + Some(tx_and_pos) => tx_and_pos, + None => continue, // Doesn't exist in graph. + }; + + tx_entry.insert((tx.clone(), pos.clone())); + + for op in (0_u32..tx.output.len() as u32).map(|vout| OutPoint::new(txid, vout)) { + let spent_by = match self.spends.remove(&op) { + Some(spent_by) => spent_by, + None => continue, + }; + spends.insert(op, spent_by); + to_vist.push(spent_by); + } + } + + // final pass to clean `self.spends`. + for txin in txs.values().flat_map(|(tx, _)| &tx.input) { + self.spends.remove(&txin.previous_output); + } + + // Remove extracted transactions from self.order + let extracted_txids = txs.keys().copied().collect::>(); + self.order.retain(|txid| !extracted_txids.contains(txid)); + + // TODO: Use this with `TopologicalIter` once #2027 gets merged to return a view that has + // topologically-ordered transactions. + let _roots = txs + .iter() + .filter(|(_, (tx, _))| { + tx.is_coinbase() + || tx + .input + .iter() + .all(|txin| !spends.contains_key(&txin.previous_output)) + }) + .map(|(&txid, _)| txid); + + CanonicalView { + order: txs.keys().copied().collect(), // TODO: Not ordered. + txs, + spends, + tip: self.tip, + } + } + + /// Returns transactions without parent transactions in this view. + /// + /// Root transactions are either coinbase transactions or transactions whose inputs + /// reference outputs not present in this canonical view. + pub fn roots(&self) -> impl Iterator> + '_ { + self.txs + .iter() + .filter(|(_, (tx, _))| { + tx.is_coinbase() + || tx + .input + .iter() + .all(|txin| !self.spends.contains_key(&txin.previous_output)) + }) + .map(|(&txid, (tx, pos))| CanonicalTx { + pos: pos.clone(), + txid, + tx: tx.clone(), + }) + } } From f08130045e3bdd126dd040f6ac3314e3fcbb4ac6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=BF=97=E5=AE=87?= Date: Fri, 19 Sep 2025 07:35:08 +0000 Subject: [PATCH 12/12] test(chain): add tests for CanonicalView::extract_subgraph MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Test extraction of transaction subgraphs with descendants, verifying that spends and txs fields maintain consistency. 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --- crates/chain/tests/test_canonical_view.rs | 459 ++++++++++++++++++++++ 1 file changed, 459 insertions(+) diff --git a/crates/chain/tests/test_canonical_view.rs b/crates/chain/tests/test_canonical_view.rs index 86adb35ce..ec0d24285 100644 --- a/crates/chain/tests/test_canonical_view.rs +++ b/crates/chain/tests/test_canonical_view.rs @@ -299,3 +299,462 @@ fn test_additional_confirmations_multiple_transactions() { ); assert_eq!(balance_high.untrusted_pending, Amount::ZERO); } + +#[test] +fn test_extract_subgraph_basic_chain() { + // Test extracting a simple chain: tx0 -> tx1 -> tx2 + // Extracting tx1 should also extract tx2 (its child) + + let blocks: BTreeMap = [ + (0, hash!("genesis")), + (1, hash!("block1")), + (2, hash!("block2")), + ] + .into_iter() + .collect(); + let chain = LocalChain::from_blocks(blocks).unwrap(); + let mut tx_graph = TxGraph::default(); + + // Create tx0 (coinbase - will be canonical) + let tx0 = Transaction { + output: vec![TxOut { + value: Amount::from_sat(100_000), + script_pubkey: ScriptBuf::new(), + }], + ..new_tx(0) + }; + let txid0 = tx0.compute_txid(); + let _ = tx_graph.insert_tx(tx0.clone()); + let _ = tx_graph.insert_anchor( + txid0, + ConfirmationBlockTime { + block_id: chain.get(1).unwrap().block_id(), + confirmation_time: 100, + }, + ); + + // Create tx1 that spends from tx0 + let tx1 = Transaction { + input: vec![TxIn { + previous_output: OutPoint::new(txid0, 0), + ..Default::default() + }], + output: vec![TxOut { + value: Amount::from_sat(90_000), + script_pubkey: ScriptBuf::new(), + }], + ..new_tx(1) + }; + let txid1 = tx1.compute_txid(); + let _ = tx_graph.insert_tx(tx1.clone()); + let _ = tx_graph.insert_anchor( + txid1, + ConfirmationBlockTime { + block_id: chain.get(2).unwrap().block_id(), + confirmation_time: 200, + }, + ); + + // Create tx2 that spends from tx1 + let tx2 = Transaction { + input: vec![TxIn { + previous_output: OutPoint::new(txid1, 0), + ..Default::default() + }], + output: vec![TxOut { + value: Amount::from_sat(80_000), + script_pubkey: ScriptBuf::new(), + }], + ..new_tx(2) + }; + let txid2 = tx2.compute_txid(); + let _ = tx_graph.insert_tx(tx2.clone()); + // tx2 is unconfirmed but seen + let _ = tx_graph.insert_seen_at(txid2, 300); + + let mut canonical_view = tx_graph.canonical_view( + &chain, + chain.tip().block_id(), + CanonicalizationParams::default(), + ); + + // Extract tx1 and its descendants + let extracted = canonical_view.extract_subgraph([txid1].into_iter()); + + // Verify extracted view contains tx1 and tx2 but not tx0 + assert!(extracted.tx(txid1).is_some()); + assert!(extracted.tx(txid2).is_some()); + assert!(extracted.tx(txid0).is_none()); + + // Verify remaining view contains only tx0 + assert!(canonical_view.tx(txid0).is_some()); + assert!(canonical_view.tx(txid1).is_none()); + assert!(canonical_view.tx(txid2).is_none()); + + // Verify that tx1's input (spending from tx0) is properly handled + let tx1_data = extracted.tx(txid1).unwrap(); + assert_eq!(tx1_data.tx.input[0].previous_output.txid, txid0); + + // Verify that tx2's input (spending from tx1) is properly handled + let tx2_data = extracted.tx(txid2).unwrap(); + assert_eq!(tx2_data.tx.input[0].previous_output.txid, txid1); + + // Verify through txout that spending relationships are maintained + // tx0 output 0 is spent (but tx0 is not in the extracted view) + assert!(extracted.txout(OutPoint::new(txid0, 0)).is_none()); + + // tx1 output 0 is spent by tx2 + let tx1_out = extracted.txout(OutPoint::new(txid1, 0)).unwrap(); + assert_eq!( + tx1_out.spent_by.as_ref().map(|(_, txid)| *txid), + Some(txid2) + ); + + // tx2 output 0 is unspent + let tx2_out = extracted.txout(OutPoint::new(txid2, 0)).unwrap(); + assert!(tx2_out.spent_by.is_none()); + + // Verify remaining view: tx0 output should be unspent (tx1 was removed) + let tx0_out = canonical_view.txout(OutPoint::new(txid0, 0)).unwrap(); + assert!(tx0_out.spent_by.is_none()); +} + +#[test] +fn test_extract_subgraph_complex_graph() { + // Test a more complex graph: + // tx0 + // / \ + // tx1 tx2 + // \ / + // tx3 + // | + // tx4 + + let blocks: BTreeMap = [ + (0, hash!("genesis")), + (1, hash!("block1")), + (2, hash!("block2")), + (3, hash!("block3")), + ] + .into_iter() + .collect(); + let chain = LocalChain::from_blocks(blocks).unwrap(); + let mut tx_graph = TxGraph::default(); + + // Create tx0 with 2 outputs + let tx0 = Transaction { + output: vec![ + TxOut { + value: Amount::from_sat(50_000), + script_pubkey: ScriptBuf::new(), + }, + TxOut { + value: Amount::from_sat(50_000), + script_pubkey: ScriptBuf::new(), + }, + ], + ..new_tx(0) + }; + let txid0 = tx0.compute_txid(); + let _ = tx_graph.insert_tx(tx0.clone()); + let _ = tx_graph.insert_anchor( + txid0, + ConfirmationBlockTime { + block_id: chain.get(1).unwrap().block_id(), + confirmation_time: 100, + }, + ); + + // tx1 spends first output of tx0 + let tx1 = Transaction { + input: vec![TxIn { + previous_output: OutPoint::new(txid0, 0), + ..Default::default() + }], + output: vec![TxOut { + value: Amount::from_sat(45_000), + script_pubkey: ScriptBuf::new(), + }], + ..new_tx(1) + }; + let txid1 = tx1.compute_txid(); + let _ = tx_graph.insert_tx(tx1.clone()); + let _ = tx_graph.insert_anchor( + txid1, + ConfirmationBlockTime { + block_id: chain.get(2).unwrap().block_id(), + confirmation_time: 200, + }, + ); + + // tx2 spends second output of tx0 + let tx2 = Transaction { + input: vec![TxIn { + previous_output: OutPoint::new(txid0, 1), + ..Default::default() + }], + output: vec![TxOut { + value: Amount::from_sat(45_000), + script_pubkey: ScriptBuf::new(), + }], + ..new_tx(2) + }; + let txid2 = tx2.compute_txid(); + let _ = tx_graph.insert_tx(tx2.clone()); + let _ = tx_graph.insert_anchor( + txid2, + ConfirmationBlockTime { + block_id: chain.get(2).unwrap().block_id(), + confirmation_time: 201, + }, + ); + + // tx3 spends from both tx1 and tx2 + let tx3 = Transaction { + input: vec![ + TxIn { + previous_output: OutPoint::new(txid1, 0), + ..Default::default() + }, + TxIn { + previous_output: OutPoint::new(txid2, 0), + ..Default::default() + }, + ], + output: vec![TxOut { + value: Amount::from_sat(85_000), + script_pubkey: ScriptBuf::new(), + }], + ..new_tx(3) + }; + let txid3 = tx3.compute_txid(); + let _ = tx_graph.insert_tx(tx3.clone()); + let _ = tx_graph.insert_anchor( + txid3, + ConfirmationBlockTime { + block_id: chain.get(3).unwrap().block_id(), + confirmation_time: 300, + }, + ); + + // tx4 spends from tx3 + let tx4 = Transaction { + input: vec![TxIn { + previous_output: OutPoint::new(txid3, 0), + ..Default::default() + }], + output: vec![TxOut { + value: Amount::from_sat(80_000), + script_pubkey: ScriptBuf::new(), + }], + ..new_tx(4) + }; + let txid4 = tx4.compute_txid(); + let _ = tx_graph.insert_tx(tx4.clone()); + // tx4 is unconfirmed but seen + let _ = tx_graph.insert_seen_at(txid4, 400); + + let mut canonical_view = tx_graph.canonical_view( + &chain, + chain.tip().block_id(), + CanonicalizationParams::default(), + ); + + // Extract tx1 and tx2, should also get tx3 and tx4 as descendants + let extracted = canonical_view.extract_subgraph([txid1, txid2].into_iter()); + + // Verify extracted view contains tx1, tx2, tx3, tx4 but not tx0 + assert!(extracted.tx(txid1).is_some()); + assert!(extracted.tx(txid2).is_some()); + assert!(extracted.tx(txid3).is_some()); + assert!(extracted.tx(txid4).is_some()); + assert!(extracted.tx(txid0).is_none()); + + // Verify remaining view contains only tx0 + assert!(canonical_view.tx(txid0).is_some()); + assert!(canonical_view.tx(txid1).is_none()); + assert!(canonical_view.tx(txid2).is_none()); + assert!(canonical_view.tx(txid3).is_none()); + assert!(canonical_view.tx(txid4).is_none()); + + // Verify spends field correctness + verify_spends_consistency(&extracted); + verify_spends_consistency(&canonical_view); +} + +#[test] +fn test_extract_subgraph_nonexistent_tx() { + // Test extracting a transaction that doesn't exist + let blocks: BTreeMap = [(0, hash!("genesis")), (1, hash!("block1"))] + .into_iter() + .collect(); + let chain = LocalChain::from_blocks(blocks).unwrap(); + let mut tx_graph = TxGraph::default(); + + let tx = Transaction { + output: vec![TxOut { + value: Amount::from_sat(100_000), + script_pubkey: ScriptBuf::new(), + }], + ..new_tx(0) + }; + let txid = tx.compute_txid(); + let _ = tx_graph.insert_tx(tx); + let _ = tx_graph.insert_anchor( + txid, + ConfirmationBlockTime { + block_id: chain.get(1).unwrap().block_id(), + confirmation_time: 100, + }, + ); + + let mut canonical_view = tx_graph.canonical_view( + &chain, + chain.tip().block_id(), + CanonicalizationParams::default(), + ); + + // Try to extract a non-existent transaction + let fake_txid = hash!("nonexistent"); + let extracted = canonical_view.extract_subgraph([fake_txid].into_iter()); + + // Should return empty view + assert_eq!(extracted.txs().count(), 0); + + // Original view should be unchanged + assert!(canonical_view.tx(txid).is_some()); +} + +#[test] +fn test_extract_subgraph_partial_chain() { + // Test extracting from the middle of a chain + // tx0 -> tx1 -> tx2 -> tx3 + // Extract tx1 should give tx1, tx2, tx3 but not tx0 + + let blocks: BTreeMap = [ + (0, hash!("genesis")), + (1, hash!("block1")), + (2, hash!("block2")), + (3, hash!("block3")), + (4, hash!("block4")), + ] + .into_iter() + .collect(); + let chain = LocalChain::from_blocks(blocks).unwrap(); + let mut tx_graph = TxGraph::default(); + + // Build chain of transactions + let mut txids = vec![]; + let mut prev_txid = None; + + for i in 0..4 { + let tx = if let Some(prev) = prev_txid { + Transaction { + input: vec![TxIn { + previous_output: OutPoint::new(prev, 0), + ..Default::default() + }], + output: vec![TxOut { + value: Amount::from_sat(100_000 - i * 10_000), + script_pubkey: ScriptBuf::new(), + }], + ..new_tx(i as u32) + } + } else { + Transaction { + output: vec![TxOut { + value: Amount::from_sat(100_000), + script_pubkey: ScriptBuf::new(), + }], + ..new_tx(i as u32) + } + }; + + let txid = tx.compute_txid(); + let _ = tx_graph.insert_tx(tx); + + // Anchor each transaction in successive blocks + let _ = tx_graph.insert_anchor( + txid, + ConfirmationBlockTime { + block_id: chain.get(i as u32 + 1).unwrap().block_id(), + confirmation_time: (i + 1) * 100, + }, + ); + + txids.push(txid); + prev_txid = Some(txid); + } + + let mut canonical_view = tx_graph.canonical_view( + &chain, + chain.tip().block_id(), + CanonicalizationParams::default(), + ); + + // Extract from tx1 + let extracted = canonical_view.extract_subgraph([txids[1]].into_iter()); + + // Verify extracted contains tx1, tx2, tx3 but not tx0 + assert!(extracted.tx(txids[1]).is_some()); + assert!(extracted.tx(txids[2]).is_some()); + assert!(extracted.tx(txids[3]).is_some()); + assert!(extracted.tx(txids[0]).is_none()); + + // Verify remaining contains only tx0 + assert!(canonical_view.tx(txids[0]).is_some()); + assert!(canonical_view.tx(txids[1]).is_none()); + + verify_spends_consistency(&extracted); + verify_spends_consistency(&canonical_view); +} + +// Helper function to verify spends field consistency +fn verify_spends_consistency(view: &bdk_chain::CanonicalView) { + // Verify each transaction's outputs and their spent status + for tx in view.txs() { + // Verify the transaction exists + assert!(view.tx(tx.txid).is_some()); + + // For each output, check if it's properly tracked as spent + for vout in 0..tx.tx.output.len() { + let op = OutPoint::new(tx.txid, vout as u32); + if let Some(txout) = view.txout(op) { + // If this output is spent, verify the spending tx exists + if let Some((_, spending_txid)) = txout.spent_by { + assert!( + view.tx(spending_txid).is_some(), + "Spending tx {spending_txid} not found in view" + ); + + // Verify the spending tx actually has this input + let spending_tx = view.tx(spending_txid).unwrap(); + assert!( + spending_tx + .tx + .input + .iter() + .any(|input| input.previous_output == op), + "Transaction {spending_txid} doesn't actually spend outpoint {op}" + ); + } + } + } + + // For each input (except coinbase), verify it references valid outpoints + if !tx.tx.is_coinbase() { + for input in &tx.tx.input { + // If the parent tx is in this view, verify the output exists and shows as spent + if let Some(parent_txout) = view.txout(input.previous_output) { + assert_eq!( + parent_txout.spent_by.as_ref().map(|(_, txid)| *txid), + Some(tx.txid), + "Output {:?} should be marked as spent by tx {}", + input.previous_output, + tx.txid + ); + } + } + } + } +}