diff --git a/crates/bitcoind_rpc/examples/filter_iter.rs b/crates/bitcoind_rpc/examples/filter_iter.rs index c0e755f9c..e79bde672 100644 --- a/crates/bitcoind_rpc/examples/filter_iter.rs +++ b/crates/bitcoind_rpc/examples/filter_iter.rs @@ -68,14 +68,11 @@ 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(), - ) + + 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() { println!("\nUnspent"); @@ -85,16 +82,9 @@ 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 canonical_view.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..0923bfbd7 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, 0); 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..3caea42d2 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, 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 new file mode 100644 index 000000000..3794877b6 --- /dev/null +++ b/crates/chain/src/canonical_view.rs @@ -0,0 +1,521 @@ +//! 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::{BTreeSet, 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 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 CanonicalTx { + /// 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, + /// The transaction ID (hash) of this transaction. + pub txid: Txid, + /// The full transaction. + pub tx: Arc, +} + +/// 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 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 topological-spending 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 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. + /// + /// # Returns + /// + /// Returns `Ok(CanonicalView)` on success, or an error if the chain oracle fails. + 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, reason) = 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 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, + }, + }, + }; + view.txs.insert(txid, (tx_node.tx, pos)); + } + + Ok(view) + } + + /// Get a single canonical transaction by its transaction ID. + /// + /// 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) + .cloned() + .map(|(tx, pos)| CanonicalTx { pos, txid, tx }) + } + + /// 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 + 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(), + }) + } + + /// Get an iterator over all canonical transactions in order. + /// + /// 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 + /// + /// ``` + /// # 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 + '_ { + self.order.iter().map(|&txid| { + let (tx, pos) = self.txs[&txid].clone(); + CanonicalTx { pos, txid, tx } + }) + } + + /// 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. + /// + /// The identifier type `O` is useful for tracking which outpoints correspond to which addresses + /// or keys. + /// + /// # 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().clone()) { + /// println!("{}: {} sats", keychain.0, txout.txout.value); + /// } + /// ``` + 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. + /// + /// 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. + /// + /// # 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().clone()) { + /// println!("{} UTXO: {} sats", keychain.0, utxo.txout.value); + /// } + /// ``` + 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()) + } + + /// 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 + /// + /// * `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` + /// * `additional_confirmations` - Additional confirmations required beyond the first one. + /// Outputs with fewer than (1 + additional_confirmations) are treated as pending. + /// + /// # Additional Confirmations + /// + /// 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 + /// + /// ``` + /// # 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 + /// 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, &FullTxOut) -> bool, + additional_confirmations: u32, + ) -> 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 { anchor, .. } => { + let confirmation_height = anchor.confirmation_height_upper_bound(); + let confirmations = self + .tip + .height + .saturating_sub(confirmation_height) + .saturating_add(1); + let required_confirmations = 1 + additional_confirmations; + + if confirmations < required_confirmations { + // Not enough confirmations, treat as trusted/untrusted pending + if trust_predicate(&spk_i, &txout) { + trusted_pending += txout.txout.value; + } else { + untrusted_pending += txout.txout.value; + } + } else if txout.is_confirmed_and_spendable(self.tip.height) { + confirmed += txout.txout.value; + } else if !txout.is_mature(self.tip.height) { + immature += txout.txout.value; + } + } + ChainPosition::Unconfirmed { .. } => { + if trust_predicate(&spk_i, &txout) { + trusted_pending += txout.txout.value; + } else { + untrusted_pending += txout.txout.value; + } + } + } + } + + Balance { + immature, + trusted_pending, + untrusted_pending, + confirmed, + } + } + + /// 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. + 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() + }) + } + + /// 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(), + }) + } +} 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..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 @@ -120,21 +119,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, 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}, @@ -248,27 +244,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 { @@ -980,183 +955,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 +990,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>( + /// 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, - ) -> impl Iterator)> + 'a { - self.try_filter_chain_txouts(chain, chain_tip, params, outpoints) - .expect("oracle is infallible") + ) -> 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`. - /// - /// `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 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())) - } - - /// 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>( - &'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_canonical_view.rs b/crates/chain/tests/test_canonical_view.rs new file mode 100644 index 000000000..ec0d24285 --- /dev/null +++ b/crates/chain/tests/test_canonical_view.rs @@ -0,0 +1,760 @@ +#![cfg(feature = "miniscript")] + +use bdk_chain::{local_chain::LocalChain, CanonicalizationParams, ConfirmationBlockTime, TxGraph}; +use bdk_testenv::{hash, utils::new_tx}; +use bitcoin::{Amount, BlockHash, OutPoint, ScriptBuf, Transaction, TxIn, TxOut}; +use std::collections::BTreeMap; + +#[test] +fn test_additional_confirmations_parameter() { + // Create a local chain with several blocks + 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(); + + // 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 additional_confirmations = 0: Should be confirmed (has 6 confirmations, needs 1) + let balance_1_conf = canonical_view.balance( + [((), outpoint)], + |_, _| true, // trust all + 0, + ); + + assert_eq!(balance_1_conf.confirmed, Amount::from_sat(50_000)); + assert_eq!(balance_1_conf.trusted_pending, Amount::ZERO); + + // Test additional_confirmations = 5: Should be confirmed (has 6 confirmations, needs 6) + let balance_6_conf = canonical_view.balance( + [((), outpoint)], + |_, _| true, // trust all + 5, + ); + assert_eq!(balance_6_conf.confirmed, Amount::from_sat(50_000)); + assert_eq!(balance_6_conf.trusted_pending, Amount::ZERO); + + // Test additional_confirmations = 6: Should be trusted pending (has 6 confirmations, needs 7) + let balance_7_conf = canonical_view.balance( + [((), outpoint)], + |_, _| true, // trust all + 6, + ); + assert_eq!(balance_7_conf.confirmed, Amount::ZERO); + assert_eq!(balance_7_conf.trusted_pending, Amount::from_sat(50_000)); + + // Test additional_confirmations = 0: Should be 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); +} + +#[test] +fn test_additional_confirmations_with_untrusted_tx() { + // Create a local chain + 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(); + + // 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 additional_confirmations = 4 and untrusted predicate (requires 5 total) + let balance = canonical_view.balance( + [((), outpoint)], + |_, _| false, // don't trust + 4, + ); + + // 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_additional_confirmations_multiple_transactions() { + // Create a local chain + 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(); + + // 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 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, 4); + + 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 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, 9); + + 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); +} + +#[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 + ); + } + } + } + } +} diff --git a/crates/chain/tests/test_indexed_tx_graph.rs b/crates/chain/tests/test_indexed_tx_graph.rs index db91a34b3..4595769c9 100644 --- a/crates/chain/tests/test_indexed_tx_graph.rs +++ b/crates/chain/tests/test_indexed_tx_graph.rs @@ -460,32 +460,22 @@ 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(), + |_, txout| trusted_spks.contains(&txout.txout.script_pubkey), + 0, + ); let confirmed_txouts_txid = txouts .iter() @@ -789,15 +779,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..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}; @@ -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,18 @@ 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(), + |_, txout| { + env.indexer + .index_of_spk(txout.txout.script_pubkey.clone()) + .is_some() + }, + 0, + ); 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..608dae39f 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, 0); 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..d67959392 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,17 @@ 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, + 0, + ) }; println!( "[{:>10}s] synced to {} @ {} | total: {}", @@ -245,13 +250,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 +356,17 @@ 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, + 0, + ) }; println!( "[{:>10}s] synced to {} @ {} / {} | total: {}", diff --git a/examples/example_cli/src/lib.rs b/examples/example_cli/src/lib.rs index 96a41802f..9904bc47e 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,17 @@ 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, + 0, + ); let confirmed_total = balance.confirmed + balance.immature; let unconfirmed_total = balance.untrusted_pending + balance.trusted_pending; @@ -573,13 +572,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), ); } }