diff --git a/crates/bitcoind_rpc/examples/filter_iter.rs b/crates/bitcoind_rpc/examples/filter_iter.rs index e79bde672..895120323 100644 --- a/crates/bitcoind_rpc/examples/filter_iter.rs +++ b/crates/bitcoind_rpc/examples/filter_iter.rs @@ -69,7 +69,8 @@ fn main() -> anyhow::Result<()> { println!("\ntook: {}s", start.elapsed().as_secs()); println!("Local tip: {}", chain.tip().height()); - let canonical_view = graph.canonical_view(&chain, chain.tip().block_id(), Default::default()); + let chain_tip = chain.tip().block_id(); + let canonical_view = chain.canonical_view(graph.graph(), chain_tip, Default::default()); let unspent: Vec<_> = canonical_view .filter_unspent_outpoints(graph.index.outpoints().clone()) diff --git a/crates/bitcoind_rpc/tests/test_emitter.rs b/crates/bitcoind_rpc/tests/test_emitter.rs index 67cbb329f..b891e9d76 100644 --- a/crates/bitcoind_rpc/tests/test_emitter.rs +++ b/crates/bitcoind_rpc/tests/test_emitter.rs @@ -5,7 +5,7 @@ use bdk_chain::{ bitcoin::{Address, Amount, Txid}, local_chain::{CheckPoint, LocalChain}, spk_txout::SpkTxOutIndex, - Balance, BlockId, CanonicalizationParams, IndexedTxGraph, Merge, + Balance, BlockId, CanonicalParams, IndexedTxGraph, Merge, }; use bdk_testenv::{ anyhow, @@ -320,9 +320,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 - .canonical_view(recv_chain, chain_tip, CanonicalizationParams::default()) - .balance(outpoints, |_, _| true, 1); + let balance = recv_chain + .canonical_view(recv_graph.graph(), chain_tip, CanonicalParams::default()) + .balance(outpoints, |_, _| true, 0); Ok(balance) } @@ -634,8 +634,8 @@ fn test_expect_tx_evicted() -> anyhow::Result<()> { let _txid_2 = core.send_raw_transaction(&tx1b)?; // Retrieve the expected unconfirmed txids and spks from the graph. - let exp_spk_txids = graph - .canonical_view(&chain, chain_tip, Default::default()) + let exp_spk_txids = chain + .canonical_view(graph.graph(), chain_tip, Default::default()) .list_expected_spk_txids(&graph.index, ..) .collect::>(); assert_eq!(exp_spk_txids, vec![(spk, txid_1)]); @@ -650,8 +650,8 @@ fn test_expect_tx_evicted() -> anyhow::Result<()> { // Update graph with evicted tx. let _ = graph.batch_insert_relevant_evicted_at(mempool_event.evicted); - let canonical_txids = graph - .canonical_view(&chain, chain_tip, CanonicalizationParams::default()) + let canonical_txids = chain + .canonical_view(graph.graph(), chain_tip, CanonicalParams::default()) .txs() .map(|tx| tx.txid) .collect::>(); diff --git a/crates/chain/benches/canonicalization.rs b/crates/chain/benches/canonicalization.rs index 074e38cc4..dd05100a5 100644 --- a/crates/chain/benches/canonicalization.rs +++ b/crates/chain/benches/canonicalization.rs @@ -1,4 +1,4 @@ -use bdk_chain::CanonicalizationParams; +use bdk_chain::CanonicalParams; use bdk_chain::{keychain_txout::KeychainTxOutIndex, local_chain::LocalChain, IndexedTxGraph}; use bdk_core::{BlockId, CheckPoint}; use bdk_core::{ConfirmationBlockTime, TxUpdate}; @@ -95,31 +95,22 @@ fn setup(f: F) -> (KeychainTxGraph, Lo } fn run_list_canonical_txs(tx_graph: &KeychainTxGraph, chain: &LocalChain, exp_txs: usize) { - let view = tx_graph.canonical_view( - chain, - chain.tip().block_id(), - CanonicalizationParams::default(), - ); + let chain_tip = chain.tip().block_id(); + let view = chain.canonical_view(tx_graph.graph(), chain_tip, CanonicalParams::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 view = tx_graph.canonical_view( - chain, - chain.tip().block_id(), - CanonicalizationParams::default(), - ); + let chain_tip = chain.tip().block_id(); + let view = chain.canonical_view(tx_graph.graph(), chain_tip, CanonicalParams::default()); 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 view = tx_graph.canonical_view( - chain, - chain.tip().block_id(), - CanonicalizationParams::default(), - ); + let chain_tip = chain.tip().block_id(); + let view = chain.canonical_view(tx_graph.graph(), chain_tip, CanonicalParams::default()); 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 5907c76a0..68e9a477e 100644 --- a/crates/chain/benches/indexer.rs +++ b/crates/chain/benches/indexer.rs @@ -1,7 +1,7 @@ use bdk_chain::{ keychain_txout::{InsertDescriptorError, KeychainTxOutIndex}, local_chain::LocalChain, - CanonicalizationParams, IndexedTxGraph, + CanonicalParams, IndexedTxGraph, }; use bdk_core::{BlockId, CheckPoint, ConfirmationBlockTime, TxUpdate}; use bitcoin::{ @@ -84,8 +84,8 @@ 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 - .canonical_view(chain, chain_tip, CanonicalizationParams::default()) + let bal = chain + .canonical_view(graph.graph(), chain_tip, CanonicalParams::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.rs similarity index 51% rename from crates/chain/src/canonical_view.rs rename to crates/chain/src/canonical.rs index 0191f4507..e89b5d4e5 100644 --- a/crates/chain/src/canonical_view.rs +++ b/crates/chain/src/canonical.rs @@ -6,14 +6,14 @@ //! ## Example //! //! ``` -//! # use bdk_chain::{CanonicalView, TxGraph, CanonicalizationParams, local_chain::LocalChain}; +//! # use bdk_chain::{TxGraph, CanonicalParams, 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(); +//! let chain_tip = chain.tip().block_id(); +//! let params = CanonicalParams::default(); +//! let view = chain.canonical_view(&tx_graph, chain_tip, params); //! //! // Iterate over canonical transactions //! for tx in view.txs() { @@ -23,37 +23,50 @@ use crate::collections::HashMap; use alloc::sync::Arc; +use alloc::vec::Vec; use core::{fmt, ops::RangeBounds}; -use alloc::vec::Vec; +use bdk_core::{BlockId, BlockQueries}; +use bitcoin::{ + constants::COINBASE_MATURITY, Amount, OutPoint, ScriptBuf, Transaction, TxOut, Txid, +}; -use bdk_core::BlockId; -use bitcoin::{Amount, OutPoint, ScriptBuf, Transaction, Txid}; +use crate::{spk_txout::SpkTxOutIndex, Anchor, Balance, CanonicalViewTask, ChainPosition, TxGraph}; -use crate::{ - spk_txout::SpkTxOutIndex, tx_graph::TxNode, Anchor, Balance, CanonicalIter, CanonicalReason, - CanonicalizationParams, ChainOracle, ChainPosition, FullTxOut, ObservedIn, TxGraph, -}; +/// Internal per-transaction entry in [`Canonical`]. +#[derive(Clone, Debug)] +pub(crate) struct CanonicalEntry

{ + pub(crate) tx: Arc, + pub(crate) pos: P, + pub(crate) mtp: Option, +} -/// A single canonical transaction with its chain position. +/// A single canonical transaction with its 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). +/// conflicted). It includes the transaction itself along with its position information. +/// The position type `P` is generic — it can be [`ChainPosition`] for resolved views, +/// or [`CanonicalReason`](crate::canonical_task::CanonicalReason) for unresolved canonicalization +/// results. #[derive(Clone, Debug, PartialEq, Eq)] -pub struct CanonicalTx { - /// The position of this transaction in the chain. +pub struct CanonicalTx

{ + /// The position of this transaction. /// - /// This indicates whether the transaction is confirmed (and at what height) or - /// unconfirmed (most likely pending in the mempool). - pub pos: ChainPosition, + /// When `P` is [`ChainPosition`], this indicates whether the transaction is confirmed + /// (and at what height) or unconfirmed (most likely pending in the mempool). + pub pos: P, /// The transaction ID (hash) of this transaction. pub txid: Txid, /// The full transaction. pub tx: Arc, + /// The median-time-past at the confirmation height, if computed. + /// + /// This is `Some` only when the transaction is confirmed and MTP computation was + /// enabled via [`CanonicalViewTask::with_mtp`](crate::CanonicalViewTask::with_mtp). + pub mtp: Option, } -impl Ord for CanonicalTx { +impl Ord for CanonicalTx

{ fn cmp(&self, other: &Self) -> core::cmp::Ordering { self.pos .cmp(&other.pos) @@ -62,155 +75,201 @@ impl Ord for CanonicalTx { } } -impl PartialOrd for CanonicalTx { +impl PartialOrd for CanonicalTx

{ + fn partial_cmp(&self, other: &Self) -> Option { + Some(self.cmp(other)) + } +} + +/// A canonical transaction output with position and spend information. +/// +/// The position type `P` is generic — it can be [`ChainPosition`] for resolved views, +/// or [`CanonicalReason`](crate::canonical_task::CanonicalReason) for unresolved canonicalization +/// results. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct CanonicalTxOut

{ + /// The position of the transaction in `outpoint` in the overall chain. + pub pos: P, + /// The location of the `TxOut`. + pub outpoint: OutPoint, + /// The `TxOut`. + pub txout: TxOut, + /// The txid and position of the transaction (if any) that has spent this output. + pub spent_by: Option<(P, Txid)>, + /// Whether this output is on a coinbase transaction. + pub is_on_coinbase: bool, + /// The median-time-past at the confirmation height, if computed. + /// + /// This is `Some` only when the output's transaction is confirmed and MTP computation + /// was enabled via [`CanonicalViewTask::with_mtp`](crate::CanonicalViewTask::with_mtp). + pub mtp: Option, +} + +impl Ord for CanonicalTxOut

{ + fn cmp(&self, other: &Self) -> core::cmp::Ordering { + self.pos + .cmp(&other.pos) + // Tie-break with `outpoint` and `spent_by`. + .then_with(|| self.outpoint.cmp(&other.outpoint)) + .then_with(|| self.spent_by.cmp(&other.spent_by)) + } +} + +impl PartialOrd for CanonicalTxOut

{ fn partial_cmp(&self, other: &Self) -> Option { Some(self.cmp(other)) } } -/// A view of canonical transactions from a [`TxGraph`]. +impl CanonicalTxOut> { + /// Whether the `txout` is considered mature. + /// + /// Depending on the implementation of [`confirmation_height_upper_bound`] in [`Anchor`], this + /// method may return false-negatives. In other words, interpreted confirmation count may be + /// less than the actual value. + /// + /// [`confirmation_height_upper_bound`]: Anchor::confirmation_height_upper_bound + pub fn is_mature(&self, tip: u32) -> bool { + if self.is_on_coinbase { + let conf_height = match self.pos.confirmation_height_upper_bound() { + Some(height) => height, + None => { + debug_assert!(false, "coinbase tx can never be unconfirmed"); + return false; + } + }; + let age = tip.saturating_sub(conf_height); + if age + 1 < COINBASE_MATURITY { + return false; + } + } + + true + } + + /// Whether the utxo is/was/will be spendable with chain `tip`. + /// + /// This method does not take into account the lock time. + /// + /// Depending on the implementation of [`confirmation_height_upper_bound`] in [`Anchor`], this + /// method may return false-negatives. In other words, interpreted confirmation count may be + /// less than the actual value. + /// + /// [`confirmation_height_upper_bound`]: Anchor::confirmation_height_upper_bound + pub fn is_confirmed_and_spendable(&self, tip: u32) -> bool { + if !self.is_mature(tip) { + return false; + } + + let conf_height = match self.pos.confirmation_height_upper_bound() { + Some(height) => height, + None => return false, + }; + if conf_height > tip { + return false; + } + + // if the spending tx is confirmed within tip height, the txout is no longer spendable + if let Some(spend_height) = self + .spent_by + .as_ref() + .and_then(|(pos, _)| pos.confirmation_height_upper_bound()) + { + if spend_height <= tip { + return false; + } + } + + true + } +} + +/// Canonical set of transactions from a [`TxGraph`]. /// -/// `CanonicalView` provides an ordered, conflict-resolved view of transactions. It determines +/// `Canonical` provides an ordered, conflict-resolved set 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 position type `P` is generic: +/// - [`ChainPosition`] for resolved views (aka [`CanonicalView`]) +/// - [`CanonicalReason`](crate::canonical_task::CanonicalReason) for unresolved results (aka +/// [`CanonicalTxs`]) +/// /// 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 in topological-spending order. - order: Vec, - /// Map of transaction IDs to their transaction data and chain position. - txs: HashMap, ChainPosition)>, +pub struct Canonical { + /// Ordered list of transaction IDs in topological-spending order. + pub(crate) order: Vec, + /// Map of transaction IDs to their transaction data, position, and MTP. + pub(crate) txs: HashMap>, /// Map of outpoints to the transaction ID that spends them. - spends: HashMap, + pub(crate) spends: HashMap, /// The chain tip at the time this view was created. - tip: BlockId, + pub(crate) tip: BlockId, + /// Median-time-past at the chain tip height. + pub(crate) tip_mtp: Option, + /// Marker for the anchor type. + pub(crate) _anchor: core::marker::PhantomData, } -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( - 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() - } - - 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; - } - }; +/// Type alias for canonical transactions with resolved [`ChainPosition`]s. +pub type CanonicalView = Canonical>; - view.order.push(txid); - - if !tx.is_coinbase() { - view.spends - .extend(tx.input.iter().map(|txin| (txin.previous_output, txid))); - } +/// Type alias for canonical transactions with unresolved +/// [`CanonicalReason`](crate::canonical_task::CanonicalReason)s. +pub type CanonicalTxs = Canonical>; - 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)); +impl Canonical { + /// Creates a [`Canonical`] from its constituent parts. + /// + /// This internal constructor is used by [`CanonicalTask`] to build the canonical set + /// after completing the canonicalization process. It takes the processed transaction + /// data including the canonical ordering, transaction map with positions, and + /// spend information. + pub(crate) fn new( + tip: BlockId, + order: Vec, + txs: HashMap>, + spends: HashMap, + tip_mtp: Option, + ) -> Self { + Self { + tip, + order, + txs, + spends, + tip_mtp, + _anchor: core::marker::PhantomData, } + } + + /// Get the chain tip used to construct this canonical set. + pub fn tip(&self) -> BlockId { + self.tip + } - Ok(view) + /// Get the MTP at the chain tip height. + /// + /// Returns `None` if MTP was not computed. + pub fn tip_mtp(&self) -> Option { + self.tip_mtp } /// Get a single canonical transaction by its transaction ID. /// - /// Returns `Some(CanonicalViewTx)` if the transaction exists in the canonical view, + /// Returns `Some(CanonicalTx)` if the transaction exists in the canonical set, /// or `None` if the transaction doesn't exist or was excluded due to conflicts. - pub fn tx(&self, txid: Txid) -> Option> { - self.txs - .get(&txid) - .cloned() - .map(|(tx, pos)| CanonicalTx { pos, txid, tx }) + pub fn tx(&self, txid: Txid) -> Option> { + let entry = self.txs.get(&txid)?; + Some(CanonicalTx { + pos: entry.pos.clone(), + txid, + tx: entry.tx.clone(), + mtp: entry.mtp, + }) } /// Get a single canonical transaction output. @@ -219,23 +278,24 @@ impl CanonicalView { /// spent and by which transaction. /// /// Returns `None` if: - /// - The transaction doesn't exist in the canonical view + /// - The transaction doesn't exist in the canonical set /// - 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)?; + pub fn txout(&self, op: OutPoint) -> Option> { + let entry = self.txs.get(&op.txid)?; let vout: usize = op.vout.try_into().ok()?; - let txout = tx.output.get(vout)?; + let txout = entry.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) + let spent_by_entry = &self.txs[spent_by_txid]; + (spent_by_entry.pos.clone(), *spent_by_txid) }); - Some(FullTxOut { - chain_position: pos.clone(), + Some(CanonicalTxOut { + pos: entry.pos.clone(), outpoint: op, txout: txout.clone(), spent_by, - is_on_coinbase: tx.is_coinbase(), + is_on_coinbase: entry.tx.is_coinbase(), + mtp: entry.mtp, }) } @@ -247,12 +307,13 @@ impl CanonicalView { /// # Example /// /// ``` - /// # use bdk_chain::{CanonicalView, TxGraph, local_chain::LocalChain}; + /// # use bdk_chain::{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 chain_tip = chain.tip().block_id(); + /// # let view = chain.canonical_view(&tx_graph, chain_tip, Default::default()); /// // Iterate over all canonical transactions /// for tx in view.txs() { /// println!("TX {}: {:?}", tx.txid, tx.pos); @@ -261,17 +322,22 @@ 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(); - CanonicalTx { pos, txid, tx } + let entry = &self.txs[&txid]; + CanonicalTx { + pos: entry.pos.clone(), + txid, + tx: entry.tx.clone(), + mtp: entry.mtp, + } }) } /// 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. + /// of `(identifier, canonical_txout)` pairs for outpoints that exist in the canonical set. /// Non-existent outpoints are silently filtered out. /// /// The identifier type `O` is useful for tracking which outpoints correspond to which addresses @@ -280,12 +346,13 @@ impl CanonicalView { /// # Example /// /// ``` - /// # use bdk_chain::{CanonicalView, TxGraph, local_chain::LocalChain, keychain_txout::KeychainTxOutIndex}; + /// # use bdk_chain::{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 chain_tip = chain.tip().block_id(); + /// # let view = chain.canonical_view(&tx_graph, chain_tip, Default::default()); /// # let indexer = KeychainTxOutIndex::<&str>::default(); /// // Get all outputs from an indexer /// for (keychain, txout) in view.filter_outpoints(indexer.outpoints().clone()) { @@ -295,7 +362,7 @@ impl CanonicalView { pub fn filter_outpoints<'v, O: Clone + 'v>( &'v self, outpoints: impl IntoIterator + 'v, - ) -> impl Iterator)> + 'v { + ) -> impl Iterator)> + 'v { outpoints .into_iter() .filter_map(|(op_i, op)| Some((op_i, self.txout(op)?))) @@ -309,12 +376,13 @@ impl CanonicalView { /// # Example /// /// ``` - /// # use bdk_chain::{CanonicalView, TxGraph, local_chain::LocalChain, keychain_txout::KeychainTxOutIndex}; + /// # use bdk_chain::{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 chain_tip = chain.tip().block_id(); + /// # let view = chain.canonical_view(&tx_graph, chain_tip, Default::default()); /// # let indexer = KeychainTxOutIndex::<&str>::default(); /// // Get unspent outputs (UTXOs) from an indexer /// for (keychain, utxo) in view.filter_unspent_outpoints(indexer.outpoints().clone()) { @@ -324,11 +392,40 @@ impl CanonicalView { pub fn filter_unspent_outpoints<'v, O: Clone + 'v>( &'v self, outpoints: impl IntoIterator + 'v, - ) -> impl Iterator)> + 'v { + ) -> impl Iterator)> + 'v { self.filter_outpoints(outpoints) .filter(|(_, txo)| txo.spent_by.is_none()) } + /// 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() + }) + } +} + +impl CanonicalView { /// Calculate the total balance of the given outpoints. /// /// This method computes a detailed balance breakdown for a set of outpoints, categorizing @@ -355,12 +452,13 @@ impl CanonicalView { /// # Example /// /// ``` - /// # use bdk_chain::{CanonicalView, TxGraph, local_chain::LocalChain, keychain_txout::KeychainTxOutIndex}; + /// # use bdk_chain::{CanonicalParams, 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 chain_tip = chain.tip().block_id(); + /// # let view = chain.canonical_view(&tx_graph, chain_tip, CanonicalParams::default()); /// # let indexer = KeychainTxOutIndex::<&str>::default(); /// // Calculate balance with 6 confirmations, trusting all outputs /// let balance = view.balance( @@ -372,7 +470,7 @@ impl CanonicalView { pub fn balance<'v, O: Clone + 'v>( &'v self, outpoints: impl IntoIterator + 'v, - mut trust_predicate: impl FnMut(&O, &FullTxOut) -> bool, + mut trust_predicate: impl FnMut(&O, &CanonicalTxOut>) -> bool, min_confirmations: u32, ) -> Balance { let mut immature = Amount::ZERO; @@ -381,7 +479,7 @@ impl CanonicalView { let mut confirmed = Amount::ZERO; for (spk_i, txout) in self.filter_unspent_outpoints(outpoints) { - match &txout.chain_position { + match &txout.pos { ChainPosition::Confirmed { anchor, .. } => { let confirmation_height = anchor.confirmation_height_upper_bound(); let confirmations = self @@ -421,31 +519,25 @@ impl CanonicalView { confirmed, } } +} - /// List transaction IDs that are expected to exist for the given script pubkeys. +impl CanonicalTxs { + /// Creates a [`CanonicalViewTask`] that resolves [`CanonicalReason`]s into [`ChainPosition`]s. /// - /// 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() - }) + /// This is the second phase of the canonicalization pipeline. Blocks fetched during + /// phase 1 are passed through so they can be reused without redundant queries. + pub fn view_task<'g, B>( + self, + tx_graph: &'g TxGraph, + queries: BlockQueries, + ) -> CanonicalViewTask<'g, A, B> { + CanonicalViewTask::new( + tx_graph, + self.tip, + self.order, + self.txs, + self.spends, + queries, + ) } } diff --git a/crates/chain/src/canonical_iter.rs b/crates/chain/src/canonical_iter.rs deleted file mode 100644 index 204ead451..000000000 --- a/crates/chain/src/canonical_iter.rs +++ /dev/null @@ -1,344 +0,0 @@ -use crate::collections::{HashMap, HashSet, VecDeque}; -use crate::tx_graph::{TxAncestors, TxDescendants}; -use crate::{Anchor, ChainOracle, TxGraph}; -use alloc::boxed::Box; -use alloc::collections::BTreeSet; -use alloc::sync::Arc; -use alloc::vec::Vec; -use bdk_core::BlockId; -use bitcoin::{Transaction, Txid}; - -type CanonicalMap = HashMap, CanonicalReason)>; -type NotCanonicalSet = HashSet; - -/// Modifies the canonicalization algorithm. -#[derive(Debug, Default, Clone)] -pub struct CanonicalizationParams { - /// Transactions that will supercede all other transactions. - /// - /// In case of conflicting transactions within `assume_canonical`, transactions that appear - /// later in the list (have higher index) have precedence. - pub assume_canonical: Vec, -} - -/// Iterates over canonical txs. -pub struct CanonicalIter<'g, A, C> { - tx_graph: &'g TxGraph, - chain: &'g C, - chain_tip: BlockId, - - unprocessed_assumed_txs: Box)> + 'g>, - unprocessed_anchored_txs: - Box, &'g BTreeSet)> + 'g>, - unprocessed_seen_txs: Box, u64)> + 'g>, - unprocessed_leftover_txs: VecDeque<(Txid, Arc, u32)>, - - canonical: CanonicalMap, - not_canonical: NotCanonicalSet, - - queue: VecDeque, -} - -impl<'g, A: Anchor, C: ChainOracle> CanonicalIter<'g, A, C> { - /// Constructs [`CanonicalIter`]. - pub fn new( - tx_graph: &'g TxGraph, - chain: &'g C, - chain_tip: BlockId, - params: CanonicalizationParams, - ) -> Self { - let anchors = tx_graph.all_anchors(); - let unprocessed_assumed_txs = Box::new( - params - .assume_canonical - .into_iter() - .rev() - .filter_map(|txid| Some((txid, tx_graph.get_tx(txid)?))), - ); - let unprocessed_anchored_txs = Box::new( - tx_graph - .txids_by_descending_anchor_height() - .filter_map(|(_, txid)| Some((txid, tx_graph.get_tx(txid)?, anchors.get(&txid)?))), - ); - let unprocessed_seen_txs = Box::new( - tx_graph - .txids_by_descending_last_seen() - .filter_map(|(last_seen, txid)| Some((txid, tx_graph.get_tx(txid)?, last_seen))), - ); - Self { - tx_graph, - chain, - chain_tip, - unprocessed_assumed_txs, - unprocessed_anchored_txs, - unprocessed_seen_txs, - unprocessed_leftover_txs: VecDeque::new(), - canonical: HashMap::new(), - not_canonical: HashSet::new(), - queue: VecDeque::new(), - } - } - - /// Whether this transaction is already canonicalized. - fn is_canonicalized(&self, txid: Txid) -> bool { - self.canonical.contains_key(&txid) || self.not_canonical.contains(&txid) - } - - /// Mark transaction as canonical if it is anchored in the best chain. - fn scan_anchors( - &mut self, - txid: Txid, - tx: Arc, - anchors: &BTreeSet, - ) -> Result<(), C::Error> { - for anchor in anchors { - let in_chain_opt = self - .chain - .is_block_in_chain(anchor.anchor_block(), self.chain_tip)?; - if in_chain_opt == Some(true) { - self.mark_canonical(txid, tx, CanonicalReason::from_anchor(anchor.clone())); - return Ok(()); - } - } - // cannot determine - self.unprocessed_leftover_txs.push_back(( - txid, - tx, - anchors - .iter() - .last() - .expect( - "tx taken from `unprocessed_txs_with_anchors` so it must atleast have an anchor", - ) - .confirmation_height_upper_bound(), - )); - Ok(()) - } - - /// Marks `tx` and it's ancestors as canonical and mark all conflicts of these as - /// `not_canonical`. - /// - /// The exception is when it is discovered that `tx` double spends itself (i.e. two of it's - /// inputs conflict with each other), then no changes will be made. - /// - /// The logic works by having two loops where one is nested in another. - /// * The outer loop iterates through ancestors of `tx` (including `tx`). We can transitively - /// assume that all ancestors of `tx` are also canonical. - /// * The inner loop loops through conflicts of ancestors of `tx`. Any descendants of conflicts - /// are also conflicts and are transitively considered non-canonical. - /// - /// If the inner loop ends up marking `tx` as non-canonical, then we know that it double spends - /// itself. - fn mark_canonical(&mut self, txid: Txid, tx: Arc, reason: CanonicalReason) { - let starting_txid = txid; - let mut is_starting_tx = true; - - // We keep track of changes made so far so that we can undo it later in case we detect that - // `tx` double spends itself. - let mut detected_self_double_spend = false; - let mut undo_not_canonical = Vec::::new(); - - // `staged_queue` doubles as the `undo_canonical` data. - let staged_queue = TxAncestors::new_include_root( - self.tx_graph, - tx, - |_: usize, tx: Arc| -> Option { - let this_txid = tx.compute_txid(); - let this_reason = if is_starting_tx { - is_starting_tx = false; - reason.clone() - } else { - reason.to_transitive(starting_txid) - }; - - use crate::collections::hash_map::Entry; - let canonical_entry = match self.canonical.entry(this_txid) { - // Already visited tx before, exit early. - Entry::Occupied(_) => return None, - Entry::Vacant(entry) => entry, - }; - - // Any conflicts with a canonical tx can be added to `not_canonical`. Descendants - // of `not_canonical` txs can also be added to `not_canonical`. - for (_, conflict_txid) in self.tx_graph.direct_conflicts(&tx) { - TxDescendants::new_include_root( - self.tx_graph, - conflict_txid, - |_: usize, txid: Txid| -> Option<()> { - if self.not_canonical.insert(txid) { - undo_not_canonical.push(txid); - Some(()) - } else { - None - } - }, - ) - .run_until_finished() - } - - if self.not_canonical.contains(&this_txid) { - // Early exit if self-double-spend is detected. - detected_self_double_spend = true; - return None; - } - canonical_entry.insert((tx, this_reason)); - Some(this_txid) - }, - ) - .collect::>(); - - if detected_self_double_spend { - for txid in staged_queue { - self.canonical.remove(&txid); - } - for txid in undo_not_canonical { - self.not_canonical.remove(&txid); - } - } else { - self.queue.extend(staged_queue); - } - } -} - -impl Iterator for CanonicalIter<'_, A, C> { - type Item = Result<(Txid, Arc, CanonicalReason), C::Error>; - - fn next(&mut self) -> Option { - loop { - if let Some(txid) = self.queue.pop_front() { - let (tx, reason) = self - .canonical - .get(&txid) - .cloned() - .expect("reason must exist"); - return Some(Ok((txid, tx, reason))); - } - - if let Some((txid, tx)) = self.unprocessed_assumed_txs.next() { - if !self.is_canonicalized(txid) { - self.mark_canonical(txid, tx, CanonicalReason::assumed()); - } - } - - if let Some((txid, tx, anchors)) = self.unprocessed_anchored_txs.next() { - if !self.is_canonicalized(txid) { - if let Err(err) = self.scan_anchors(txid, tx, anchors) { - return Some(Err(err)); - } - } - continue; - } - - if let Some((txid, tx, last_seen)) = self.unprocessed_seen_txs.next() { - debug_assert!( - !tx.is_coinbase(), - "Coinbase txs must not have `last_seen` (in mempool) value" - ); - if !self.is_canonicalized(txid) { - let observed_in = ObservedIn::Mempool(last_seen); - self.mark_canonical(txid, tx, CanonicalReason::from_observed_in(observed_in)); - } - continue; - } - - if let Some((txid, tx, height)) = self.unprocessed_leftover_txs.pop_front() { - if !self.is_canonicalized(txid) && !tx.is_coinbase() { - let observed_in = ObservedIn::Block(height); - self.mark_canonical(txid, tx, CanonicalReason::from_observed_in(observed_in)); - } - continue; - } - - return None; - } - } -} - -/// Represents when and where a transaction was last observed in. -#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] -pub enum ObservedIn { - /// The transaction was last observed in a block of height. - Block(u32), - /// The transaction was last observed in the mempool at the given unix timestamp. - Mempool(u64), -} - -/// The reason why a transaction is canonical. -#[derive(Debug, Clone, PartialEq, Eq)] -pub enum CanonicalReason { - /// This transaction is explicitly assumed to be canonical by the caller, superceding all other - /// canonicalization rules. - Assumed { - /// Whether it is a descendant that is assumed to be canonical. - descendant: Option, - }, - /// This transaction is anchored in the best chain by `A`, and therefore canonical. - Anchor { - /// The anchor that anchored the transaction in the chain. - anchor: A, - /// Whether the anchor is of the transaction's descendant. - descendant: Option, - }, - /// This transaction does not conflict with any other transaction with a more recent - /// [`ObservedIn`] value or one that is anchored in the best chain. - ObservedIn { - /// The [`ObservedIn`] value of the transaction. - observed_in: ObservedIn, - /// Whether the [`ObservedIn`] value is of the transaction's descendant. - descendant: Option, - }, -} - -impl CanonicalReason { - /// Constructs a [`CanonicalReason`] for a transaction that is assumed to supercede all other - /// transactions. - pub fn assumed() -> Self { - Self::Assumed { descendant: None } - } - - /// Constructs a [`CanonicalReason`] from an `anchor`. - pub fn from_anchor(anchor: A) -> Self { - Self::Anchor { - anchor, - descendant: None, - } - } - - /// Constructs a [`CanonicalReason`] from an `observed_in` value. - pub fn from_observed_in(observed_in: ObservedIn) -> Self { - Self::ObservedIn { - observed_in, - descendant: None, - } - } - - /// Contruct a new [`CanonicalReason`] from the original which is transitive to `descendant`. - /// - /// This signals that either the [`ObservedIn`] or [`Anchor`] value belongs to the transaction's - /// descendant, but is transitively relevant. - pub fn to_transitive(&self, descendant: Txid) -> Self { - match self { - CanonicalReason::Assumed { .. } => Self::Assumed { - descendant: Some(descendant), - }, - CanonicalReason::Anchor { anchor, .. } => Self::Anchor { - anchor: anchor.clone(), - descendant: Some(descendant), - }, - CanonicalReason::ObservedIn { observed_in, .. } => Self::ObservedIn { - observed_in: *observed_in, - descendant: Some(descendant), - }, - } - } - - /// This signals that either the [`ObservedIn`] or [`Anchor`] value belongs to the transaction's - /// descendant. - pub fn descendant(&self) -> &Option { - match self { - CanonicalReason::Assumed { descendant, .. } => descendant, - CanonicalReason::Anchor { descendant, .. } => descendant, - CanonicalReason::ObservedIn { descendant, .. } => descendant, - } - } -} diff --git a/crates/chain/src/canonical_task.rs b/crates/chain/src/canonical_task.rs new file mode 100644 index 000000000..abff856f9 --- /dev/null +++ b/crates/chain/src/canonical_task.rs @@ -0,0 +1,590 @@ +use crate::collections::{HashMap, HashSet, VecDeque}; +use crate::tx_graph::{TxAncestors, TxDescendants}; +use crate::{canonical::CanonicalEntry, Anchor, CanonicalTxs, TxGraph}; +use alloc::boxed::Box; +use alloc::collections::BTreeSet; +use alloc::sync::Arc; +use alloc::vec::Vec; +use bdk_core::{BlockId, BlockQueries, ChainQuery, TaskProgress, ToBlockHash}; +use bitcoin::{Transaction, Txid}; + +type CanonicalMap = HashMap>>; +type NotCanonicalSet = HashSet; + +/// Represents the current stage of canonicalization processing. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +enum CanonicalStage { + /// Processing transctions assumed to be canonical. + #[default] + AssumedTxs, + /// Processing directly anchored transactions. + AnchoredTxs, + /// Processing transactions seen in mempool. + SeenTxs, + /// Processing leftover transactions. + LeftOverTxs, + /// All processing is complete. + Finished, +} + +impl CanonicalStage { + fn next_stage(&mut self) { + *self = match self { + CanonicalStage::AssumedTxs => Self::AnchoredTxs, + CanonicalStage::AnchoredTxs => Self::SeenTxs, + CanonicalStage::SeenTxs => Self::LeftOverTxs, + CanonicalStage::LeftOverTxs => Self::Finished, + CanonicalStage::Finished => Self::Finished, + }; + } +} + +/// Modifies the canonicalization algorithm. +#[derive(Debug, Default, Clone)] +pub struct CanonicalParams { + /// Transactions that will supersede all other transactions. + /// + /// In case of conflicting transactions within `assume_canonical`, transactions that appear + /// later in the list (have higher index) have precedence. + pub assume_canonical: Vec, +} + +/// Determines which transactions are canonical without resolving chain positions. +/// +/// This task implements the first phase of canonicalization: it walks the transaction +/// graph and determines which transactions are canonical (non-conflicting) and why +/// (via [`CanonicalReason`](crate::CanonicalReason)). The output is a [`CanonicalTxs`] which can +/// then be further processed by [`CanonicalViewTask`](crate::CanonicalViewTask) to resolve reasons +/// into [`ChainPosition`](crate::ChainPosition)s. +pub struct CanonicalTask<'g, A, B> { + tx_graph: &'g TxGraph, + chain_tip: BlockId, + + queries: BlockQueries, + + unprocessed_assumed_txs: Box)> + 'g>, + unprocessed_anchored_txs: VecDeque<(Txid, Arc, &'g BTreeSet)>, + unprocessed_seen_txs: Box, u64)> + 'g>, + unprocessed_leftover_txs: VecDeque<(Txid, Arc, u32)>, + + canonical: CanonicalMap, + not_canonical: NotCanonicalSet, + + // Store canonical transactions in order + canonical_order: Vec, + + // Track the current stage of processing + current_stage: CanonicalStage, +} + +impl<'g, A: Anchor, B: ToBlockHash> ChainQuery for CanonicalTask<'g, A, B> { + type Output = (CanonicalTxs, BlockQueries); + + fn tip(&self) -> BlockId { + self.chain_tip + } + + fn unresolved_queries<'a>(&'a self) -> impl Iterator + 'a { + self.queries.unresolved() + } + + fn poll(&mut self) -> TaskProgress { + match self.current_stage { + CanonicalStage::AssumedTxs => { + if let Some((txid, tx)) = self.unprocessed_assumed_txs.next() { + if !self.is_canonicalized(txid) { + self.mark_canonical(txid, tx, CanonicalReason::assumed()); + } + } else { + self.current_stage.next_stage(); + } + TaskProgress::Advanced + } + CanonicalStage::AnchoredTxs => { + if let Some((txid, tx, anchors)) = self.unprocessed_anchored_txs.pop_front() { + if self.is_canonicalized(txid) { + return TaskProgress::Advanced; + } + + let mut best_anchor = Option::::None; + let mut has_unresolved_heights = false; + + for a in anchors.iter() { + let h = a.anchor_block().height; + match self.queries.get(h) { + Some(Some(b)) if b.to_blockhash() == a.anchor_block().hash => { + best_anchor = Some(a.clone()); + break; + } + None => has_unresolved_heights = true, + // Either the block does not match the anchor, or the chain source + // failed to fetch a block at this height. + _ => {} + } + } + + if let Some(a) = best_anchor { + self.mark_canonical(txid, tx, CanonicalReason::from_anchor(a)); + return TaskProgress::Advanced; + } + + if has_unresolved_heights { + let heights = self + .queries + .request(anchors.iter().map(|a| a.anchor_block().height)); + self.unprocessed_anchored_txs + .push_front((txid, tx, anchors)); + return TaskProgress::Query(heights); + } + + // No confirmed anchor found. + self.unprocessed_leftover_txs.push_back(( + txid, + tx, + anchors + .iter() + .last() + .expect("must have at least one anchor") + .confirmation_height_upper_bound(), + )); + } else { + self.current_stage.next_stage(); + } + TaskProgress::Advanced + } + CanonicalStage::SeenTxs => { + if let Some((txid, tx, last_seen)) = self.unprocessed_seen_txs.next() { + debug_assert!( + !tx.is_coinbase(), + "Coinbase txs must not have `last_seen` (in mempool) value" + ); + if !self.is_canonicalized(txid) { + let observed_in = ObservedIn::Mempool(last_seen); + self.mark_canonical( + txid, + tx, + CanonicalReason::from_observed_in(observed_in), + ); + } + } else { + self.current_stage.next_stage(); + } + TaskProgress::Advanced + } + CanonicalStage::LeftOverTxs => { + if let Some((txid, tx, height)) = self.unprocessed_leftover_txs.pop_front() { + if !self.is_canonicalized(txid) && !tx.is_coinbase() { + let observed_in = ObservedIn::Block(height); + self.mark_canonical( + txid, + tx, + CanonicalReason::from_observed_in(observed_in), + ); + } + } else { + self.current_stage.next_stage(); + } + TaskProgress::Advanced + } + CanonicalStage::Finished => TaskProgress::Done, + } + } + + fn resolve_query(&mut self, height: u32, block: Option) { + self.queries.resolve(height, block); + } + + fn finish(self) -> Self::Output { + let mut view_spends = HashMap::new(); + + for txid in &self.canonical_order { + if let Some(entry) = self.canonical.get(txid) { + if !entry.tx.is_coinbase() { + for input in &entry.tx.input { + view_spends.insert(input.previous_output, *txid); + } + } + } + } + + ( + CanonicalTxs::new( + self.chain_tip, + self.canonical_order, + self.canonical, + view_spends, + None, + ), + self.queries, + ) + } +} + +impl<'g, A: Anchor, B: ToBlockHash> CanonicalTask<'g, A, B> { + /// Creates a new canonicalization task. + pub fn new(tx_graph: &'g TxGraph, chain_tip: BlockId, params: CanonicalParams) -> Self { + let anchors = tx_graph.all_anchors(); + let unprocessed_assumed_txs = Box::new( + params + .assume_canonical + .into_iter() + .rev() + .filter_map(|txid| Some((txid, tx_graph.get_tx(txid)?))), + ); + let unprocessed_anchored_txs: VecDeque<_> = tx_graph + .txids_by_descending_anchor_height() + .filter_map(|(_, txid)| Some((txid, tx_graph.get_tx(txid)?, anchors.get(&txid)?))) + .collect(); + let unprocessed_seen_txs = Box::new( + tx_graph + .txids_by_descending_last_seen() + .filter_map(|(last_seen, txid)| Some((txid, tx_graph.get_tx(txid)?, last_seen))), + ); + + Self { + tx_graph, + chain_tip, + + unprocessed_assumed_txs, + unprocessed_anchored_txs, + unprocessed_seen_txs, + unprocessed_leftover_txs: VecDeque::new(), + + canonical: HashMap::new(), + not_canonical: HashSet::new(), + + canonical_order: Vec::new(), + current_stage: CanonicalStage::default(), + + queries: BlockQueries::new(), + } + } + + fn is_canonicalized(&self, txid: Txid) -> bool { + self.canonical.contains_key(&txid) || self.not_canonical.contains(&txid) + } + + fn mark_canonical(&mut self, txid: Txid, tx: Arc, reason: CanonicalReason) { + let starting_txid = txid; + let mut is_starting_tx = true; + + // We keep track of changes made so far so that we can undo it later in case we detect that + // `tx` double spends itself. + let mut detected_self_double_spend = false; + let mut undo_not_canonical = Vec::::new(); + let mut staged_canonical = Vec::<(Txid, Arc, CanonicalReason)>::new(); + + // Process ancestors + TxAncestors::new_include_root( + self.tx_graph, + tx, + |_: usize, tx: Arc| -> Option { + let this_txid = tx.compute_txid(); + let this_reason = if is_starting_tx { + is_starting_tx = false; + reason.clone() + } else { + // This is an ancestor being marked transitively + reason.to_transitive(starting_txid) + }; + + use crate::collections::hash_map::Entry; + let canonical_entry = match self.canonical.entry(this_txid) { + // Already visited tx before, exit early. + Entry::Occupied(_) => return None, + Entry::Vacant(entry) => entry, + }; + + // Any conflicts with a canonical tx can be added to `not_canonical`. Descendants + // of `not_canonical` txs can also be added to `not_canonical`. + for (_, conflict_txid) in self.tx_graph.direct_conflicts(&tx) { + TxDescendants::new_include_root( + self.tx_graph, + conflict_txid, + |_: usize, txid: Txid| -> Option<()> { + if self.not_canonical.insert(txid) { + undo_not_canonical.push(txid); + Some(()) + } else { + None + } + }, + ) + .run_until_finished() + } + + if self.not_canonical.contains(&this_txid) { + // Early exit if self-double-spend is detected. + detected_self_double_spend = true; + return None; + } + + staged_canonical.push((this_txid, tx.clone(), this_reason.clone())); + canonical_entry.insert(CanonicalEntry { + tx: tx.clone(), + pos: this_reason, + mtp: None, + }); + Some(this_txid) + }, + ) + .run_until_finished(); + + if detected_self_double_spend { + // Undo changes + for (txid, _, _) in staged_canonical { + self.canonical.remove(&txid); + } + for txid in undo_not_canonical { + self.not_canonical.remove(&txid); + } + return; + } + + // Add to canonical order + for (txid, _, _) in &staged_canonical { + self.canonical_order.push(*txid); + } + } +} + +/// Represents when and where a transaction was last observed in. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] +pub enum ObservedIn { + /// The transaction was last observed in a block of height. + Block(u32), + /// The transaction was last observed in the mempool at the given unix timestamp. + Mempool(u64), +} + +/// The reason why a transaction is canonical. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum CanonicalReason { + /// This transaction is explicitly assumed to be canonical by the caller, superceding all other + /// canonicalization rules. + Assumed { + /// Whether it is a descendant that is assumed to be canonical. + descendant: Option, + }, + /// This transaction is anchored in the best chain by `A`, and therefore canonical. + Anchor { + /// The anchor that anchored the transaction in the chain. + anchor: A, + /// Whether the anchor is of the transaction's descendant. + descendant: Option, + }, + /// This transaction does not conflict with any other transaction with a more recent + /// [`ObservedIn`] value or one that is anchored in the best chain. + ObservedIn { + /// The [`ObservedIn`] value of the transaction. + observed_in: ObservedIn, + /// Whether the [`ObservedIn`] value is of the transaction's descendant. + descendant: Option, + }, +} + +impl CanonicalReason { + /// Constructs a [`CanonicalReason`] for a transaction that is assumed to supercede all other + /// transactions. + pub fn assumed() -> Self { + Self::Assumed { descendant: None } + } + + /// Constructs a [`CanonicalReason`] from an `anchor`. + pub fn from_anchor(anchor: A) -> Self { + Self::Anchor { + anchor, + descendant: None, + } + } + + /// Constructs a [`CanonicalReason`] from an `observed_in` value. + pub fn from_observed_in(observed_in: ObservedIn) -> Self { + Self::ObservedIn { + observed_in, + descendant: None, + } + } + + /// Contruct a new [`CanonicalReason`] from the original which is transitive to `descendant`. + /// + /// This signals that either the [`ObservedIn`] or [`Anchor`] value belongs to the transaction's + /// descendant, but is transitively relevant. + pub fn to_transitive(&self, descendant: Txid) -> Self { + match self { + CanonicalReason::Assumed { .. } => Self::Assumed { + descendant: Some(descendant), + }, + CanonicalReason::Anchor { anchor, .. } => Self::Anchor { + anchor: anchor.clone(), + descendant: Some(descendant), + }, + CanonicalReason::ObservedIn { observed_in, .. } => Self::ObservedIn { + observed_in: *observed_in, + descendant: Some(descendant), + }, + } + } + + /// This signals that either the [`ObservedIn`] or [`Anchor`] value belongs to the transaction's + /// descendant. + pub fn descendant(&self) -> &Option { + match self { + CanonicalReason::Assumed { descendant, .. } => descendant, + CanonicalReason::Anchor { descendant, .. } => descendant, + CanonicalReason::ObservedIn { descendant, .. } => descendant, + } + } + + /// Returns true if this reason represents a transitive canonicalization + /// (i.e., the transaction is canonical because of its descendant). + pub fn is_transitive(&self) -> bool { + self.descendant().is_some() + } + + /// Returns true if this reason is [`CanonicalReason::Assumed`]. + pub fn is_assumed(&self) -> bool { + matches!(self, CanonicalReason::Assumed { .. }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::collections::BTreeMap; + use crate::local_chain::LocalChain; + use crate::ChainPosition; + use bitcoin::{hashes::Hash, BlockHash, TxIn, TxOut}; + + #[test] + fn test_canonicalization_task_sans_io() { + // Create a simple chain + let blocks = [ + (0, BlockHash::all_zeros()), + (1, BlockHash::from_byte_array([1; 32])), + (2, BlockHash::from_byte_array([2; 32])), + ]; + let chain = LocalChain::from_blocks(blocks.into_iter().collect()).unwrap(); + let chain_tip = chain.tip().block_id(); + + // Create a simple transaction graph + let mut tx_graph = TxGraph::default(); + + // Add a transaction + let tx = bitcoin::Transaction { + version: bitcoin::transaction::Version::ONE, + lock_time: bitcoin::absolute::LockTime::ZERO, + input: vec![TxIn::default()], + output: vec![TxOut { + value: bitcoin::Amount::from_sat(1000), + script_pubkey: bitcoin::ScriptBuf::new(), + }], + }; + let _ = tx_graph.insert_tx(tx.clone()); + let txid = tx.compute_txid(); + + // Add an anchor at height 1 + let anchor = crate::ConfirmationBlockTime { + block_id: chain.get(1).unwrap().block_id(), + confirmation_time: 12345, + }; + let _ = tx_graph.insert_anchor(txid, anchor); + + // Create canonicalization task and canonicalize using the two-step pipeline + let params = CanonicalParams::default(); + let task = CanonicalTask::new(&tx_graph, chain_tip, params); + let (txs, queries) = chain.canonicalize(task); + let view_task = txs.view_task(&tx_graph, queries); + let canonical_view = chain.canonicalize(view_task); + + // Should have one canonical transaction + assert_eq!(canonical_view.txs().len(), 1); + let canon_tx = canonical_view.txs().next().unwrap(); + assert_eq!(canon_tx.txid, txid); + assert_eq!(canon_tx.tx.compute_txid(), txid); + + // Should be confirmed (anchored) + assert!(matches!(canon_tx.pos, ChainPosition::Confirmed { .. })); + } + + #[test] + fn test_mtp_with_header_chain() { + use bitcoin::block::Header; + use bitcoin::CompactTarget; + + // Helper to create a header with a specific prev_blockhash and time + fn make_header(prev_blockhash: BlockHash, time: u32) -> Header { + Header { + version: bitcoin::block::Version::ONE, + prev_blockhash, + merkle_root: bitcoin::TxMerkleNode::all_zeros(), + time, + bits: CompactTarget::from_consensus(0x2000_0000), + nonce: 0, + } + } + + // Build a chain of 12 headers (heights 0..=11) with known timestamps. + // Timestamps: height h has time = (h + 1) * 100 + let genesis_header = make_header(BlockHash::all_zeros(), 100); + let genesis_hash = genesis_header.block_hash(); + + let mut headers: BTreeMap = BTreeMap::new(); + headers.insert(0, genesis_header); + + let mut prev_hash = genesis_hash; + for h in 1u32..=11 { + let header = make_header(prev_hash, (h + 1) * 100); + prev_hash = header.block_hash(); + headers.insert(h, header); + } + + let chain = LocalChain::

::from_blocks(headers.clone()).unwrap(); + let chain_tip = chain.tip().block_id(); + + // Create a transaction graph with a tx anchored at height 5 + let mut tx_graph = TxGraph::default(); + + let tx = bitcoin::Transaction { + version: bitcoin::transaction::Version::ONE, + lock_time: bitcoin::absolute::LockTime::ZERO, + input: vec![TxIn::default()], + output: vec![TxOut { + value: bitcoin::Amount::from_sat(1000), + script_pubkey: bitcoin::ScriptBuf::new(), + }], + }; + let _ = tx_graph.insert_tx(tx.clone()); + let txid = tx.compute_txid(); + + let anchor = crate::ConfirmationBlockTime { + block_id: chain.get(5).unwrap().block_id(), + confirmation_time: 600, + }; + let _ = tx_graph.insert_anchor(txid, anchor); + + // Run the two-phase pipeline with MTP enabled + let params = CanonicalParams::default(); + let task = CanonicalTask::new(&tx_graph, chain_tip, params); + let (txs, queries) = chain.canonicalize(task); + let view_task = txs.view_task(&tx_graph, queries).with_mtp(); + let view = chain.canonicalize(view_task); + + // Should have one tx + assert_eq!(view.txs().len(), 1); + assert!(matches!( + view.txs().next().unwrap().pos, + ChainPosition::Confirmed { .. } + )); + + // Per-tx MTP at height 5: median of timestamps at heights 0..=5 + // Timestamps: 100, 200, 300, 400, 500, 600 → sorted, median = ts[len/2] = ts[3] = 400 + let canon_tx = view.txs().next().unwrap(); + assert_eq!(canon_tx.mtp, Some(400)); + + // MTP at tip height (11): median of timestamps at heights 1..=11 + // Timestamps: 200, 300, 400, 500, 600, 700, 800, 900, 1000, 1100, 1200 + // 11 elements, median = ts[5] = 700 + assert_eq!(view.tip_mtp(), Some(700)); + } +} diff --git a/crates/chain/src/canonical_view_task.rs b/crates/chain/src/canonical_view_task.rs new file mode 100644 index 000000000..b13798129 --- /dev/null +++ b/crates/chain/src/canonical_view_task.rs @@ -0,0 +1,300 @@ +//! Phase 2 task: resolves canonical reasons into chain positions. + +use crate::canonical_task::{CanonicalReason, ObservedIn}; +use crate::collections::{HashMap, VecDeque}; +use alloc::collections::BTreeSet; +use alloc::vec::Vec; + +use bdk_core::{BlockId, BlockQueries, ChainQuery, TaskProgress, ToBlockHash, ToBlockTime}; +use bitcoin::{OutPoint, Txid}; + +use crate::{canonical::CanonicalEntry, Anchor, CanonicalView, ChainPosition, TxGraph}; + +/// Represents the current stage of view task processing. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +enum ViewStage { + /// Verifying anchors for transitively anchored transactions. + #[default] + ResolvingPositions, + /// Fetching blocks needed for MTP computation. + FetchingMtpBlocks, + /// All processing is complete. + Finished, +} + +/// Resolves [`CanonicalReason`]s into [`ChainPosition`]s. +/// +/// This task implements the second phase of canonicalization: given a set of canonical +/// transactions with their reasons (from [`CanonicalTask`](crate::CanonicalTask)), it resolves each +/// reason into a concrete [`ChainPosition`] (confirmed or unconfirmed). For transitively +/// anchored transactions, it queries the chain to check if they have their own direct +/// anchors. +/// +/// When `with_mtp()` is called, this task also computes median-time-past (MTP) values +/// for confirmed heights and stores them in the resulting [`CanonicalView`]. +pub struct CanonicalViewTask<'g, A, B> { + tx_graph: &'g TxGraph, + tip: BlockId, + + queries: BlockQueries, + + canonical_order: Vec, + canonical_txs: HashMap>>, + spends: HashMap, + unprocessed_anchor_checks: VecDeque<(Txid, &'g BTreeSet)>, + direct_anchors: HashMap, + + // MTP support — `extract_time` being `Some` means MTP is enabled. + extract_time: Option u32>, + + current_stage: ViewStage, +} + +impl<'g, A: Anchor, B> CanonicalViewTask<'g, A, B> { + /// Creates a new [`CanonicalViewTask`]. + /// + /// Accepts canonical transaction data, a reference to the [`TxGraph`], and blocks + /// already fetched during phase 1 (to avoid redundant queries). + pub(crate) fn new( + tx_graph: &'g TxGraph, + tip: BlockId, + order: Vec, + txs: HashMap>>, + spends: HashMap, + queries: BlockQueries, + ) -> Self { + let all_anchors = tx_graph.all_anchors(); + + let mut unprocessed_anchor_checks = VecDeque::new(); + let mut direct_anchors = HashMap::new(); + for txid in &order { + if let Some(entry) = txs.get(txid) { + match &entry.pos { + CanonicalReason::Anchor { + anchor, + descendant: None, + } => { + // Non-transitive anchor — already resolved. + direct_anchors.insert(*txid, anchor.clone()); + } + CanonicalReason::Anchor { .. } | CanonicalReason::Assumed { .. } => { + // Transitive or assumed — needs anchor verification. + if let Some(anchors) = all_anchors.get(txid) { + unprocessed_anchor_checks.push_back((*txid, anchors)); + } + } + CanonicalReason::ObservedIn { .. } => {} + } + } + } + + Self { + tx_graph, + tip, + queries, + canonical_order: order, + canonical_txs: txs, + spends, + unprocessed_anchor_checks, + direct_anchors, + extract_time: None, + current_stage: ViewStage::default(), + } + } +} + +impl<'g, A: Anchor, B: ToBlockHash + ToBlockTime> CanonicalViewTask<'g, A, B> { + /// Enable MTP (median-time-past) computation. + /// + /// When enabled, the task will fetch additional blocks needed to compute MTP values + /// for each confirmed height and the tip height. The resulting [`CanonicalView`] will + /// have per-tx MTP on [`CanonicalTx::mtp`](crate::CanonicalTx::mtp) and the tip MTP + /// accessible via [`tip_mtp()`](crate::Canonical::tip_mtp). + pub fn with_mtp(mut self) -> Self { + self.extract_time = Some(B::to_blocktime); + self + } +} + +impl<'g, A: Anchor, B: ToBlockHash> ChainQuery for CanonicalViewTask<'g, A, B> { + type Output = CanonicalView; + + fn tip(&self) -> BlockId { + self.tip + } + + fn unresolved_queries<'a>(&'a self) -> impl Iterator + 'a { + self.queries.unresolved() + } + + fn poll(&mut self) -> TaskProgress { + match self.current_stage { + ViewStage::ResolvingPositions => { + if let Some((txid, anchors)) = self.unprocessed_anchor_checks.pop_front() { + let mut best_anchor = Option::::None; + let mut has_unresolved_heights = false; + + for a in anchors.iter() { + let h = a.anchor_block().height; + match self.queries.get(h) { + Some(Some(b)) => { + if b.to_blockhash() == a.anchor_block().hash { + best_anchor = Some(a.clone()); + break; + } + } + Some(None) => {} + None => { + has_unresolved_heights = true; + } + } + } + + if let Some(anchor) = best_anchor { + self.direct_anchors.insert(txid, anchor); + return TaskProgress::Advanced; + } + + if has_unresolved_heights { + let heights = self + .queries + .request(anchors.iter().map(|a| a.anchor_block().height)); + self.unprocessed_anchor_checks.push_front((txid, anchors)); + return TaskProgress::Query(heights); + } + + // No confirmed anchor found for this tx + TaskProgress::Advanced + } else { + self.current_stage = ViewStage::FetchingMtpBlocks; + TaskProgress::Advanced + } + } + ViewStage::FetchingMtpBlocks => { + if self.extract_time.is_none() { + self.current_stage = ViewStage::Finished; + return TaskProgress::Advanced; + } + + // Collect all MTP heights needed. + let mut mtp_heights = BTreeSet::new(); + mtp_heights.insert(self.tip.height); + for anchor in self.direct_anchors.values() { + mtp_heights.insert(anchor.confirmation_height_upper_bound()); + } + + let needed = self + .queries + .request(mtp_heights.iter().flat_map(|&h| h.saturating_sub(10)..=h)); + + if needed.is_empty() { + self.current_stage = ViewStage::Finished; + return TaskProgress::Advanced; + } + + TaskProgress::Query(needed) + } + ViewStage::Finished => TaskProgress::Done, + } + } + + fn resolve_query(&mut self, height: u32, block: Option) { + self.queries.resolve(height, block); + } + + fn finish(self) -> Self::Output { + // Helper: compute MTP for a given height from the blocks map. + let compute_mtp_at = |h: u32, f: fn(&B) -> u32| -> Option { + let start = h.saturating_sub(10); + let mut ts: Vec = (start..=h) + .map(|mtp_h| self.queries.get(mtp_h).and_then(|b| b.as_ref()).map(f)) + .collect::>>()?; + ts.sort_unstable(); + Some(ts[ts.len() / 2]) + }; + + let extract_time = self.extract_time; + + // Compute tip MTP. + let tip_mtp = extract_time.and_then(|f| compute_mtp_at(self.tip.height, f)); + + let mut view_order = Vec::new(); + let mut view_txs = HashMap::new(); + + for txid in &self.canonical_order { + if let Some(CanonicalEntry { + tx, pos: reason, .. + }) = self.canonical_txs.get(txid) + { + view_order.push(*txid); + + // Get transaction node for first_seen/last_seen info + let tx_node = match self.tx_graph.get_tx_node(*txid) { + Some(tx_node) => tx_node, + None => { + debug_assert!(false, "tx node must exist!"); + continue; + } + }; + + // Determine chain position and per-tx MTP. For confirmed txs, prefer + // direct_anchors (which includes both non-transitive anchors and resolved + // anchors for transitive/assumed txs). + let (chain_position, mtp) = if let Some(anchor) = self.direct_anchors.get(txid) { + let mtp = extract_time + .and_then(|f| compute_mtp_at(anchor.confirmation_height_upper_bound(), f)); + ( + ChainPosition::Confirmed { + anchor, + transitively: None, + }, + mtp, + ) + } else { + match reason { + CanonicalReason::Anchor { anchor, descendant } => { + let mtp = extract_time.and_then(|f| { + compute_mtp_at(anchor.confirmation_height_upper_bound(), f) + }); + ( + ChainPosition::Confirmed { + anchor, + transitively: *descendant, + }, + mtp, + ) + } + CanonicalReason::ObservedIn { + observed_in: ObservedIn::Mempool(last_seen), + .. + } => ( + ChainPosition::Unconfirmed { + first_seen: tx_node.first_seen, + last_seen: Some(*last_seen), + }, + None, + ), + _ => ( + ChainPosition::Unconfirmed { + first_seen: tx_node.first_seen, + last_seen: tx_node.last_seen, + }, + None, + ), + } + }; + + view_txs.insert( + *txid, + CanonicalEntry { + tx: tx.clone(), + pos: chain_position.cloned(), + mtp, + }, + ); + } + } + + CanonicalView::new(self.tip, view_order, view_txs, self.spends.clone(), tip_mtp) + } +} diff --git a/crates/chain/src/chain_data.rs b/crates/chain/src/chain_data.rs index 43d41e2ed..7ec88fba8 100644 --- a/crates/chain/src/chain_data.rs +++ b/crates/chain/src/chain_data.rs @@ -1,4 +1,4 @@ -use bitcoin::{constants::COINBASE_MATURITY, OutPoint, TxOut, Txid}; +use bitcoin::Txid; use crate::Anchor; @@ -161,100 +161,6 @@ impl PartialOrd for ChainPosition { } } -/// A `TxOut` with as much data as we can retrieve about it -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct FullTxOut { - /// The position of the transaction in `outpoint` in the overall chain. - pub chain_position: ChainPosition, - /// The location of the `TxOut`. - pub outpoint: OutPoint, - /// The `TxOut`. - pub txout: TxOut, - /// The txid and chain position of the transaction (if any) that has spent this output. - pub spent_by: Option<(ChainPosition, Txid)>, - /// Whether this output is on a coinbase transaction. - pub is_on_coinbase: bool, -} - -impl Ord for FullTxOut { - fn cmp(&self, other: &Self) -> core::cmp::Ordering { - self.chain_position - .cmp(&other.chain_position) - // Tie-break with `outpoint` and `spent_by`. - .then_with(|| self.outpoint.cmp(&other.outpoint)) - .then_with(|| self.spent_by.cmp(&other.spent_by)) - } -} - -impl PartialOrd for FullTxOut { - fn partial_cmp(&self, other: &Self) -> Option { - Some(self.cmp(other)) - } -} - -impl FullTxOut { - /// Whether the `txout` is considered mature. - /// - /// Depending on the implementation of [`confirmation_height_upper_bound`] in [`Anchor`], this - /// method may return false-negatives. In other words, interpreted confirmation count may be - /// less than the actual value. - /// - /// [`confirmation_height_upper_bound`]: Anchor::confirmation_height_upper_bound - pub fn is_mature(&self, tip: u32) -> bool { - if self.is_on_coinbase { - let conf_height = match self.chain_position.confirmation_height_upper_bound() { - Some(height) => height, - None => { - debug_assert!(false, "coinbase tx can never be unconfirmed"); - return false; - } - }; - let age = tip.saturating_sub(conf_height); - if age + 1 < COINBASE_MATURITY { - return false; - } - } - - true - } - - /// Whether the utxo is/was/will be spendable with chain `tip`. - /// - /// This method does not take into account the lock time. - /// - /// Depending on the implementation of [`confirmation_height_upper_bound`] in [`Anchor`], this - /// method may return false-negatives. In other words, interpreted confirmation count may be - /// less than the actual value. - /// - /// [`confirmation_height_upper_bound`]: Anchor::confirmation_height_upper_bound - pub fn is_confirmed_and_spendable(&self, tip: u32) -> bool { - if !self.is_mature(tip) { - return false; - } - - let conf_height = match self.chain_position.confirmation_height_upper_bound() { - Some(height) => height, - None => return false, - }; - if conf_height > tip { - return false; - } - - // if the spending tx is confirmed within tip height, the txout is no longer spendable - if let Some(spend_height) = self - .spent_by - .as_ref() - .and_then(|(pos, _)| pos.confirmation_height_upper_bound()) - { - if spend_height <= tip { - return false; - } - } - - true - } -} - #[cfg(test)] #[cfg_attr(coverage_nightly, coverage(off))] mod test { diff --git a/crates/chain/src/chain_oracle.rs b/crates/chain/src/chain_oracle.rs deleted file mode 100644 index 08e697ed4..000000000 --- a/crates/chain/src/chain_oracle.rs +++ /dev/null @@ -1,25 +0,0 @@ -use crate::BlockId; - -/// Represents a service that tracks the blockchain. -/// -/// The main method is [`is_block_in_chain`] which determines whether a given block of [`BlockId`] -/// is an ancestor of the `chain_tip`. -/// -/// [`is_block_in_chain`]: Self::is_block_in_chain -pub trait ChainOracle { - /// Error type. - type Error: core::fmt::Debug; - - /// Determines whether `block` of [`BlockId`] exists as an ancestor of `chain_tip`. - /// - /// If `None` is returned, it means the implementation cannot determine whether `block` exists - /// under `chain_tip`. - fn is_block_in_chain( - &self, - block: BlockId, - chain_tip: BlockId, - ) -> Result, Self::Error>; - - /// Get the best chain's chain tip. - fn get_chain_tip(&self) -> Result; -} diff --git a/crates/chain/src/indexed_tx_graph.rs b/crates/chain/src/indexed_tx_graph.rs index 9adf7ed93..02549bba7 100644 --- a/crates/chain/src/indexed_tx_graph.rs +++ b/crates/chain/src/indexed_tx_graph.rs @@ -1,14 +1,13 @@ //! Contains the [`IndexedTxGraph`] and associated types. Refer to the //! [`IndexedTxGraph`] documentation for more. -use core::{convert::Infallible, fmt::Debug}; +use core::fmt::Debug; use alloc::{sync::Arc, vec::Vec}; use bitcoin::{Block, OutPoint, Transaction, TxOut, Txid}; use crate::{ tx_graph::{self, TxGraph}, - Anchor, BlockId, CanonicalView, CanonicalizationParams, ChainOracle, Indexer, Merge, - TxPosInBlock, + Anchor, BlockId, CanonicalParams, CanonicalTask, Indexer, Merge, TxPosInBlock, }; /// A [`TxGraph`] paired with an indexer `I`, enforcing that every insertion into the graph is @@ -423,36 +422,29 @@ where } } +impl AsRef> for IndexedTxGraph { + fn as_ref(&self) -> &TxGraph { + &self.graph + } +} + impl IndexedTxGraph where A: Anchor, { - /// Returns a [`CanonicalView`]. - pub fn try_canonical_view<'a, C: ChainOracle>( - &'a self, - chain: &'a C, - chain_tip: BlockId, - params: CanonicalizationParams, - ) -> Result, C::Error> { - self.graph.try_canonical_view(chain, chain_tip, params) - } - - /// Returns a [`CanonicalView`]. + /// Creates a [`CanonicalTask`] to determine the [`CanonicalView`](crate::CanonicalView) of + /// transactions. /// - /// 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, + /// This method delegates to the underlying [`TxGraph`] to create a [`CanonicalTask`] + /// that can be used to determine which transactions are canonical based on the provided + /// parameters. The task handles the stateless canonicalization logic and can be polled + /// for anchor verification requests. + pub fn canonical_task( + &'_ self, chain_tip: BlockId, - params: CanonicalizationParams, - ) -> CanonicalView { - self.graph.canonical_view(chain, chain_tip, params) - } -} - -impl AsRef> for IndexedTxGraph { - fn as_ref(&self) -> &TxGraph { - &self.graph + params: CanonicalParams, + ) -> CanonicalTask<'_, A, B> { + self.graph.canonical_task(chain_tip, params) } } diff --git a/crates/chain/src/lib.rs b/crates/chain/src/lib.rs index be9170b1a..41ed7cc09 100644 --- a/crates/chain/src/lib.rs +++ b/crates/chain/src/lib.rs @@ -42,12 +42,12 @@ mod tx_data_traits; pub use tx_data_traits::*; pub mod tx_graph; pub use tx_graph::TxGraph; -mod chain_oracle; -pub use chain_oracle::*; -mod canonical_iter; -pub use canonical_iter::*; -mod canonical_view; -pub use canonical_view::*; +mod canonical_task; +pub use canonical_task::*; +mod canonical; +pub use canonical::*; +mod canonical_view_task; +pub use canonical_view_task::*; #[doc(hidden)] pub mod example_utils; diff --git a/crates/chain/src/local_chain.rs b/crates/chain/src/local_chain.rs index 5bfff3aa9..86eea4fa8 100644 --- a/crates/chain/src/local_chain.rs +++ b/crates/chain/src/local_chain.rs @@ -1,12 +1,11 @@ -//! The [`LocalChain`] is a local implementation of [`ChainOracle`]. +//! The [`LocalChain`] is a local chain of checkpoints. -use core::convert::Infallible; use core::fmt; use core::ops::RangeBounds; use crate::collections::BTreeMap; -use crate::{BlockId, ChainOracle, Merge}; -use bdk_core::ToBlockHash; +use crate::{Anchor, BlockId, CanonicalParams, CanonicalTask, CanonicalView, Merge, TxGraph}; +use bdk_core::{ChainQuery, TaskProgress, ToBlockHash, ToBlockTime}; pub use bdk_core::{CheckPoint, CheckPointIter}; use bitcoin::block::Header; use bitcoin::BlockHash; @@ -57,7 +56,7 @@ where Ok(init_cp) } -/// This is a local implementation of [`ChainOracle`]. +/// A local chain of checkpoints. #[derive(Debug, Clone)] pub struct LocalChain { tip: CheckPoint, @@ -69,33 +68,16 @@ impl PartialEq for LocalChain { } } -impl ChainOracle for LocalChain { - type Error = Infallible; - - fn is_block_in_chain( - &self, - block: BlockId, - chain_tip: BlockId, - ) -> Result, Self::Error> { - let chain_tip_cp = match self.tip.get(chain_tip.height) { - // we can only determine whether `block` is in chain of `chain_tip` if `chain_tip` can - // be identified in chain - Some(cp) if cp.hash() == chain_tip.hash => cp, - _ => return Ok(None), - }; - match chain_tip_cp.get(block.height) { - Some(cp) => Ok(Some(cp.hash() == block.hash)), - None => Ok(None), - } - } - - fn get_chain_tip(&self) -> Result { - Ok(self.tip.block_id()) - } -} - // Methods for `LocalChain` impl LocalChain { + /// Get the chain tip. + /// + /// # Returns + /// The [`BlockId`] of the chain tip. + pub fn chain_tip(&self) -> BlockId { + self.tip.block_id() + } + /// Update the chain with a given [`Header`] at `height` which you claim is connected to a /// existing block in the chain. /// @@ -406,6 +388,92 @@ where Ok(changeset) } + /// Check if a block is in the chain. + /// + /// # Returns + /// * `Some(true)` if the block is in the chain + /// * `Some(false)` if the block is not in the chain + /// * `None` if it cannot be determined + pub fn is_block_in_chain(&self, block: BlockId, chain_tip: BlockId) -> Option { + let chain_tip_cp = match self.tip.get(chain_tip.height) { + Some(cp) if cp.hash() == chain_tip.hash => cp, + _ => return None, + }; + chain_tip_cp + .get(block.height) + .map(|cp| cp.hash() == block.hash) + } + + /// Canonicalize a transaction graph using this chain. + /// + /// This method processes any type implementing [`ChainQuery`], handling all its requests + /// to determine which transactions are canonical, and returns the query's output. + /// The chain responds with its data type `D`, so `LocalChain
` responds with + /// headers and `LocalChain` responds with hashes. + pub fn canonicalize(&self, mut task: Q) -> Q::Output + where + Q: ChainQuery, + D: Clone, + { + loop { + match task.poll() { + TaskProgress::Advanced => continue, + TaskProgress::Done => return task.finish(), + TaskProgress::Query(heights) => { + debug_assert!(!heights.is_empty(), "TaskProgress::Query must not be empty"); + for height in heights { + let data = self + .get(height) + .filter(|cp| { + let chain_tip = task.tip(); + self.is_block_in_chain(cp.block_id(), chain_tip) == Some(true) + }) + .map(|cp| cp.data()); + task.resolve_query(height, data); + } + } + } + } + } + + /// Convenience method that runs both canonicalization phases and returns a [`CanonicalView`]. + /// + /// This is equivalent to: + /// ```ignore + /// let (txs, queries) = chain.canonicalize(CanonicalTask::new(&tx_graph, tip, params)); + /// let view = chain.canonicalize(txs.view_task(tx_graph, queries)); + /// ``` + pub fn canonical_view( + &self, + tx_graph: &TxGraph, + tip: BlockId, + params: CanonicalParams, + ) -> CanonicalView { + let (txs, queries) = self.canonicalize(CanonicalTask::new(tx_graph, tip, params)); + self.canonicalize(txs.view_task(tx_graph, queries)) + } + + /// Like [`canonical_view`](Self::canonical_view), but also computes median-time-past (MTP) + /// values for confirmed transactions and the chain tip. + /// + /// Requires `D: ToBlockTime` (e.g. `LocalChain
`). + /// + /// The resulting [`CanonicalView`] will have per-tx MTP on + /// [`CanonicalTx::mtp`](crate::CanonicalTx::mtp) and the tip MTP accessible via + /// [`tip_mtp()`](crate::Canonical::tip_mtp). + pub fn canonical_view_with_mtp( + &self, + tx_graph: &TxGraph, + tip: BlockId, + params: CanonicalParams, + ) -> CanonicalView + where + D: ToBlockTime, + { + let (txs, queries) = self.canonicalize(CanonicalTask::new(tx_graph, tip, params)); + self.canonicalize(txs.view_task(tx_graph, queries).with_mtp()) + } + fn _check_changeset_is_applied(&self, changeset: &ChangeSet) -> bool { let mut cur = self.tip.clone(); for (&exp_height, exp_data) in changeset.blocks.iter().rev() { diff --git a/crates/chain/src/tx_graph.rs b/crates/chain/src/tx_graph.rs index 44c34c2d7..f2adc7e01 100644 --- a/crates/chain/src/tx_graph.rs +++ b/crates/chain/src/tx_graph.rs @@ -21,18 +21,25 @@ //! Conflicting transactions are allowed to coexist within a [`TxGraph`]. A process called //! canonicalization is required to get a conflict-free view of transactions. //! -//! * [`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. +//! The canonicalization process uses a two-step, sans-IO approach: //! -//! 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 -//! identifies which blocks exist under a given `chain_tip`. +//! 1. **Create a canonicalization task** using [`canonical_task`](TxGraph::canonical_task): +//! ```ignore let task = tx_graph.canonical_task(params); ``` This creates a [`CanonicalTask`] +//! that encapsulates the canonicalization logic without performing any I/O operations. +//! +//! 2. **Execute the task** with a chain oracle to obtain a [`CanonicalView`]: ```ignore let view = +//! chain.canonicalize(task); ``` The chain oracle (such as +//! [`LocalChain`](crate::local_chain::LocalChain)) handles all anchor verification queries from +//! the task. +//! +//! The [`CanonicalView`] 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 view before performing these operations. +//! +//! The separation between task creation and execution (sans-IO pattern) enables: +//! * Better testability - tasks can be tested without a real chain +//! * Flexibility - different chain oracle implementations can be used +//! * Clean separation of concerns - canonicalization logic is isolated from I/O //! //! The canonicalization algorithm uses the following associated data to determine which //! transactions have precedence over others: @@ -117,24 +124,20 @@ //! assert!(changeset.is_empty()); //! ``` //! [`insert_txout`]: TxGraph::insert_txout +//! [`CanonicalView`]: crate::CanonicalView use crate::collections::*; -use crate::BlockId; -use crate::CanonicalIter; -use crate::CanonicalView; -use crate::CanonicalizationParams; -use crate::{Anchor, ChainOracle, Merge}; +use crate::CanonicalParams; +use crate::CanonicalTask; +use crate::{Anchor, BlockId, 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 bdk_core::{ConfirmationBlockTime, ToBlockHash}; use bitcoin::{Amount, OutPoint, SignedAmount, Transaction, TxOut, Txid}; use core::fmt::{self, Formatter}; -use core::{ - convert::Infallible, - ops::{Deref, RangeInclusive}, -}; +use core::ops::{Deref, RangeInclusive}; impl From> for TxUpdate { fn from(graph: TxGraph) -> Self { @@ -969,6 +972,22 @@ impl TxGraph { let _ = self.insert_evicted_at(txid, evicted_at); } } + + /// Creates a [`CanonicalTask`] to determine the [`CanonicalView`] of transactions. + /// + /// This method delegates to the underlying [`TxGraph`] to create a [`CanonicalTask`] + /// that can be used to determine which transactions are canonical based on the provided + /// parameters. The task handles the stateless canonicalization logic and can be polled + /// for anchor verification requests. + /// + /// [`CanonicalView`]: crate::CanonicalView + pub fn canonical_task( + &'_ self, + chain_tip: BlockId, + params: CanonicalParams, + ) -> CanonicalTask<'_, A, B> { + CanonicalTask::new(self, chain_tip, params) + } } impl TxGraph { @@ -997,36 +1016,6 @@ impl TxGraph { }) } - /// Returns a [`CanonicalIter`]. - pub fn canonical_iter<'a, C: ChainOracle>( - &'a self, - chain: &'a C, - chain_tip: BlockId, - params: CanonicalizationParams, - ) -> CanonicalIter<'a, A, C> { - CanonicalIter::new(self, chain, chain_tip, params) - } - - /// Returns a [`CanonicalView`]. - pub fn try_canonical_view<'a, C: ChainOracle>( - &'a self, - chain: &'a C, - chain_tip: BlockId, - params: CanonicalizationParams, - ) -> Result, C::Error> { - CanonicalView::new(self, chain, chain_tip, params) - } - - /// Returns a [`CanonicalView`]. - pub fn canonical_view<'a, C: ChainOracle>( - &'a self, - chain: &'a C, - chain_tip: BlockId, - params: CanonicalizationParams, - ) -> CanonicalView { - CanonicalView::new(self, chain, chain_tip, params).expect("infallible") - } - /// Construct a `TxGraph` from a `changeset`. pub fn from_changeset(changeset: ChangeSet) -> Self { let mut graph = Self::default(); diff --git a/crates/chain/tests/common/tx_template.rs b/crates/chain/tests/common/tx_template.rs index 29f36169a..7bdac78a5 100644 --- a/crates/chain/tests/common/tx_template.rs +++ b/crates/chain/tests/common/tx_template.rs @@ -4,7 +4,7 @@ use bdk_testenv::utils::DESCRIPTORS; use rand::distributions::{Alphanumeric, DistString}; use std::collections::HashMap; -use bdk_chain::{spk_txout::SpkTxOutIndex, tx_graph::TxGraph, Anchor, CanonicalizationParams}; +use bdk_chain::{spk_txout::SpkTxOutIndex, tx_graph::TxGraph, Anchor, CanonicalParams}; use bitcoin::{ locktime::absolute::LockTime, secp256k1::Secp256k1, transaction, Amount, OutPoint, ScriptBuf, Sequence, Transaction, TxIn, TxOut, Txid, Witness, @@ -57,7 +57,7 @@ pub struct TxTemplateEnv<'a, A> { pub tx_graph: TxGraph, pub indexer: SpkTxOutIndex, pub txid_to_name: HashMap<&'a str, Txid>, - pub canonicalization_params: CanonicalizationParams, + pub canonicalization_params: CanonicalParams, } #[allow(dead_code)] @@ -79,7 +79,7 @@ pub fn init_graph<'a, A: Anchor + Clone + 'a>( }); let mut txid_to_name = HashMap::<&'a str, Txid>::new(); - let mut canonicalization_params = CanonicalizationParams::default(); + let mut canonicalization_params = CanonicalParams::default(); for (bogus_txin_vout, tx_tmp) in tx_templates.into_iter().enumerate() { let tx = Transaction { version: transaction::Version::non_standard(0), diff --git a/crates/chain/tests/test_canonical_view.rs b/crates/chain/tests/test_canonical_view.rs index 3c0d54381..47547c16c 100644 --- a/crates/chain/tests/test_canonical_view.rs +++ b/crates/chain/tests/test_canonical_view.rs @@ -2,7 +2,7 @@ use std::collections::BTreeMap; -use bdk_chain::{local_chain::LocalChain, CanonicalizationParams, ConfirmationBlockTime, TxGraph}; +use bdk_chain::{local_chain::LocalChain, CanonicalParams, ConfirmationBlockTime, TxGraph}; use bdk_testenv::{hash, utils::new_tx}; use bitcoin::{Amount, BlockHash, OutPoint, ScriptBuf, Transaction, TxIn, TxOut}; @@ -54,8 +54,7 @@ fn test_min_confirmations_parameter() { 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()); + let canonical_view = chain.canonical_view(&tx_graph, chain_tip, CanonicalParams::default()); // Test min_confirmations = 1: Should be confirmed (has 6 confirmations) let balance_1_conf = canonical_view.balance( @@ -142,11 +141,8 @@ fn test_min_confirmations_with_untrusted_tx() { }; let _ = tx_graph.insert_anchor(txid, anchor); - let canonical_view = tx_graph.canonical_view( - &chain, - chain.tip().block_id(), - CanonicalizationParams::default(), - ); + let chain_tip = chain.tip().block_id(); + let canonical_view = chain.canonical_view(&tx_graph, chain_tip, CanonicalParams::default()); // Test with min_confirmations = 5 and untrusted predicate let balance = canonical_view.balance( @@ -263,11 +259,8 @@ fn test_min_confirmations_multiple_transactions() { ); outpoints.push(((), outpoint2)); - let canonical_view = tx_graph.canonical_view( - &chain, - chain.tip().block_id(), - CanonicalizationParams::default(), - ); + let chain_tip = chain.tip().block_id(); + let canonical_view = chain.canonical_view(&tx_graph, chain_tip, CanonicalParams::default()); // Test with min_confirmations = 5 // tx0: 11 confirmations -> confirmed diff --git a/crates/chain/tests/test_indexed_tx_graph.rs b/crates/chain/tests/test_indexed_tx_graph.rs index 18d1ff1bf..8129a8033 100644 --- a/crates/chain/tests/test_indexed_tx_graph.rs +++ b/crates/chain/tests/test_indexed_tx_graph.rs @@ -10,7 +10,7 @@ use bdk_chain::{ indexer::keychain_txout::KeychainTxOutIndex, local_chain::LocalChain, spk_txout::SpkTxOutIndex, - tx_graph, Balance, CanonicalizationParams, ChainPosition, ConfirmationBlockTime, DescriptorExt, + tx_graph, Balance, CanonicalParams, ChainPosition, ConfirmationBlockTime, DescriptorExt, SpkIterator, }; use bdk_testenv::{ @@ -305,7 +305,7 @@ fn insert_relevant_txs() { } /// Ensure consistency IndexedTxGraph list_* and balance methods. These methods lists -/// relevant txouts and utxos from the information fetched from a ChainOracle (here a LocalChain). +/// relevant txouts and utxos from the information fetched from a LocalChain. /// /// Test Setup: /// @@ -470,28 +470,28 @@ fn test_list_owned_txouts() { .get(height) .map(|cp| cp.block_id()) .unwrap_or_else(|| panic!("block must exist at {height}")); - let txouts = graph - .canonical_view(&local_chain, chain_tip, CanonicalizationParams::default()) + let txouts = local_chain + .canonical_view(graph.graph(), chain_tip, CanonicalParams::default()) .filter_outpoints(graph.index.outpoints().iter().cloned()) .collect::>(); - let utxos = graph - .canonical_view(&local_chain, chain_tip, CanonicalizationParams::default()) + let utxos = local_chain + .canonical_view(graph.graph(), chain_tip, CanonicalParams::default()) .filter_unspent_outpoints(graph.index.outpoints().iter().cloned()) .collect::>(); - let balance = graph - .canonical_view(&local_chain, chain_tip, CanonicalizationParams::default()) + let balance = local_chain + .canonical_view(graph.graph(), chain_tip, CanonicalParams::default()) .balance( graph.index.outpoints().iter().cloned(), |_, txout| trusted_spks.contains(&txout.txout.script_pubkey), - 1, + 0, ); let confirmed_txouts_txid = txouts .iter() .filter_map(|(_, full_txout)| { - if full_txout.chain_position.is_confirmed() { + if full_txout.pos.is_confirmed() { Some(full_txout.outpoint.txid) } else { None @@ -502,7 +502,7 @@ fn test_list_owned_txouts() { let unconfirmed_txouts_txid = txouts .iter() .filter_map(|(_, full_txout)| { - if !full_txout.chain_position.is_confirmed() { + if !full_txout.pos.is_confirmed() { Some(full_txout.outpoint.txid) } else { None @@ -513,7 +513,7 @@ fn test_list_owned_txouts() { let confirmed_utxos_txid = utxos .iter() .filter_map(|(_, full_txout)| { - if full_txout.chain_position.is_confirmed() { + if full_txout.pos.is_confirmed() { Some(full_txout.outpoint.txid) } else { None @@ -524,7 +524,7 @@ fn test_list_owned_txouts() { let unconfirmed_utxos_txid = utxos .iter() .filter_map(|(_, full_txout)| { - if !full_txout.chain_position.is_confirmed() { + if !full_txout.pos.is_confirmed() { Some(full_txout.outpoint.txid) } else { None @@ -789,12 +789,9 @@ fn test_get_chain_position() { } // check chain position - let chain_pos = graph - .canonical_view( - chain, - chain.tip().block_id(), - CanonicalizationParams::default(), - ) + let chain_tip = chain.tip().block_id(); + let chain_pos = chain + .canonical_view(graph.graph(), chain_tip, CanonicalParams::default()) .txs() .find_map(|canon_tx| { if canon_tx.txid == txid { diff --git a/crates/chain/tests/test_tx_graph.rs b/crates/chain/tests/test_tx_graph.rs index b2a359608..9181f8af2 100644 --- a/crates/chain/tests/test_tx_graph.rs +++ b/crates/chain/tests/test_tx_graph.rs @@ -2,12 +2,12 @@ #[macro_use] mod common; -use bdk_chain::{collections::*, BlockId, CanonicalizationParams, ConfirmationBlockTime}; +use bdk_chain::{collections::*, BlockId, CanonicalParams, ConfirmationBlockTime}; use bdk_chain::{ local_chain::LocalChain, tx_graph::{self, CalculateFeeError}, tx_graph::{ChangeSet, TxGraph}, - Anchor, ChainOracle, ChainPosition, Merge, + Anchor, ChainPosition, Merge, }; use bdk_testenv::{block_id, hash, utils::new_tx}; use bitcoin::hex::FromHex; @@ -758,7 +758,7 @@ fn test_walk_ancestors() { let tx_node = graph.get_tx_node(tx.compute_txid())?; for block in tx_node.anchors { match local_chain.is_block_in_chain(block.anchor_block(), tip.block_id()) { - Ok(Some(true)) => return None, + Some(true) => return None, _ => continue, } } @@ -1014,8 +1014,8 @@ fn test_chain_spends() { let build_canonical_spends = |chain: &LocalChain, tx_graph: &TxGraph| -> HashMap { - tx_graph - .canonical_view(chain, tip.block_id(), CanonicalizationParams::default()) + chain + .canonical_view(tx_graph, tip.block_id(), CanonicalParams::default()) .filter_outpoints(tx_graph.all_txouts().map(|(op, _)| ((), op))) .filter_map(|(_, full_txo)| Some((full_txo.outpoint, full_txo.spent_by?))) .collect() @@ -1023,8 +1023,8 @@ fn test_chain_spends() { let build_canonical_positions = |chain: &LocalChain, tx_graph: &TxGraph| -> HashMap> { - tx_graph - .canonical_view(chain, tip.block_id(), CanonicalizationParams::default()) + chain + .canonical_view(tx_graph, tip.block_id(), CanonicalParams::default()) .txs() .map(|canon_tx| (canon_tx.txid, canon_tx.pos)) .collect() @@ -1197,35 +1197,26 @@ fn transactions_inserted_into_tx_graph_are_not_canonical_until_they_have_an_anch .into_iter() .collect(); let chain = LocalChain::from_blocks(blocks).unwrap(); - let canonical_txs: Vec<_> = graph - .canonical_view( - &chain, - chain.tip().block_id(), - CanonicalizationParams::default(), - ) + let chain_tip = chain.tip().block_id(); + let canonical_txs: Vec<_> = chain + .canonical_view(&graph, chain_tip, CanonicalParams::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 canonical_view = graph.canonical_view( - &chain, - chain.tip().block_id(), - CanonicalizationParams::default(), - ); + let chain_tip = chain.tip().block_id(); + let canonical_view = chain.canonical_view(&graph, chain_tip, CanonicalParams::default()); 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 - .canonical_view( - &chain, - chain.tip().block_id(), - CanonicalizationParams::default(), - ) + let chain_tip = chain.tip().block_id(); + let canonical_txids: Vec<_> = chain + .canonical_view(&graph, chain_tip, CanonicalParams::default()) .txs() .map(|tx| tx.txid) .collect(); diff --git a/crates/chain/tests/test_tx_graph_conflicts.rs b/crates/chain/tests/test_tx_graph_conflicts.rs index 38f21365c..45ecc7762 100644 --- a/crates/chain/tests/test_tx_graph_conflicts.rs +++ b/crates/chain/tests/test_tx_graph_conflicts.rs @@ -970,9 +970,12 @@ fn test_tx_conflict_handling() { for scenario in scenarios { let env = init_graph(scenario.tx_templates.iter()); - let txs = env - .tx_graph - .canonical_view(&local_chain, chain_tip, env.canonicalization_params.clone()) + let txs = local_chain + .canonical_view( + &env.tx_graph, + chain_tip, + env.canonicalization_params.clone(), + ) .txs() .map(|tx| tx.txid) .collect::>(); @@ -987,9 +990,12 @@ fn test_tx_conflict_handling() { scenario.name ); - let txouts = env - .tx_graph - .canonical_view(&local_chain, chain_tip, env.canonicalization_params.clone()) + let txouts = local_chain + .canonical_view( + &env.tx_graph, + chain_tip, + env.canonicalization_params.clone(), + ) .filter_outpoints(env.indexer.outpoints().iter().cloned()) .map(|(_, full_txout)| full_txout.outpoint) .collect::>(); @@ -1007,9 +1013,12 @@ fn test_tx_conflict_handling() { scenario.name ); - let utxos = env - .tx_graph - .canonical_view(&local_chain, chain_tip, env.canonicalization_params.clone()) + let utxos = local_chain + .canonical_view( + &env.tx_graph, + chain_tip, + env.canonicalization_params.clone(), + ) .filter_unspent_outpoints(env.indexer.outpoints().iter().cloned()) .map(|(_, full_txout)| full_txout.outpoint) .collect::>(); @@ -1027,9 +1036,12 @@ fn test_tx_conflict_handling() { scenario.name ); - let balance = env - .tx_graph - .canonical_view(&local_chain, chain_tip, env.canonicalization_params.clone()) + let balance = local_chain + .canonical_view( + &env.tx_graph, + chain_tip, + env.canonicalization_params.clone(), + ) .balance( env.indexer.outpoints().iter().cloned(), |_, txout| { diff --git a/crates/core/src/block_queries.rs b/crates/core/src/block_queries.rs new file mode 100644 index 000000000..fbf365e80 --- /dev/null +++ b/crates/core/src/block_queries.rs @@ -0,0 +1,67 @@ +use crate::collections::BTreeMap; +use alloc::collections::BTreeSet; +use alloc::vec::Vec; + +/// Tracks block-height queries: which heights have been requested, +/// which are still pending, and the resolved data. +/// +/// This is a helper for [`ChainQuery`](crate::ChainQuery) implementors that need to +/// track which block heights have been requested from the chain source and which +/// responses have been received. It deduplicates requests and stores resolved blocks. +/// +/// # Usage +/// +/// 1. Call [`request`](Self::request) with the heights you need — it returns only the heights that +/// haven't been requested or resolved yet. +/// 2. Feed responses back via [`resolve`](Self::resolve). +/// 3. Look up resolved blocks with [`get`](Self::get). +/// 4. Check for outstanding requests with [`unresolved`](Self::unresolved). +pub struct BlockQueries { + pending: BTreeSet, + blocks: BTreeMap>, +} + +impl BlockQueries { + /// Creates an empty `BlockQueries`. + pub fn new() -> Self { + Self { + pending: BTreeSet::new(), + blocks: BTreeMap::new(), + } + } + + /// Filters out already-resolved/pending heights, inserts new ones into pending, + /// and returns the list of newly requested heights. + pub fn request(&mut self, heights: impl Iterator) -> Vec { + heights + .filter(|h| !self.blocks.contains_key(h)) + .filter(|h| self.pending.insert(*h)) + .collect() + } + + /// Marks a height as resolved. Removes from pending and inserts into blocks. + pub fn resolve(&mut self, height: u32, block: Option) { + let was_pending = self.pending.remove(&height); + self.blocks.insert(height, block); + debug_assert!( + was_pending, + "request must be previously unresolved to be resolved now" + ); + } + + /// Iterates over heights that are still pending (unresolved). + pub fn unresolved(&self) -> impl Iterator + '_ { + self.pending.iter().copied() + } + + /// Looks up a resolved block by height. + pub fn get(&self, height: u32) -> Option<&Option> { + self.blocks.get(&height) + } +} + +impl Default for BlockQueries { + fn default() -> Self { + Self::new() + } +} diff --git a/crates/core/src/chain_query.rs b/crates/core/src/chain_query.rs new file mode 100644 index 000000000..635fd238f --- /dev/null +++ b/crates/core/src/chain_query.rs @@ -0,0 +1,109 @@ +//! Sans-IO trait for tasks that need to query block data from a chain source. +//! +//! [`ChainQuery`] defines a cooperative protocol between a **task** (the implementor) +//! and a **driver** (the caller that has access to the chain source). The task +//! declares what block heights it needs, the driver fetches them, and the task +//! makes progress until it produces a final output. +//! +//! # The driver loop +//! +//! A correct driver calls [`poll`](ChainQuery::poll) in a loop, matching the returned +//! [`TaskProgress`]: +//! +//! ```ignore +//! loop { +//! match task.poll() { +//! TaskProgress::Advanced => continue, +//! TaskProgress::Done => return task.finish(), +//! TaskProgress::Query(heights) => { +//! debug_assert!(!heights.is_empty()); +//! for h in heights { +//! let block = chain_source.get(h); +//! task.resolve_query(h, block); +//! } +//! } +//! } +//! } +//! ``` +//! +//! # The block type `B` +//! +//! The generic parameter `B` is the block data the task receives from the driver. +//! It defaults to [`BlockHash`] but can be any type (e.g. [`Header`](bitcoin::block::Header)) +//! depending on the chain source. A response of `None` means the chain source has +//! no block at that height. + +use crate::BlockId; +use alloc::vec::Vec; +use bitcoin::BlockHash; + +/// Progress indicator returned by [`ChainQuery::poll`]. +/// +/// This enum makes "stuck" states unrepresentable: the task must always either +/// make progress, request data, or declare itself done. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum TaskProgress { + /// Internal progress was made. The driver should call [`poll`](ChainQuery::poll) again. + /// + /// Implementations should return this at stage transitions and after processing + /// individual items, rather than looping internally. This keeps `poll()` doing + /// one unit of work per call, giving the driver observability into progress and + /// an opportunity to cancel or log between steps. + Advanced, + /// The task needs block data at these heights. The driver should resolve each + /// height via [`resolve_query`](ChainQuery::resolve_query) and then call + /// [`poll`](ChainQuery::poll) again. + /// + /// The heights vector is guaranteed to be non-empty by correct implementations. + Query(Vec), + /// The task is complete. The driver should call [`finish`](ChainQuery::finish). + Done, +} + +/// A sans-IO task that queries block data by height from a chain source. +/// +/// See the [module-level documentation](self) for the driver loop contract. +/// +/// # Contract for implementors +/// +/// - [`poll`](Self::poll) should do one logical unit of work and return: +/// - [`TaskProgress::Advanced`] when internal progress was made (e.g. a stage transition or +/// processing a single item). The driver will call `poll()` again. +/// - [`TaskProgress::Query`] when the task needs block data at one or more heights. +/// - [`TaskProgress::Done`] when all processing is complete. +/// - Prefer returning [`TaskProgress::Advanced`] over looping internally. This gives the driver +/// control between steps for progress reporting, cancellation, or logging. +/// - [`finish`](Self::finish) consumes the task and produces the final output. It should only be +/// called after `poll` returns [`TaskProgress::Done`]. +pub trait ChainQuery { + /// The final output produced when the task completes. + type Output; + + /// The chain tip that serves as the reference point for all queries. + fn tip(&self) -> BlockId; + + /// Drive the task forward, returning its progress status. + /// + /// Each call should perform one logical unit of work. The driver calls this + /// in a loop, matching the returned [`TaskProgress`]. + fn poll(&mut self) -> TaskProgress; + + /// Provides the block data (or `None`) for a previously requested `height`. + /// + /// A response of `None` signals that the chain source has no block at that + /// height. The task records this so it can distinguish "not yet queried" + /// from "queried but absent." + fn resolve_query(&mut self, height: u32, response: Option); + + /// Returns heights that have been requested but not yet resolved. + /// + /// This is useful for retrying failed queries or reporting progress. + fn unresolved_queries<'a>(&'a self) -> impl Iterator + 'a; + + /// Consumes the task and returns the final output. + /// + /// # Panics + /// + /// May panic if called before [`poll`](Self::poll) returns [`TaskProgress::Done`]. + fn finish(self) -> Self::Output; +} diff --git a/crates/core/src/lib.rs b/crates/core/src/lib.rs index 95bebe907..757d35050 100644 --- a/crates/core/src/lib.rs +++ b/crates/core/src/lib.rs @@ -72,3 +72,9 @@ mod merge; pub use merge::*; pub mod spk_client; + +mod chain_query; +pub use chain_query::*; + +mod block_queries; +pub use block_queries::*; diff --git a/crates/electrum/tests/test_electrum.rs b/crates/electrum/tests/test_electrum.rs index 318708a19..efe789465 100644 --- a/crates/electrum/tests/test_electrum.rs +++ b/crates/electrum/tests/test_electrum.rs @@ -3,8 +3,7 @@ use bdk_chain::{ local_chain::LocalChain, spk_client::{FullScanRequest, SyncRequest, SyncResponse}, spk_txout::SpkTxOutIndex, - Balance, CanonicalizationParams, ConfirmationBlockTime, IndexedTxGraph, Indexer, Merge, - TxGraph, + Balance, CanonicalParams, ConfirmationBlockTime, IndexedTxGraph, Indexer, Merge, TxGraph, }; use bdk_core::bitcoin::{ key::{Secp256k1, UntweakedPublicKey}, @@ -40,9 +39,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 - .canonical_view(recv_chain, chain_tip, CanonicalizationParams::default()) - .balance(outpoints, |_, _| true, 1); + let balance = recv_chain + .canonical_view(recv_graph.graph(), chain_tip, CanonicalParams::default()) + .balance(outpoints, |_, _| true, 0); Ok(balance) } @@ -156,8 +155,8 @@ pub fn detect_receive_tx_cancel() -> anyhow::Result<()> { .chain_tip(chain.tip()) .spks_with_indexes(graph.index.all_spks().clone()) .expected_spk_txids( - graph - .canonical_view(&chain, chain.tip().block_id(), Default::default()) + chain + .canonical_view(graph.graph(), chain.tip().block_id(), Default::default()) .list_expected_spk_txids(&graph.index, ..), ); let sync_response = client.sync(sync_request, BATCH_SIZE, true)?; @@ -186,8 +185,8 @@ pub fn detect_receive_tx_cancel() -> anyhow::Result<()> { .chain_tip(chain.tip()) .spks_with_indexes(graph.index.all_spks().clone()) .expected_spk_txids( - graph - .canonical_view(&chain, chain.tip().block_id(), Default::default()) + chain + .canonical_view(graph.graph(), chain.tip().block_id(), Default::default()) .list_expected_spk_txids(&graph.index, ..), ); let sync_response = client.sync(sync_request, BATCH_SIZE, true)?; diff --git a/crates/esplora/tests/async_ext.rs b/crates/esplora/tests/async_ext.rs index 209e5b788..1958190a7 100644 --- a/crates/esplora/tests/async_ext.rs +++ b/crates/esplora/tests/async_ext.rs @@ -100,8 +100,8 @@ pub async fn detect_receive_tx_cancel() -> anyhow::Result<()> { .chain_tip(chain.tip()) .spks_with_indexes(graph.index.all_spks().clone()) .expected_spk_txids( - graph - .canonical_view(&chain, chain.tip().block_id(), Default::default()) + chain + .canonical_view(graph.graph(), chain.tip().block_id(), Default::default()) .list_expected_spk_txids(&graph.index, ..), ); let sync_response = client.sync(sync_request, 1).await?; @@ -130,8 +130,8 @@ pub async fn detect_receive_tx_cancel() -> anyhow::Result<()> { .chain_tip(chain.tip()) .spks_with_indexes(graph.index.all_spks().clone()) .expected_spk_txids( - graph - .canonical_view(&chain, chain.tip().block_id(), Default::default()) + chain + .canonical_view(graph.graph(), chain.tip().block_id(), Default::default()) .list_expected_spk_txids(&graph.index, ..), ); let sync_response = client.sync(sync_request, 1).await?; diff --git a/crates/esplora/tests/blocking_ext.rs b/crates/esplora/tests/blocking_ext.rs index 76ed28fbb..4855638fe 100644 --- a/crates/esplora/tests/blocking_ext.rs +++ b/crates/esplora/tests/blocking_ext.rs @@ -97,8 +97,8 @@ pub fn detect_receive_tx_cancel() -> anyhow::Result<()> { .chain_tip(chain.tip()) .spks_with_indexes(graph.index.all_spks().clone()) .expected_spk_txids( - graph - .canonical_view(&chain, chain.tip().block_id(), Default::default()) + chain + .canonical_view(graph.graph(), chain.tip().block_id(), Default::default()) .list_expected_spk_txids(&graph.index, ..), ); let sync_response = client.sync(sync_request, 1)?; @@ -127,8 +127,8 @@ pub fn detect_receive_tx_cancel() -> anyhow::Result<()> { .chain_tip(chain.tip()) .spks_with_indexes(graph.index.all_spks().clone()) .expected_spk_txids( - graph - .canonical_view(&chain, chain.tip().block_id(), Default::default()) + chain + .canonical_view(graph.graph(), chain.tip().block_id(), Default::default()) .list_expected_spk_txids(&graph.index, ..), ); let sync_response = client.sync(sync_request, 1)?; diff --git a/examples/example_bitcoind_rpc_polling/src/main.rs b/examples/example_bitcoind_rpc_polling/src/main.rs index 0263c5b0b..5a97a3c4c 100644 --- a/examples/example_bitcoind_rpc_polling/src/main.rs +++ b/examples/example_bitcoind_rpc_polling/src/main.rs @@ -11,7 +11,7 @@ use bdk_bitcoind_rpc::{ bitcoincore_rpc::{Auth, Client, RpcApi}, Emitter, }; -use bdk_chain::{bitcoin::Block, local_chain, CanonicalizationParams, Merge}; +use bdk_chain::{bitcoin::Block, local_chain, CanonicalParams, Merge}; use example_cli::{ anyhow, clap::{self, Args, Subcommand}, @@ -144,11 +144,11 @@ fn main() -> anyhow::Result<()> { &rpc_client, chain.tip(), fallback_height, - graph + chain .canonical_view( - &*chain, + graph.graph(), chain.tip().block_id(), - CanonicalizationParams::default(), + CanonicalParams::default(), ) .txs() .filter(|tx| tx.pos.is_unconfirmed()) @@ -196,16 +196,16 @@ fn main() -> anyhow::Result<()> { last_print = Instant::now(); let synced_to = chain.tip(); let balance = { - graph + chain .canonical_view( - &*chain, + graph.graph(), synced_to.block_id(), - CanonicalizationParams::default(), + CanonicalParams::default(), ) .balance( graph.index.outpoints().iter().cloned(), |(k, _), _| k == &Keychain::Internal, - 1, + 0, ) }; println!( @@ -249,11 +249,11 @@ fn main() -> anyhow::Result<()> { rpc_client.clone(), chain.tip(), fallback_height, - graph + chain .canonical_view( - &*chain, + graph.graph(), chain.tip().block_id(), - CanonicalizationParams::default(), + CanonicalParams::default(), ) .txs() .filter(|tx| tx.pos.is_unconfirmed()) @@ -356,16 +356,16 @@ fn main() -> anyhow::Result<()> { last_print = Some(Instant::now()); let synced_to = chain.tip(); let balance = { - graph + chain .canonical_view( - &*chain, + graph.graph(), synced_to.block_id(), - CanonicalizationParams::default(), + CanonicalParams::default(), ) .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 6745ae6c1..62667e365 100644 --- a/examples/example_cli/src/lib.rs +++ b/examples/example_cli/src/lib.rs @@ -1,6 +1,7 @@ use bdk_chain::keychain_txout::DEFAULT_LOOKAHEAD; use serde_json::json; use std::cmp; +use std::convert::Infallible; use std::env; use std::fmt; use std::str::FromStr; @@ -18,12 +19,12 @@ use bdk_chain::miniscript::{ psbt::PsbtExt, Descriptor, DescriptorPublicKey, ForEachKey, }; -use bdk_chain::CanonicalizationParams; +use bdk_chain::CanonicalParams; use bdk_chain::ConfirmationBlockTime; use bdk_chain::{ indexer::keychain_txout::{self, KeychainTxOutIndex}, local_chain::{self, LocalChain}, - tx_graph, ChainOracle, DescriptorExt, FullTxOut, IndexedTxGraph, Merge, + tx_graph, CanonicalTxOut, ChainPosition, DescriptorExt, IndexedTxGraph, Merge, }; use bdk_coin_select::{ metrics::LowestFee, Candidate, ChangePolicy, CoinSelector, DrainWeights, FeeRate, Target, @@ -258,18 +259,15 @@ pub struct ChangeInfo { pub index: u32, } -pub fn create_tx( +pub fn create_tx( graph: &mut KeychainTxGraph, - chain: &O, + chain: &LocalChain, assets: &Assets, cs_algorithm: CoinSelectionAlgo, address: Address, value: u64, feerate: f32, -) -> anyhow::Result<(Psbt, Option)> -where - O::Error: core::error::Error + Send + Sync + 'static, -{ +) -> anyhow::Result<(Psbt, Option)> { let mut changeset = keychain_txout::ChangeSet::default(); // get planned utxos @@ -281,9 +279,9 @@ where plan_utxos.sort_by_key(|(_, utxo)| cmp::Reverse(utxo.txout.value)) } CoinSelectionAlgo::SmallestFirst => plan_utxos.sort_by_key(|(_, utxo)| utxo.txout.value), - CoinSelectionAlgo::OldestFirst => plan_utxos.sort_by_key(|(_, utxo)| utxo.chain_position), + CoinSelectionAlgo::OldestFirst => plan_utxos.sort_by_key(|(_, utxo)| utxo.pos), CoinSelectionAlgo::NewestFirst => { - plan_utxos.sort_by_key(|(_, utxo)| cmp::Reverse(utxo.chain_position)) + plan_utxos.sort_by_key(|(_, utxo)| cmp::Reverse(utxo.pos)) } CoinSelectionAlgo::BranchAndBound => plan_utxos.shuffle(&mut thread_rng()), } @@ -392,9 +390,7 @@ where version: transaction::Version::TWO, lock_time: assets .absolute_timelock - .unwrap_or(absolute::LockTime::from_height( - chain.get_chain_tip()?.height, - )?), + .unwrap_or(absolute::LockTime::from_height(chain.chain_tip().height)?), input: selected .iter() .map(|(plan, utxo)| TxIn { @@ -420,17 +416,17 @@ where } // Alias the elements of `planned_utxos` -pub type PlanUtxo = (Plan, FullTxOut); +pub type PlanUtxo = (Plan, CanonicalTxOut>); -pub fn planned_utxos( +pub fn planned_utxos( graph: &KeychainTxGraph, - chain: &O, + chain: &LocalChain, assets: &Assets, -) -> Result, O::Error> { - let chain_tip = chain.get_chain_tip()?; +) -> Result, Infallible> { + let chain_tip = chain.tip().block_id(); let outpoints = graph.index.outpoints(); - graph - .try_canonical_view(chain, chain_tip, CanonicalizationParams::default())? + chain + .canonical_view(graph.graph(), chain_tip, CanonicalParams::default()) .filter_unspent_outpoints(outpoints.iter().cloned()) .filter_map(|((k, i), full_txo)| -> Option> { let desc = graph @@ -522,12 +518,9 @@ pub fn handle_commands( } } - let balance = graph - .try_canonical_view( - chain, - chain.get_chain_tip()?, - CanonicalizationParams::default(), - )? + let chain_tip = chain.tip().block_id(); + let balance = chain + .canonical_view(graph.graph(), chain_tip, CanonicalParams::default()) .balance( graph.index.outpoints().iter().cloned(), |(k, _), _| k == &Keychain::Internal, @@ -559,7 +552,7 @@ pub fn handle_commands( Commands::TxOut { txout_cmd } => { let graph = &*graph.lock().unwrap(); let chain = &*chain.lock().unwrap(); - let chain_tip = chain.get_chain_tip()?; + let chain_tip = chain.chain_tip(); let outpoints = graph.index.outpoints(); match txout_cmd { @@ -569,8 +562,8 @@ pub fn handle_commands( confirmed, unconfirmed, } => { - let txouts = graph - .try_canonical_view(chain, chain_tip, CanonicalizationParams::default())? + let txouts = chain + .canonical_view(graph.graph(), chain_tip, CanonicalParams::default()) .filter_outpoints(outpoints.iter().cloned()) .filter(|(_, full_txo)| match (spent, unspent) { (true, false) => full_txo.spent_by.is_some(), @@ -578,8 +571,8 @@ pub fn handle_commands( _ => true, }) .filter(|(_, full_txo)| match (confirmed, unconfirmed) { - (true, false) => full_txo.chain_position.is_confirmed(), - (false, true) => !full_txo.chain_position.is_confirmed(), + (true, false) => full_txo.pos.is_confirmed(), + (false, true) => !full_txo.pos.is_confirmed(), _ => true, }) .collect::>(); @@ -629,7 +622,7 @@ pub fn handle_commands( create_tx( &mut graph, - &*chain, + &chain, &assets, coin_select, address, diff --git a/examples/example_electrum/src/main.rs b/examples/example_electrum/src/main.rs index c92666303..cc995a738 100644 --- a/examples/example_electrum/src/main.rs +++ b/examples/example_electrum/src/main.rs @@ -5,7 +5,7 @@ use bdk_chain::{ collections::BTreeSet, indexed_tx_graph, spk_client::{FullScanRequest, SyncRequest}, - CanonicalizationParams, ConfirmationBlockTime, Merge, + CanonicalParams, ConfirmationBlockTime, Merge, }; use bdk_electrum::{ electrum_client::{self, Client, ElectrumApi}, @@ -226,11 +226,10 @@ fn main() -> anyhow::Result<()> { } let _ = io::stderr().flush(); }); - let canonical_view = graph.canonical_view( - &*chain, - chain_tip.block_id(), - CanonicalizationParams::default(), - ); + + let chain_tip_block = chain_tip.block_id(); + let canonical_view = + chain.canonical_view(graph.graph(), chain_tip_block, CanonicalParams::default()); request = request .expected_spk_txids(canonical_view.list_expected_spk_txids(&graph.index, ..)); diff --git a/examples/example_esplora/src/main.rs b/examples/example_esplora/src/main.rs index 9bc3231e6..288acfb67 100644 --- a/examples/example_esplora/src/main.rs +++ b/examples/example_esplora/src/main.rs @@ -8,7 +8,7 @@ use bdk_chain::{ bitcoin::Network, keychain_txout::FullScanRequestBuilderExt, spk_client::{FullScanRequest, SyncRequest}, - CanonicalizationParams, Merge, + CanonicalParams, Merge, }; use bdk_esplora::{esplora_client, EsploraExt}; use example_cli::{ @@ -237,10 +237,11 @@ fn main() -> anyhow::Result<()> { { let graph = graph.lock().unwrap(); let chain = chain.lock().unwrap(); - let canonical_view = graph.canonical_view( - &*chain, - local_tip.block_id(), - CanonicalizationParams::default(), + let local_tip_block = local_tip.block_id(); + let canonical_view = chain.canonical_view( + graph.graph(), + local_tip_block, + CanonicalParams::default(), ); request = request