From 987da73fce7b7f4e015aa05cc8fa4e36deebf4df Mon Sep 17 00:00:00 2001 From: Leonardo Lima Date: Tue, 19 May 2026 15:53:10 -0300 Subject: [PATCH 1/8] feat(core,chain): introduce `CanonicalizationTask` and `ChainQuery` It introduces the new `CanonicalizationTask` that's implements the canonicalization algorithm through a request/response pattern. Also, it introduces the new `ChainQuery` trait in `bdk_core`, which provides an interface for blockchain source/oracle query-based operations. Allowing sans-IO patterns for algorithm that needs a blockchain oracle, without the need for directly implement/handle I/O. Adds new API methods into `LocalChain`: `canonicalize` and `canonical_view`, adding same features as the existing `CanonicalIter` and it's APIs. Co-Authored-By: Claude --- crates/chain/src/canonical_iter.rs | 101 +---- crates/chain/src/canonical_task.rs | 613 +++++++++++++++++++++++++++ crates/chain/src/canonical_view.rs | 20 + crates/chain/src/indexed_tx_graph.rs | 18 +- crates/chain/src/lib.rs | 2 + crates/chain/src/local_chain.rs | 63 ++- crates/chain/src/tx_graph.rs | 45 +- crates/core/src/chain_query.rs | 41 ++ crates/core/src/lib.rs | 3 + 9 files changed, 791 insertions(+), 115 deletions(-) create mode 100644 crates/chain/src/canonical_task.rs create mode 100644 crates/core/src/chain_query.rs diff --git a/crates/chain/src/canonical_iter.rs b/crates/chain/src/canonical_iter.rs index 204ead4511..7866d12da2 100644 --- a/crates/chain/src/canonical_iter.rs +++ b/crates/chain/src/canonical_iter.rs @@ -1,6 +1,6 @@ use crate::collections::{HashMap, HashSet, VecDeque}; use crate::tx_graph::{TxAncestors, TxDescendants}; -use crate::{Anchor, ChainOracle, TxGraph}; +use crate::{Anchor, CanonicalReason, CanonicalizationParams, ChainOracle, ObservedIn, TxGraph}; use alloc::boxed::Box; use alloc::collections::BTreeSet; use alloc::sync::Arc; @@ -11,16 +11,6 @@ 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, @@ -253,92 +243,3 @@ impl Iterator for CanonicalIter<'_, A, C> { } } } - -/// 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 0000000000..7fabb21df4 --- /dev/null +++ b/crates/chain/src/canonical_task.rs @@ -0,0 +1,613 @@ +use crate::collections::{HashMap, HashSet, VecDeque}; +use crate::tx_graph::{TxAncestors, TxDescendants}; +use crate::{Anchor, CanonicalView, ChainPosition, TxGraph}; +use alloc::boxed::Box; +use alloc::collections::BTreeSet; +use alloc::sync::Arc; +use alloc::vec::Vec; +use bdk_core::{BlockId, ChainQuery, ChainRequest, ChainResponse}; +use bitcoin::{Transaction, Txid}; + +type CanonicalMap = HashMap, CanonicalReason)>; +type NotCanonicalSet = HashSet; + +/// Represents the current stage of canonicalization processing. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +enum CanonicalStage { + /// Processing transactions assumed to be canonical. + #[default] + AssumedTxs, + /// Processing directly anchored transactions. + AnchoredTxs, + /// Processing transactions seen in mempool. + SeenTxs, + /// Processing leftover transactions. + LeftOverTxs, + /// Processing transitively anchored transactions. + TransitivelyAnchoredTxs, + /// All processing is complete. + Finished, +} + +impl CanonicalStage { + fn advance(&mut self) { + *self = match self { + CanonicalStage::AssumedTxs => Self::AnchoredTxs, + CanonicalStage::AnchoredTxs => Self::SeenTxs, + CanonicalStage::SeenTxs => Self::LeftOverTxs, + CanonicalStage::LeftOverTxs => Self::TransitivelyAnchoredTxs, + CanonicalStage::TransitivelyAnchoredTxs => Self::Finished, + CanonicalStage::Finished => Self::Finished, + }; + } +} + +/// Modifies the canonicalization algorithm. +#[derive(Debug, Default, Clone)] +pub struct CanonicalizationParams { + /// 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, +} + +/// Manages the canonicalization process without direct I/O operations. +pub struct CanonicalizationTask<'g, A> { + tx_graph: &'g TxGraph, + chain_tip: BlockId, + + 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)>, + unprocessed_transitively_anchored_txs: VecDeque<(Txid, Arc, &'g BTreeSet)>, + + canonical: CanonicalMap, + not_canonical: NotCanonicalSet, + + // Store canonical transactions in order + canonical_order: Vec, + + // Track which transactions have direct anchors (not transitive) + direct_anchors: HashMap, + + // Track the current stage of processing + current_stage: CanonicalStage, +} + +impl<'g, A: Anchor> ChainQuery for CanonicalizationTask<'g, A> { + type Output = CanonicalView; + + fn tip(&self) -> BlockId { + self.chain_tip + } + + fn next_query(&mut self) -> Option { + loop { + 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()); + } + continue; + } + } + CanonicalStage::AnchoredTxs => { + if let Some((_txid, _, anchors)) = self.unprocessed_anchored_txs.front() { + let block_ids = + anchors.iter().map(|anchor| anchor.anchor_block()).collect(); + return Some(block_ids); + } + } + 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), + ); + } + continue; + } + } + 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), + ); + } + continue; + } + } + CanonicalStage::TransitivelyAnchoredTxs => { + if let Some((_txid, _, anchors)) = + self.unprocessed_transitively_anchored_txs.front() + { + let block_ids = + anchors.iter().map(|anchor| anchor.anchor_block()).collect(); + return Some(block_ids); + } + } + CanonicalStage::Finished => return None, + } + + self.current_stage.advance(); + } + } + + fn resolve_query(&mut self, response: ChainResponse) { + // Only AnchoredTxs and TransitivelyAnchoredTxs stages should receive query + // responses Other stages don't generate queries and thus shouldn't call + // resolve_query + match self.current_stage { + CanonicalStage::AnchoredTxs => { + // Process directly anchored transaction response + if let Some((txid, tx, anchors)) = self.unprocessed_anchored_txs.pop_front() { + // Find the anchor that matches the confirmed BlockId + let best_anchor = response.and_then(|block_id| { + anchors + .iter() + .find(|anchor| anchor.anchor_block() == block_id) + .cloned() + }); + + match best_anchor { + Some(best_anchor) => { + // Transaction has a confirmed anchor + self.direct_anchors.insert(txid, best_anchor.clone()); + if !self.is_canonicalized(txid) { + self.mark_canonical( + txid, + tx, + CanonicalReason::from_anchor(best_anchor), + ); + } + } + None => { + // No confirmed anchor found, add to leftover transactions for later + // processing + self.unprocessed_leftover_txs.push_back(( + txid, + tx, + anchors + .iter() + .last() + .expect( + "tx taken from `unprocessed_anchored_txs` so it must have at least one anchor", + ) + .confirmation_height_upper_bound(), + )) + } + } + } + } + CanonicalStage::TransitivelyAnchoredTxs => { + // Process transitively anchored transaction response + if let Some((txid, _tx, anchors)) = + self.unprocessed_transitively_anchored_txs.pop_front() + { + // Find the anchor that matches the confirmed BlockId + let best_anchor = response.and_then(|block_id| { + anchors + .iter() + .find(|anchor| anchor.anchor_block() == block_id) + .cloned() + }); + + if let Some(best_anchor) = best_anchor { + // Found a confirmed anchor for this transitively anchored transaction + self.direct_anchors.insert(txid, best_anchor.clone()); + // Note: We don't re-mark as canonical since it's already marked + // from being transitively anchored by its descendant + } + // If no confirmed anchor, we keep the transitive canonicalization status + } + } + CanonicalStage::AssumedTxs + | CanonicalStage::SeenTxs + | CanonicalStage::LeftOverTxs + | CanonicalStage::Finished => { + // These stages don't generate queries and shouldn't receive responses + debug_assert!( + false, + "resolve_query called for stage {:?} which doesn't generate queries", + self.current_stage + ); + } + } + } + + fn finish(self) -> Self::Output { + // Build the canonical view + let mut view_order = Vec::new(); + let mut view_txs = HashMap::new(); + let mut view_spends = HashMap::new(); + + for txid in &self.canonical_order { + if let Some((tx, reason)) = self.canonical.get(txid) { + view_order.push(*txid); + + // Add spends + if !tx.is_coinbase() { + for input in &tx.input { + view_spends.insert(input.previous_output, *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 based on reason + let chain_position = match reason { + CanonicalReason::Assumed { descendant } => match descendant { + Some(_) => match self.direct_anchors.get(txid) { + 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 self.direct_anchors.get(txid) { + 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.clone(), chain_position.cloned())); + } + } + + CanonicalView::from_parts(self.chain_tip, view_order, view_txs, view_spends) + } +} + +impl<'g, A: Anchor> CanonicalizationTask<'g, A> { + /// Creates a new canonicalization task. + pub fn new( + tx_graph: &'g TxGraph, + 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: 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(), + unprocessed_transitively_anchored_txs: VecDeque::new(), + + canonical: HashMap::new(), + not_canonical: HashSet::new(), + + canonical_order: Vec::new(), + direct_anchors: HashMap::new(), + current_stage: CanonicalStage::default(), + } + } + + 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 + // Check if it has its own anchor that needs to be verified later + // We'll check anchors after marking it canonical + 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, + }; + + // Prune conflicts + // + // 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((tx.clone(), this_reason)); + 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, tx, reason) in &staged_canonical { + self.canonical_order.push(*txid); + + // ObservedIn transactions don't need anchor verification + if matches!(reason, CanonicalReason::ObservedIn { .. }) { + continue; + } + + // Check if this transaction was marked transitively and needs its own anchors verified + if reason.is_transitive() { + if let Some(anchors) = self.tx_graph.all_anchors().get(txid) { + // only check anchors we haven't already confirmed + if !self.direct_anchors.contains_key(txid) { + self.unprocessed_transitively_anchored_txs.push_back(( + *txid, + tx.clone(), + anchors, + )); + } + } + } + } + } +} + +/// 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, + } + } + + /// Construct 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() + } +} + +#[cfg(test)] +#[cfg_attr(coverage_nightly, coverage(off))] +mod tests { + use super::*; + use crate::local_chain::LocalChain; + 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 chain + let params = CanonicalizationParams::default(); + let task = CanonicalizationTask::new(&tx_graph, chain_tip, params); + let canonical_view = chain.canonicalize(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 { .. })); + } +} diff --git a/crates/chain/src/canonical_view.rs b/crates/chain/src/canonical_view.rs index 0191f45071..ced3a44925 100644 --- a/crates/chain/src/canonical_view.rs +++ b/crates/chain/src/canonical_view.rs @@ -91,6 +91,26 @@ pub struct CanonicalView { } impl CanonicalView { + /// Creates a [`CanonicalView`] from its constituent parts. + /// + /// This internal constructor is used by [`CanonicalizationTask`] to build the view + /// after completing the canonicalization process. It takes the processed transaction + /// data including the canonical ordering, transaction map with chain positions, and + /// spend information. + pub(crate) fn from_parts( + tip: BlockId, + order: Vec, + txs: HashMap, ChainPosition)>, + spends: HashMap, + ) -> Self { + Self { + tip, + order, + txs, + spends, + } + } + /// Create a new canonical view from a transaction graph. /// /// This constructor analyzes the given [`TxGraph`] and creates a canonical view of all diff --git a/crates/chain/src/indexed_tx_graph.rs b/crates/chain/src/indexed_tx_graph.rs index 6933784017..32ac444a05 100644 --- a/crates/chain/src/indexed_tx_graph.rs +++ b/crates/chain/src/indexed_tx_graph.rs @@ -7,8 +7,8 @@ use bitcoin::{Block, OutPoint, Transaction, TxOut, Txid}; use crate::{ tx_graph::{self, TxGraph}, - Anchor, BlockId, CanonicalView, CanonicalizationParams, ChainOracle, Indexer, Merge, - TxPosInBlock, + Anchor, BlockId, CanonicalView, CanonicalizationParams, CanonicalizationTask, ChainOracle, + Indexer, Merge, TxPosInBlock, }; /// A [`TxGraph`] paired with an indexer `I`, enforcing that every insertion into the graph is @@ -452,6 +452,20 @@ where ) -> CanonicalView { self.graph.canonical_view(chain, chain_tip, params) } + + /// Creates a [`CanonicalizationTask`] to determine the [`CanonicalView`] of transactions. + /// + /// This method delegates to the underlying [`TxGraph`] to create a [`CanonicalizationTask`] + /// 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 canonicalization_task( + &'_ self, + chain_tip: BlockId, + params: CanonicalizationParams, + ) -> CanonicalizationTask<'_, A> { + self.graph.canonicalization_task(chain_tip, params) + } } impl AsRef> for IndexedTxGraph { diff --git a/crates/chain/src/lib.rs b/crates/chain/src/lib.rs index be9170b1a5..3d9b5a7cfa 100644 --- a/crates/chain/src/lib.rs +++ b/crates/chain/src/lib.rs @@ -46,6 +46,8 @@ mod chain_oracle; pub use chain_oracle::*; mod canonical_iter; pub use canonical_iter::*; +mod canonical_task; +pub use canonical_task::*; mod canonical_view; pub use canonical_view::*; diff --git a/crates/chain/src/local_chain.rs b/crates/chain/src/local_chain.rs index 5c938ee473..96a671065a 100644 --- a/crates/chain/src/local_chain.rs +++ b/crates/chain/src/local_chain.rs @@ -5,9 +5,9 @@ use core::fmt; use core::ops::RangeBounds; use crate::collections::BTreeMap; -use crate::{BlockId, ChainOracle, Merge}; +use crate::{Anchor, BlockId, CanonicalView, CanonicalizationParams, ChainOracle, Merge, TxGraph}; +use bdk_core::{ChainQuery, CheckPointEntry, ToBlockHash}; pub use bdk_core::{CheckPoint, CheckPointIter}; -use bdk_core::{CheckPointEntry, ToBlockHash}; use bitcoin::block::Header; use bitcoin::BlockHash; @@ -100,6 +100,65 @@ impl ChainOracle for LocalChain { // Methods for `LocalChain` impl LocalChain { + /// 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. + /// + /// # Example + /// + /// ``` + /// # use bdk_chain::{CanonicalizationTask, CanonicalizationParams, TxGraph, local_chain::LocalChain}; + /// # use bdk_core::BlockId; + /// # use bitcoin::hashes::Hash; + /// # let tx_graph: TxGraph = TxGraph::default(); + /// # let chain = LocalChain::from_blocks([(0, bitcoin::BlockHash::all_zeros())].into_iter().collect()).unwrap(); + /// let chain_tip = chain.tip().block_id(); + /// let task = CanonicalizationTask::new(&tx_graph, chain_tip, CanonicalizationParams::default()); + /// let view = chain.canonicalize(task); + /// ``` + pub fn canonicalize(&self, mut task: Q) -> Q::Output + where + Q: ChainQuery, + { + let chain_tip = task.tip(); + while let Some(request) = task.next_query() { + let mut best_block_id = None; + for block_id in &request { + if self + .is_block_in_chain(*block_id, chain_tip) + .expect("infallible") + == Some(true) + { + best_block_id = Some(*block_id); + break; + } + } + task.resolve_query(best_block_id); + } + task.finish() + } + + /// A convenience method that creates [`CanonicalizationTask`] task, canonicalize it and returns + /// a [`CanonicalView`]. + /// + /// This is equivalent to: + /// ```ignore + /// let task = graph.canonicalization_task(chain_tip, Default::default()); + /// let canonical_view = chain.canonicalize(task); + /// ``` + /// + /// [`CanonicalizationTask`]: crate::CanonicalizationTask + pub fn canonical_view( + &self, + tx_graph: &TxGraph, + tip: BlockId, + params: CanonicalizationParams, + ) -> CanonicalView { + let task = tx_graph.canonicalization_task(tip, params); + self.canonicalize(task) + } + /// Update the chain with a given [`Header`] at `height` which you claim is connected to a /// existing block in the chain. /// diff --git a/crates/chain/src/tx_graph.rs b/crates/chain/src/tx_graph.rs index 72f8f4876f..c9a7951370 100644 --- a/crates/chain/src/tx_graph.rs +++ b/crates/chain/src/tx_graph.rs @@ -21,18 +21,26 @@ //! 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 +//! [`canonicalization_task`](TxGraph::canonicalization_task): ```ignore let task = +//! tx_graph.canonicalization_task(params); ``` This creates a [`CanonicalizationTask`] 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: @@ -123,6 +131,7 @@ use crate::BlockId; use crate::CanonicalIter; use crate::CanonicalView; use crate::CanonicalizationParams; +use crate::CanonicalizationTask; use crate::{Anchor, ChainOracle, Merge}; use alloc::collections::vec_deque::VecDeque; use alloc::sync::Arc; @@ -972,6 +981,20 @@ impl TxGraph { let _ = self.insert_evicted_at(txid, evicted_at); } } + + /// Creates a [`CanonicalizationTask`] to determine the [`CanonicalView`] of transactions. + /// + /// This method delegates to the underlying [`TxGraph`] to create a [`CanonicalizationTask`] + /// 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 canonicalization_task( + &'_ self, + chain_tip: BlockId, + params: CanonicalizationParams, + ) -> CanonicalizationTask<'_, A> { + CanonicalizationTask::new(self, chain_tip, params) + } } impl TxGraph { diff --git a/crates/core/src/chain_query.rs b/crates/core/src/chain_query.rs new file mode 100644 index 0000000000..660c1c74ff --- /dev/null +++ b/crates/core/src/chain_query.rs @@ -0,0 +1,41 @@ +//! Trait for query-based canonicalization against blockchain data. +//! +//! The [`ChainQuery`] trait provides a sans-IO interface for algorithms that +//! need to verify block confirmations against a chain source. + +use crate::BlockId; +use alloc::vec::Vec; + +/// A request containing [`BlockId`]s to check for confirmation in the chain. +pub type ChainRequest = Vec; + +/// Response containing the best confirmed [`BlockId`], if any. +pub type ChainResponse = Option; + +/// A trait for types that verify block confirmations against blockchain data. +/// +/// This trait enables a sans-IO loop: the caller drives the task by repeatedly +/// calling [`next_query`](Self::next_query) and [`resolve_query`](Self::resolve_query). +/// Once `next_query` returns `None`, call [`finish`](Self::finish) to get the output. +/// +/// `resolve_query` must only be called after `next_query` returns `Some`. +/// Calling `resolve_query` or `finish` out of sequence is a programming error. +pub trait ChainQuery { + /// The final output type produced when the query process is complete. + type Output; + + /// Returns the chain tip used as the reference point for all queries. + fn tip(&self) -> BlockId; + + /// Returns the next query needed, or `None` if no more queries are required. + #[must_use] + fn next_query(&mut self) -> Option; + + /// Resolves a query with the given response. + fn resolve_query(&mut self, response: ChainResponse); + + /// Completes the query process and returns the final output. + /// + /// This should be called once [`next_query`](Self::next_query) returns `None`. + fn finish(self) -> Self::Output; +} diff --git a/crates/core/src/lib.rs b/crates/core/src/lib.rs index cf02a99b05..3d9b47aa5f 100644 --- a/crates/core/src/lib.rs +++ b/crates/core/src/lib.rs @@ -75,3 +75,6 @@ mod merge; pub use merge::*; pub mod spk_client; + +mod chain_query; +pub use chain_query::*; From cca4d7b650298e0b8b5a5d706fa51fba8030a501 Mon Sep 17 00:00:00 2001 From: Leonardo Lima Date: Tue, 19 May 2026 15:56:26 -0300 Subject: [PATCH 2/8] chore(workspace): use new `LocalChain::canonical_view` API Updates the codebase to use the new convenience `LocalChain::canonical_view` method in order to generate the `CanonicalView`. Internally the convenience method follows the `sans-IO` approach, separating the canonicalization algorithm from i/o operations, and it's used as follows: 1. Create a new `CanonicalizationTask` with a `TxGraph`, by calling: `graph.canonicalization_task(params)` 2. Execute the canonicalization process with a chain oracle (e.g `LocalChain`, which implements `ChainOracle` trait), by calling: `chain.canonicalize(task, chain_tip)` --- crates/bitcoind_rpc/examples/filter_iter.rs | 3 +- crates/bitcoind_rpc/tests/test_emitter.rs | 21 +- crates/chain/benches/canonicalization.rs | 19 +- crates/chain/benches/indexer.rs | 7 +- crates/chain/tests/test_canonical_view.rs | 19 +- crates/chain/tests/test_indexed_tx_graph.rs | 33 +- crates/chain/tests/test_tx_graph.rs | 34 +- crates/chain/tests/test_tx_graph_conflicts.rs | 39 +- crates/electrum/tests/test_electrum.rs | 20 +- crates/esplora/tests/async_ext.rs | 6 +- crates/esplora/tests/blocking_ext.rs | 6 +- .../example_bitcoind_rpc_polling/src/main.rs | 415 ++++++++ examples/example_cli/src/lib.rs | 942 ++++++++++++++++++ examples/example_electrum/src/main.rs | 294 ++++++ examples/example_esplora/src/main.rs | 298 ++++++ 15 files changed, 2032 insertions(+), 124 deletions(-) create mode 100644 examples/example_bitcoind_rpc_polling/src/main.rs create mode 100644 examples/example_cli/src/lib.rs create mode 100644 examples/example_electrum/src/main.rs create mode 100644 examples/example_esplora/src/main.rs diff --git a/crates/bitcoind_rpc/examples/filter_iter.rs b/crates/bitcoind_rpc/examples/filter_iter.rs index e79bde6720..a346a2db0e 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 canonical_view = + chain.canonical_view(graph.graph(), chain.tip().block_id(), 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 b75d474caa..2aeede6283 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, IndexedTxGraph, Merge, }; use bdk_testenv::{ anyhow, @@ -318,11 +318,14 @@ fn get_balance( recv_chain: &LocalChain, recv_graph: &IndexedTxGraph>, ) -> 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(), + recv_chain.tip().block_id(), + Default::default(), + ) + .balance(outpoints, |_, _| true, 0); Ok(balance) } @@ -631,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)]); @@ -647,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, Default::default()) .txs() .map(|tx| tx.txid) .collect::>(); diff --git a/crates/chain/benches/canonicalization.rs b/crates/chain/benches/canonicalization.rs index 074e38cc42..74e6c05fd9 100644 --- a/crates/chain/benches/canonicalization.rs +++ b/crates/chain/benches/canonicalization.rs @@ -1,4 +1,3 @@ -use bdk_chain::CanonicalizationParams; use bdk_chain::{keychain_txout::KeychainTxOutIndex, local_chain::LocalChain, IndexedTxGraph}; use bdk_core::{BlockId, CheckPoint}; use bdk_core::{ConfirmationBlockTime, TxUpdate}; @@ -95,31 +94,19 @@ 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 view = chain.canonical_view(tx_graph.graph(), chain.tip().block_id(), Default::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 view = chain.canonical_view(tx_graph.graph(), chain.tip().block_id(), Default::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 view = chain.canonical_view(tx_graph.graph(), chain.tip().block_id(), Default::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 5907c76a00..97917ce35b 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, + IndexedTxGraph, }; use bdk_core::{BlockId, CheckPoint, ConfirmationBlockTime, TxUpdate}; use bitcoin::{ @@ -82,10 +82,9 @@ fn do_bench(indexed_tx_graph: &KeychainTxGraph, chain: &LocalChain) { .unwrap(); // 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().block_id(), Default::default()) .balance(op, |_, _| false, 1); assert_eq!(bal.total(), AMOUNT * TX_CT as u64); } diff --git a/crates/chain/tests/test_canonical_view.rs b/crates/chain/tests/test_canonical_view.rs index 3c0d54381c..2cbe021033 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, ConfirmationBlockTime, TxGraph}; use bdk_testenv::{hash, utils::new_tx}; use bitcoin::{Amount, BlockHash, OutPoint, ScriptBuf, Transaction, TxIn, TxOut}; @@ -53,9 +53,8 @@ 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()); + chain.canonical_view(&tx_graph, chain.tip().block_id(), Default::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 canonical_view = + chain.canonical_view(&tx_graph, chain.tip().block_id(), Default::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 canonical_view = + chain.canonical_view(&tx_graph, chain.tip().block_id(), Default::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 2a33f3b1c3..ad65bab808 100644 --- a/crates/chain/tests/test_indexed_tx_graph.rs +++ b/crates/chain/tests/test_indexed_tx_graph.rs @@ -10,8 +10,7 @@ use bdk_chain::{ indexer::keychain_txout::KeychainTxOutIndex, local_chain::LocalChain, spk_txout::SpkTxOutIndex, - tx_graph, Balance, CanonicalizationParams, ChainPosition, ConfirmationBlockTime, DescriptorExt, - SpkIterator, + tx_graph, Balance, ChainPosition, ConfirmationBlockTime, DescriptorExt, SpkIterator, }; use bdk_testenv::{ anyhow::{self}, @@ -469,23 +468,23 @@ 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 canonical_view = + local_chain.canonical_view(graph.graph(), chain_tip, Default::default()); + + let txouts = canonical_view .filter_outpoints(graph.index.outpoints().iter().cloned()) .collect::>(); - let utxos = graph - .canonical_view(&local_chain, chain_tip, CanonicalizationParams::default()) + let utxos = canonical_view .filter_unspent_outpoints(graph.index.outpoints().iter().cloned()) .collect::>(); - let balance = graph - .canonical_view(&local_chain, chain_tip, CanonicalizationParams::default()) - .balance( - graph.index.outpoints().iter().cloned(), - |_, txout| trusted_spks.contains(&txout.txout.script_pubkey), - 1, - ); + let balance = canonical_view.balance( + graph.index.outpoints().iter().cloned(), + |_, txout| trusted_spks.contains(&txout.txout.script_pubkey), + 0, + ); let confirmed_txouts_txid = txouts .iter() @@ -788,12 +787,8 @@ fn test_get_chain_position() { } // check chain position - let chain_pos = graph - .canonical_view( - chain, - chain.tip().block_id(), - CanonicalizationParams::default(), - ) + let chain_pos = chain + .canonical_view(graph.graph(), chain.tip().block_id(), Default::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 b2a3596085..3cbfe3414f 100644 --- a/crates/chain/tests/test_tx_graph.rs +++ b/crates/chain/tests/test_tx_graph.rs @@ -2,7 +2,7 @@ #[macro_use] mod common; -use bdk_chain::{collections::*, BlockId, CanonicalizationParams, ConfirmationBlockTime}; +use bdk_chain::{collections::*, BlockId, ConfirmationBlockTime}; use bdk_chain::{ local_chain::LocalChain, tx_graph::{self, CalculateFeeError}, @@ -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(), Default::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(), Default::default()) .txs() .map(|canon_tx| (canon_tx.txid, canon_tx.pos)) .collect() @@ -1197,35 +1197,23 @@ 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 canonical_txs: Vec<_> = chain + .canonical_view(&graph, chain.tip().block_id(), Default::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 mut canonical_txs = canonical_view.txs(); + let view = chain.canonical_view(&graph, chain.tip().block_id(), Default::default()); + let mut canonical_txs = 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 canonical_txids: Vec<_> = chain + .canonical_view(&graph, chain.tip().block_id(), Default::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 38f21365c3..f1f1669844 100644 --- a/crates/chain/tests/test_tx_graph_conflicts.rs +++ b/crates/chain/tests/test_tx_graph_conflicts.rs @@ -970,9 +970,13 @@ 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 canonical_view = local_chain.canonical_view( + &env.tx_graph, + chain_tip, + env.canonicalization_params.clone(), + ); + + let txs = canonical_view .txs() .map(|tx| tx.txid) .collect::>(); @@ -987,9 +991,7 @@ fn test_tx_conflict_handling() { scenario.name ); - let txouts = env - .tx_graph - .canonical_view(&local_chain, chain_tip, env.canonicalization_params.clone()) + let txouts = canonical_view .filter_outpoints(env.indexer.outpoints().iter().cloned()) .map(|(_, full_txout)| full_txout.outpoint) .collect::>(); @@ -1007,9 +1009,7 @@ fn test_tx_conflict_handling() { scenario.name ); - let utxos = env - .tx_graph - .canonical_view(&local_chain, chain_tip, env.canonicalization_params.clone()) + let utxos = canonical_view .filter_unspent_outpoints(env.indexer.outpoints().iter().cloned()) .map(|(_, full_txout)| full_txout.outpoint) .collect::>(); @@ -1027,18 +1027,15 @@ fn test_tx_conflict_handling() { scenario.name ); - let balance = env - .tx_graph - .canonical_view(&local_chain, chain_tip, env.canonicalization_params.clone()) - .balance( - env.indexer.outpoints().iter().cloned(), - |_, txout| { - env.indexer - .index_of_spk(txout.txout.script_pubkey.as_script()) - .is_some() - }, - 0, - ); + let balance = canonical_view.balance( + env.indexer.outpoints().iter().cloned(), + |_, txout| { + env.indexer + .index_of_spk(txout.txout.script_pubkey.as_script()) + .is_some() + }, + 0, + ); assert_eq!( balance, scenario.exp_balance, "\n[{}] 'balance' failed", diff --git a/crates/electrum/tests/test_electrum.rs b/crates/electrum/tests/test_electrum.rs index ef94c62f3e..c569b065da 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, ConfirmationBlockTime, IndexedTxGraph, Indexer, Merge, TxGraph, }; use bdk_core::bitcoin::{ key::{Secp256k1, UntweakedPublicKey}, @@ -56,11 +55,14 @@ fn get_balance( recv_chain: &LocalChain, recv_graph: &IndexedTxGraph>, ) -> 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(), + recv_chain.tip().block_id(), + Default::default(), + ) + .balance(outpoints, |_, _| true, 0); Ok(balance) } @@ -174,8 +176,7 @@ 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)?; @@ -204,8 +205,7 @@ 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 e6903591c7..2941e2a669 100644 --- a/crates/esplora/tests/async_ext.rs +++ b/crates/esplora/tests/async_ext.rs @@ -100,8 +100,7 @@ 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 +129,7 @@ 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 4619901cc8..8db59947fa 100644 --- a/crates/esplora/tests/blocking_ext.rs +++ b/crates/esplora/tests/blocking_ext.rs @@ -97,8 +97,7 @@ 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 +126,7 @@ 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 new file mode 100644 index 0000000000..118483ecbb --- /dev/null +++ b/examples/example_bitcoind_rpc_polling/src/main.rs @@ -0,0 +1,415 @@ +use std::{ + path::PathBuf, + sync::{ + atomic::{AtomicBool, Ordering}, + Arc, + }, + time::{Duration, Instant}, +}; + +use bdk_bitcoind_rpc::{ + bitcoincore_rpc::{Auth, Client, RpcApi}, + Emitter, +}; +use bdk_chain::{bitcoin::Block, local_chain, Merge}; +use example_cli::{ + anyhow, + clap::{self, Args, Subcommand}, + ChangeSet, Keychain, +}; + +const DB_MAGIC: &[u8] = b"bdk_example_rpc"; +const DB_PATH: &str = ".bdk_example_rpc.db"; + +/// The mpsc channel bound for emissions from [`Emitter`]. +const CHANNEL_BOUND: usize = 10; +/// Delay for printing status to stdout. +const STDOUT_PRINT_DELAY: Duration = Duration::from_secs(6); +/// Delay between mempool emissions. +const MEMPOOL_EMIT_DELAY: Duration = Duration::from_secs(30); +/// Delay for committing to persistence. +const DB_COMMIT_DELAY: Duration = Duration::from_secs(60); + +#[derive(Debug)] +enum Emission { + Block(bdk_bitcoind_rpc::BlockEvent), + Mempool(bdk_bitcoind_rpc::MempoolEvent), + Tip(u32), +} + +#[derive(Args, Debug, Clone)] +struct RpcArgs { + /// RPC URL + #[clap(env = "RPC_URL", long, default_value = "127.0.0.1:8332")] + url: String, + /// RPC auth cookie file + #[clap(env = "RPC_COOKIE", long)] + rpc_cookie: Option, + /// RPC auth username + #[clap(env = "RPC_USER", long)] + rpc_user: Option, + /// RPC auth password + #[clap(env = "RPC_PASS", long)] + rpc_password: Option, + /// Starting block height to fallback to if no point of agreement if found + #[clap(env = "FALLBACK_HEIGHT", long, default_value = "0")] + fallback_height: u32, +} + +impl From for Auth { + fn from(args: RpcArgs) -> Self { + match (args.rpc_cookie, args.rpc_user, args.rpc_password) { + (None, None, None) => Self::None, + (Some(path), _, _) => Self::CookieFile(path), + (_, Some(user), Some(pass)) => Self::UserPass(user, pass), + (_, Some(_), None) => panic!("rpc auth: missing rpc_pass"), + (_, None, Some(_)) => panic!("rpc auth: missing rpc_user"), + } + } +} + +impl RpcArgs { + fn new_client(&self) -> anyhow::Result { + Ok(Client::new( + &self.url, + match (&self.rpc_cookie, &self.rpc_user, &self.rpc_password) { + (None, None, None) => Auth::None, + (Some(path), _, _) => Auth::CookieFile(path.clone()), + (_, Some(user), Some(pass)) => Auth::UserPass(user.clone(), pass.clone()), + (_, Some(_), None) => panic!("rpc auth: missing rpc_pass"), + (_, None, Some(_)) => panic!("rpc auth: missing rpc_user"), + }, + )?) + } +} + +#[derive(Subcommand, Debug, Clone)] +enum RpcCommands { + /// Syncs local state with remote state via RPC (starting from last point of agreement) and + /// stores/indexes relevant transactions + Sync { + #[clap(flatten)] + rpc_args: RpcArgs, + }, + /// Sync by having the emitter logic in a separate thread + Live { + #[clap(flatten)] + rpc_args: RpcArgs, + }, +} + +fn main() -> anyhow::Result<()> { + let start = Instant::now(); + + let example_cli::Init { + args, + graph, + chain, + db, + network, + } = match example_cli::init_or_load::(DB_MAGIC, DB_PATH)? { + Some(init) => init, + None => return Ok(()), + }; + + let rpc_cmd = match args.command { + example_cli::Commands::ChainSpecific(rpc_cmd) => rpc_cmd, + general_cmd => { + return example_cli::handle_commands( + &graph, + &chain, + &db, + network, + |rpc_args, tx| { + let client = rpc_args.new_client()?; + client.send_raw_transaction(tx)?; + Ok(()) + }, + general_cmd, + ); + } + }; + + match rpc_cmd { + RpcCommands::Sync { rpc_args } => { + let RpcArgs { + fallback_height, .. + } = rpc_args; + + let rpc_client = rpc_args.new_client()?; + let mut emitter = { + let chain = chain.lock().unwrap(); + let graph = graph.lock().unwrap(); + Emitter::new( + &rpc_client, + chain.tip(), + fallback_height, + { + chain.canonical_view( + graph.graph(), + chain.tip().block_id(), + Default::default(), + ) + } + .txs() + .filter(|tx| tx.pos.is_unconfirmed()) + .map(|tx| tx.tx), + ) + }; + let mut db_stage = ChangeSet::default(); + + let mut last_db_commit = Instant::now(); + let mut last_print = Instant::now(); + + while let Some(emission) = emitter.next_block()? { + let height = emission.block_height(); + + let mut chain = chain.lock().unwrap(); + let mut graph = graph.lock().unwrap(); + + let chain_changeset = chain + .apply_update(emission.checkpoint) + .expect("must always apply as we receive blocks in order from emitter"); + let graph_changeset = graph.apply_block_relevant(&emission.block, height); + db_stage.merge(ChangeSet { + local_chain: chain_changeset, + tx_graph: graph_changeset.tx_graph, + indexer: graph_changeset.indexer, + ..Default::default() + }); + + // commit staged db changes in intervals + if last_db_commit.elapsed() >= DB_COMMIT_DELAY { + let db = &mut *db.lock().unwrap(); + last_db_commit = Instant::now(); + if let Some(changeset) = db_stage.take() { + db.append(&changeset)?; + } + println!( + "[{:>10}s] committed to db (took {}s)", + start.elapsed().as_secs_f32(), + last_db_commit.elapsed().as_secs_f32() + ); + } + + // print synced-to height and current balance in intervals + if last_print.elapsed() >= STDOUT_PRINT_DELAY { + last_print = Instant::now(); + let synced_to = chain.tip(); + let balance = { + { + chain.canonical_view( + graph.graph(), + synced_to.block_id(), + Default::default(), + ) + } + .balance( + graph.index.outpoints().iter().cloned(), + |(k, _), _| k == &Keychain::Internal, + 0, + ) + }; + println!( + "[{:>10}s] synced to {} @ {} | total: {}", + start.elapsed().as_secs_f32(), + synced_to.hash(), + synced_to.height(), + balance.total() + ); + } + } + + let mempool_txs = emitter.mempool()?; + let graph_changeset = graph + .lock() + .unwrap() + .batch_insert_relevant_unconfirmed(mempool_txs.update); + { + let db = &mut *db.lock().unwrap(); + db_stage.merge(ChangeSet { + tx_graph: graph_changeset.tx_graph, + indexer: graph_changeset.indexer, + ..Default::default() + }); + if let Some(changeset) = db_stage.take() { + db.append(&changeset)?; + } + } + } + RpcCommands::Live { rpc_args } => { + let RpcArgs { + fallback_height, .. + } = rpc_args; + let sigterm_flag = start_ctrlc_handler(); + + let rpc_client = Arc::new(rpc_args.new_client()?); + let mut emitter = { + let chain = chain.lock().unwrap(); + let graph = graph.lock().unwrap(); + Emitter::new( + rpc_client.clone(), + chain.tip(), + fallback_height, + { + chain.canonical_view( + graph.graph(), + chain.tip().block_id(), + Default::default(), + ) + } + .txs() + .filter(|tx| tx.pos.is_unconfirmed()) + .map(|tx| tx.tx), + ) + }; + + println!( + "[{:>10}s] starting emitter thread...", + start.elapsed().as_secs_f32() + ); + let (tx, rx) = std::sync::mpsc::sync_channel::(CHANNEL_BOUND); + let emission_jh = std::thread::spawn(move || -> anyhow::Result<()> { + let mut block_count = rpc_client.get_block_count()? as u32; + tx.send(Emission::Tip(block_count))?; + + loop { + match emitter.next_block()? { + Some(block_emission) => { + let height = block_emission.block_height(); + if sigterm_flag.load(Ordering::Acquire) { + break; + } + if height > block_count { + block_count = rpc_client.get_block_count()? as u32; + tx.send(Emission::Tip(block_count))?; + } + tx.send(Emission::Block(block_emission))?; + } + None => { + if await_flag(&sigterm_flag, MEMPOOL_EMIT_DELAY) { + break; + } + println!("preparing mempool emission..."); + let now = Instant::now(); + tx.send(Emission::Mempool(emitter.mempool()?))?; + println!("mempool emission prepared in {}s", now.elapsed().as_secs()); + continue; + } + }; + } + + println!("emitter thread shutting down..."); + Ok(()) + }); + + let mut tip_height = 0_u32; + let mut last_db_commit = Instant::now(); + let mut last_print = Option::::None; + let mut db_stage = ChangeSet::default(); + + for emission in rx { + let mut graph = graph.lock().unwrap(); + let mut chain = chain.lock().unwrap(); + + let (chain_changeset, graph_changeset) = match emission { + Emission::Block(block_emission) => { + let height = block_emission.block_height(); + let chain_changeset = chain + .apply_update(block_emission.checkpoint) + .expect("must always apply as we receive blocks in order from emitter"); + let graph_changeset = + graph.apply_block_relevant(&block_emission.block, height); + (chain_changeset, graph_changeset) + } + Emission::Mempool(mempool_txs) => { + let mut graph_changeset = + graph.batch_insert_relevant_unconfirmed(mempool_txs.update.clone()); + graph_changeset + .merge(graph.batch_insert_relevant_evicted_at(mempool_txs.evicted)); + (local_chain::ChangeSet::default(), graph_changeset) + } + Emission::Tip(h) => { + tip_height = h; + continue; + } + }; + + db_stage.merge(ChangeSet { + local_chain: chain_changeset, + tx_graph: graph_changeset.tx_graph, + indexer: graph_changeset.indexer, + ..Default::default() + }); + + if last_db_commit.elapsed() >= DB_COMMIT_DELAY { + let db = &mut *db.lock().unwrap(); + last_db_commit = Instant::now(); + if let Some(changeset) = db_stage.take() { + db.append(&changeset)?; + } + println!( + "[{:>10}s] committed to db (took {}s)", + start.elapsed().as_secs_f32(), + last_db_commit.elapsed().as_secs_f32() + ); + } + + if last_print.map_or(Duration::MAX, |i| i.elapsed()) >= STDOUT_PRINT_DELAY { + last_print = Some(Instant::now()); + let synced_to = chain.tip(); + let balance = { + { + chain.canonical_view( + graph.graph(), + synced_to.block_id(), + Default::default(), + ) + } + .balance( + graph.index.outpoints().iter().cloned(), + |(k, _), _| k == &Keychain::Internal, + 0, + ) + }; + println!( + "[{:>10}s] synced to {} @ {} / {} | total: {}", + start.elapsed().as_secs_f32(), + synced_to.hash(), + synced_to.height(), + tip_height, + balance.total() + ); + } + } + + emission_jh.join().expect("must join emitter thread")?; + } + } + + Ok(()) +} + +#[allow(dead_code)] +fn start_ctrlc_handler() -> Arc { + let flag = Arc::new(AtomicBool::new(false)); + let cloned_flag = flag.clone(); + + ctrlc::set_handler(move || cloned_flag.store(true, Ordering::Release)); + + flag +} + +#[allow(dead_code)] +fn await_flag(flag: &AtomicBool, duration: Duration) -> bool { + let start = Instant::now(); + loop { + if flag.load(Ordering::Acquire) { + return true; + } + if start.elapsed() >= duration { + return false; + } + std::thread::sleep(Duration::from_secs(1)); + } +} diff --git a/examples/example_cli/src/lib.rs b/examples/example_cli/src/lib.rs new file mode 100644 index 0000000000..3b660ea8fd --- /dev/null +++ b/examples/example_cli/src/lib.rs @@ -0,0 +1,942 @@ +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; +use std::sync::Mutex; + +use anyhow::bail; +use anyhow::Context; +use bdk_chain::bitcoin::{ + absolute, address::NetworkUnchecked, bip32, consensus, constants, hex::DisplayHex, relative, + secp256k1::Secp256k1, transaction, Address, Amount, Network, NetworkKind, Psbt, Sequence, + Transaction, TxIn, TxOut, +}; +use bdk_chain::miniscript::{ + plan::{Assets, Plan}, + psbt::PsbtExt, + Descriptor, DescriptorPublicKey, ForEachKey, +}; +use bdk_chain::ConfirmationBlockTime; +use bdk_chain::{ + indexer::keychain_txout::{self, KeychainTxOutIndex}, + local_chain::{self, LocalChain}, + tx_graph, ChainOracle, DescriptorExt, FullTxOut, IndexedTxGraph, Merge, +}; +use bdk_coin_select::{ + metrics::LowestFee, Candidate, ChangePolicy, CoinSelector, DrainWeights, FeeRate, Target, + TargetFee, TargetOutputs, +}; +use bdk_file_store::Store; +use clap::{Parser, Subcommand}; +use rand::prelude::*; + +pub use anyhow; +pub use clap; + +/// Alias for a `IndexedTxGraph` with specific `Anchor` and `Indexer`. +pub type KeychainTxGraph = IndexedTxGraph>; + +/// ChangeSet +#[derive(Default, Debug, Clone, PartialEq, serde::Deserialize, serde::Serialize)] +pub struct ChangeSet { + /// Descriptor for recipient addresses. + pub descriptor: Option>, + /// Descriptor for change addresses. + pub change_descriptor: Option>, + /// Stores the network type of the transaction data. + pub network: Option, + /// Changes to the [`LocalChain`]. + pub local_chain: local_chain::ChangeSet, + /// Changes to [`TxGraph`](tx_graph::TxGraph). + pub tx_graph: tx_graph::ChangeSet, + /// Changes to [`KeychainTxOutIndex`]. + pub indexer: keychain_txout::ChangeSet, +} + +#[derive(Parser)] +#[clap(author, version, about, long_about = None)] +#[clap(propagate_version = true)] +pub struct Args { + #[clap(subcommand)] + pub command: Commands, +} + +#[derive(Subcommand, Debug, Clone)] +pub enum Commands { + /// Initialize a new data store. + Init { + /// Network + #[clap(long, short, default_value = "signet")] + network: Network, + /// Descriptor + #[clap(env = "DESCRIPTOR")] + descriptor: String, + /// Change descriptor + #[clap(long, short, env = "CHANGE_DESCRIPTOR")] + change_descriptor: Option, + }, + #[clap(flatten)] + ChainSpecific(CS), + /// Address generation and inspection. + Address { + #[clap(subcommand)] + addr_cmd: AddressCmd, + }, + /// Get the wallet balance. + Balance, + /// TxOut related commands. + #[clap(name = "txout")] + TxOut { + #[clap(subcommand)] + txout_cmd: TxOutCmd, + }, + /// PSBT operations + Psbt { + #[clap(subcommand)] + psbt_cmd: PsbtCmd, + }, + /// Generate new BIP86 descriptors. + Generate { + /// Network + #[clap(long, short, default_value = "signet")] + network: Network, + }, +} + +#[derive(Subcommand, Debug, Clone)] +pub enum AddressCmd { + /// Get the next unused address. + Next, + /// Get a new address regardless of the existing unused addresses. + New, + /// List all addresses + List { + /// List change addresses + #[clap(long)] + change: bool, + }, + /// Get last revealed address index for each keychain. + Index, +} + +#[derive(Subcommand, Debug, Clone)] +pub enum TxOutCmd { + /// List transaction outputs. + List { + /// Return only spent outputs. + #[clap(short, long)] + spent: bool, + /// Return only unspent outputs. + #[clap(short, long)] + unspent: bool, + /// Return only confirmed outputs. + #[clap(long)] + confirmed: bool, + /// Return only unconfirmed outputs. + #[clap(long)] + unconfirmed: bool, + }, +} + +#[derive(Subcommand, Debug, Clone)] +pub enum PsbtCmd { + /// Create a new PSBT. + New { + /// Amount to send in satoshis + #[clap(required = true)] + value: u64, + /// Recipient address + #[clap(required = true)] + address: Address, + /// Set the feerate of the tx (sat/vbyte) + #[clap(long, short, default_value = "1.0")] + feerate: Option, + /// Set max absolute timelock (from consensus value) + #[clap(long, short)] + after: Option, + /// Set max relative timelock (from consensus value) + #[clap(long, short)] + older: Option, + /// Coin selection algorithm + #[clap(long, short, default_value = "bnb")] + coin_select: CoinSelectionAlgo, + /// Debug print the PSBT + #[clap(long, short)] + debug: bool, + }, + /// Sign with a hot signer + Sign { + /// Private descriptor [env: DESCRIPTOR=] + #[clap(long, short)] + descriptor: Option, + /// PSBT + #[clap(long, short, required = true)] + psbt: String, + }, + /// Extract transaction + Extract { + /// PSBT + #[clap(long, short, required = true)] + psbt: String, + /// Whether to try broadcasting the tx + #[clap(long, short)] + broadcast: bool, + #[clap(flatten)] + chain_specific: S, + }, +} + +#[derive( + Debug, Clone, Copy, PartialOrd, Ord, PartialEq, Eq, serde::Deserialize, serde::Serialize, +)] +pub enum Keychain { + External, + Internal, +} + +impl fmt::Display for Keychain { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Keychain::External => write!(f, "external"), + Keychain::Internal => write!(f, "internal"), + } + } +} + +#[derive(Clone, Debug, Default)] +pub enum CoinSelectionAlgo { + LargestFirst, + SmallestFirst, + OldestFirst, + NewestFirst, + #[default] + BranchAndBound, +} + +impl FromStr for CoinSelectionAlgo { + type Err = anyhow::Error; + + fn from_str(s: &str) -> Result { + use CoinSelectionAlgo::*; + Ok(match s { + "largest-first" => LargestFirst, + "smallest-first" => SmallestFirst, + "oldest-first" => OldestFirst, + "newest-first" => NewestFirst, + "bnb" => BranchAndBound, + unknown => bail!("unknown coin selection algorithm '{unknown}'"), + }) + } +} + +impl fmt::Display for CoinSelectionAlgo { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + use CoinSelectionAlgo::*; + write!( + f, + "{}", + match self { + LargestFirst => "largest-first", + SmallestFirst => "smallest-first", + OldestFirst => "oldest-first", + NewestFirst => "newest-first", + BranchAndBound => "bnb", + } + ) + } +} + +// Records changes to the internal keychain when we +// have to include a change output during tx creation. +#[derive(Debug)] +pub struct ChangeInfo { + pub change_keychain: Keychain, + pub indexer: keychain_txout::ChangeSet, + pub index: u32, +} + +pub fn create_tx( + graph: &mut KeychainTxGraph, + chain: &LocalChain, + assets: &Assets, + cs_algorithm: CoinSelectionAlgo, + address: Address, + value: u64, + feerate: f32, +) -> anyhow::Result<(Psbt, Option)> { + let mut changeset = keychain_txout::ChangeSet::default(); + + // get planned utxos + let mut plan_utxos = planned_utxos(graph, chain, assets)?; + + // sort utxos if cs-algo requires it + match cs_algorithm { + CoinSelectionAlgo::LargestFirst => { + 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::NewestFirst => { + plan_utxos.sort_by_key(|(_, utxo)| cmp::Reverse(utxo.chain_position)) + } + CoinSelectionAlgo::BranchAndBound => plan_utxos.shuffle(&mut thread_rng()), + } + + // build candidate set + let candidates: Vec = plan_utxos + .iter() + .map(|(plan, utxo)| { + Candidate::new( + utxo.txout.value.to_sat(), + plan.satisfaction_weight() as u64, + plan.witness_version().is_some(), + ) + }) + .collect(); + + // create recipient output(s) + let mut outputs = vec![TxOut { + value: Amount::from_sat(value), + script_pubkey: address.script_pubkey(), + }]; + + let (change_keychain, _) = graph + .index + .keychains() + .last() + .expect("must have a keychain"); + + let ((change_index, change_script), index_changeset) = graph + .index + .next_unused_spk(change_keychain) + .expect("Must exist"); + changeset.merge(index_changeset); + + let mut change_output = TxOut { + value: Amount::ZERO, + script_pubkey: change_script, + }; + + let change_desc = graph + .index + .keychains() + .find(|(k, _)| k == &change_keychain) + .expect("must exist") + .1; + + let min_drain_value = change_desc.dust_value().to_sat(); + + let target = Target { + outputs: TargetOutputs::fund_outputs( + outputs + .iter() + .map(|output| (output.weight().to_wu(), output.value.to_sat())), + ), + fee: TargetFee { + rate: FeeRate::from_sat_per_vb(feerate), + ..Default::default() + }, + }; + + let change_policy = ChangePolicy { + min_value: min_drain_value, + drain_weights: DrainWeights::TR_KEYSPEND, + }; + + // run coin selection + let mut selector = CoinSelector::new(&candidates); + match cs_algorithm { + CoinSelectionAlgo::BranchAndBound => { + let metric = LowestFee { + target, + long_term_feerate: FeeRate::from_sat_per_vb(10.0), + change_policy, + }; + match selector.run_bnb(metric, 10_000) { + Ok(_) => {} + Err(_) => selector + .select_until_target_met(target) + .context("selecting coins")?, + } + } + _ => selector + .select_until_target_met(target) + .context("selecting coins")?, + } + + // get the selected plan utxos + let selected: Vec<_> = selector.apply_selection(&plan_utxos).collect(); + + // if the selection tells us to use change and the change value is sufficient, we add it as an + // output + let mut change_info = Option::::None; + let drain = selector.drain(target, change_policy); + if drain.value > min_drain_value { + change_output.value = Amount::from_sat(drain.value); + outputs.push(change_output); + change_info = Some(ChangeInfo { + change_keychain, + indexer: changeset, + index: change_index, + }); + outputs.shuffle(&mut thread_rng()); + } + + let unsigned_tx = Transaction { + version: transaction::Version::TWO, + lock_time: assets + .absolute_timelock + .unwrap_or(absolute::LockTime::from_height( + chain.get_chain_tip()?.height, + )?), + input: selected + .iter() + .map(|(plan, utxo)| TxIn { + previous_output: utxo.outpoint, + sequence: plan + .relative_timelock + .map_or(Sequence::ENABLE_RBF_NO_LOCKTIME, Sequence::from), + ..Default::default() + }) + .collect(), + output: outputs, + }; + + // update psbt with plan + let mut psbt = Psbt::from_unsigned_tx(unsigned_tx)?; + for (i, (plan, utxo)) in selected.iter().enumerate() { + let psbt_input = &mut psbt.inputs[i]; + plan.update_psbt_input(psbt_input); + psbt_input.witness_utxo = Some(utxo.txout.clone()); + } + + Ok((psbt, change_info)) +} + +// Alias the elements of `planned_utxos` +pub type PlanUtxo = (Plan, FullTxOut); + +pub fn planned_utxos( + graph: &KeychainTxGraph, + chain: &LocalChain, + assets: &Assets, +) -> Result, Infallible> { + let outpoints = graph.index.outpoints(); + chain + .canonical_view(graph.graph(), chain.tip().block_id(), Default::default()) + .filter_unspent_outpoints(outpoints.iter().cloned()) + .filter_map(|((k, i), full_txo)| -> Option> { + let desc = graph + .index + .keychains() + .find(|(keychain, _)| *keychain == k) + .expect("keychain must exist") + .1 + .at_derivation_index(i) + .expect("i can't be hardened"); + + let plan = desc.plan(assets).ok()?; + + Some(Ok((plan, full_txo))) + }) + .collect() +} + +pub fn handle_commands( + graph: &Mutex, + chain: &Mutex, + db: &Mutex>, + network: Network, + broadcast_fn: impl FnOnce(S, &Transaction) -> anyhow::Result<()>, + cmd: Commands, +) -> anyhow::Result<()> { + match cmd { + Commands::Init { .. } => unreachable!("handled by init command"), + Commands::Generate { .. } => unreachable!("handled by generate command"), + Commands::ChainSpecific(_) => unreachable!("example code should handle this!"), + Commands::Address { addr_cmd } => { + let graph = &mut *graph.lock().unwrap(); + let index = &mut graph.index; + + match addr_cmd { + AddressCmd::Next | AddressCmd::New => { + let spk_chooser = match addr_cmd { + AddressCmd::Next => KeychainTxOutIndex::next_unused_spk, + AddressCmd::New => KeychainTxOutIndex::reveal_next_spk, + _ => unreachable!("only these two variants exist in match arm"), + }; + + let ((spk_i, spk), index_changeset) = + spk_chooser(index, Keychain::External).expect("Must exist"); + let db = &mut *db.lock().unwrap(); + db.append(&ChangeSet { + indexer: index_changeset, + ..Default::default() + })?; + let addr = Address::from_script(spk.as_script(), network)?; + println!("[address @ {spk_i}] {addr}"); + Ok(()) + } + AddressCmd::Index => { + for (keychain, derivation_index) in index.last_revealed_indices() { + println!("{keychain:?}: {derivation_index}"); + } + Ok(()) + } + AddressCmd::List { change } => { + let target_keychain = match change { + true => Keychain::Internal, + false => Keychain::External, + }; + for (spk_i, spk) in index.revealed_keychain_spks(target_keychain) { + let address = Address::from_script(spk.as_script(), network) + .expect("should always be able to derive address"); + println!( + "{:?} {} used:{}", + spk_i, + address, + index.is_used(target_keychain, spk_i) + ); + } + Ok(()) + } + } + } + Commands::Balance => { + let graph = &*graph.lock().unwrap(); + let chain = &*chain.lock().unwrap(); + fn print_balances<'a>( + title_str: &'a str, + items: impl IntoIterator, + ) { + println!("{title_str}:"); + for (name, amount) in items.into_iter() { + println!(" {:<10} {:>12} sats", name, amount.to_sat()) + } + } + + let balance = chain + .canonical_view(graph.graph(), chain.tip().block_id(), Default::default()) + .balance( + graph.index.outpoints().iter().cloned(), + |(k, _), _| k == &Keychain::Internal, + 1, + ); + + let confirmed_total = balance.confirmed + balance.immature; + let unconfirmed_total = balance.untrusted_pending + balance.trusted_pending; + + print_balances( + "confirmed", + [ + ("total", confirmed_total), + ("spendable", balance.confirmed), + ("immature", balance.immature), + ], + ); + print_balances( + "unconfirmed", + [ + ("total", unconfirmed_total), + ("trusted", balance.trusted_pending), + ("untrusted", balance.untrusted_pending), + ], + ); + + Ok(()) + } + Commands::TxOut { txout_cmd } => { + let graph = &*graph.lock().unwrap(); + let chain = &*chain.lock().unwrap(); + let chain_tip = chain.get_chain_tip()?; + let outpoints = graph.index.outpoints(); + + match txout_cmd { + TxOutCmd::List { + spent, + unspent, + confirmed, + unconfirmed, + } => { + let txouts = chain + .canonical_view(graph.graph(), chain_tip, Default::default()) + .filter_outpoints(outpoints.iter().cloned()) + .filter(|(_, full_txo)| match (spent, unspent) { + (true, false) => full_txo.spent_by.is_some(), + (false, true) => full_txo.spent_by.is_none(), + _ => true, + }) + .filter(|(_, full_txo)| match (confirmed, unconfirmed) { + (true, false) => full_txo.chain_position.is_confirmed(), + (false, true) => !full_txo.chain_position.is_confirmed(), + _ => true, + }) + .collect::>(); + + for (spk_i, full_txo) in txouts { + let addr = Address::from_script(&full_txo.txout.script_pubkey, network)?; + println!( + "{:?} {} {} {} spent:{:?}", + spk_i, full_txo.txout.value, full_txo.outpoint, addr, full_txo.spent_by + ) + } + Ok(()) + } + } + } + Commands::Psbt { psbt_cmd } => match psbt_cmd { + PsbtCmd::New { + value, + address, + feerate, + after, + older, + coin_select, + debug, + } => { + let address = address.require_network(network)?; + + let (psbt, change_info) = { + let mut graph = graph.lock().unwrap(); + let chain = chain.lock().unwrap(); + + // collect assets we can sign for + let mut pks = vec![]; + for (_, desc) in graph.index.keychains() { + desc.for_each_key(|k| { + pks.push(k.clone()); + true + }); + } + let mut assets = Assets::new().add(pks); + if let Some(n) = after { + assets = assets.after(absolute::LockTime::from_consensus(n)); + } + if let Some(n) = older { + assets = assets.older(relative::LockTime::from_consensus(n)?); + } + + create_tx( + &mut graph, + &chain, + &assets, + coin_select, + address, + value, + feerate.expect("must have feerate"), + )? + }; + + if let Some(ChangeInfo { + change_keychain, + indexer, + index, + }) = change_info + { + // We must first persist to disk the fact that we've got a new address from the + // change keychain so future scans will find the tx we're about to broadcast. + // If we're unable to persist this, then we don't want to broadcast. + { + let db = &mut *db.lock().unwrap(); + db.append(&ChangeSet { + indexer, + ..Default::default() + })?; + } + + // We don't want other callers/threads to use this address while we're using it + // but we also don't want to scan the tx we just created because it's not + // technically in the blockchain yet. + graph + .lock() + .unwrap() + .index + .mark_used(change_keychain, index); + } + + if debug { + dbg!(psbt); + } else { + // print base64 encoded psbt + let fee = psbt.fee()?.to_sat(); + let mut obj = serde_json::Map::new(); + obj.insert("psbt".to_string(), json!(psbt.to_string())); + obj.insert("fee".to_string(), json!(fee)); + println!("{}", serde_json::to_string_pretty(&obj)?); + }; + + Ok(()) + } + PsbtCmd::Sign { psbt, descriptor } => { + let mut psbt = Psbt::from_str(&psbt)?; + + let desc_str = match descriptor { + Some(s) => s, + None => env::var("DESCRIPTOR").context("unable to sign")?, + }; + + let secp = Secp256k1::new(); + let (_, keymap) = Descriptor::parse_descriptor(&secp, &desc_str)?; + + if keymap.is_empty() { + bail!("unable to sign") + } + + let _sign_res = psbt + .sign(&keymap, &secp) + .map_err(|errors| anyhow::anyhow!("failed to sign PSBT {errors:?}"))?; + + let mut obj = serde_json::Map::new(); + obj.insert("psbt".to_string(), json!(psbt.to_string())); + println!("{}", serde_json::to_string_pretty(&obj)?); + + Ok(()) + } + PsbtCmd::Extract { + broadcast, + chain_specific, + psbt, + } => { + let mut psbt = Psbt::from_str(&psbt)?; + psbt.finalize_mut(&Secp256k1::new()) + .map_err(|errors| anyhow::anyhow!("failed to finalize PSBT {errors:?}"))?; + + let tx = psbt.extract_tx()?; + + if broadcast { + let mut graph = graph.lock().unwrap(); + + match broadcast_fn(chain_specific, &tx) { + Ok(_) => { + println!("Broadcasted Tx: {}", tx.compute_txid()); + + let changeset = graph.insert_tx(tx); + + // We know the tx is at least unconfirmed now. Note if persisting here + // fails, it's not a big deal since we can + // always find it again from the blockchain. + db.lock().unwrap().append(&ChangeSet { + tx_graph: changeset.tx_graph, + indexer: changeset.indexer, + ..Default::default() + })?; + } + Err(e) => { + // We failed to broadcast, so allow our change address to be used in the + // future + let (change_keychain, _) = graph + .index + .keychains() + .last() + .expect("must have a keychain"); + let change_index = tx.output.iter().find_map(|txout| { + let spk = txout.script_pubkey.as_script(); + match graph.index.index_of_spk(spk) { + Some(&(keychain, index)) if keychain == change_keychain => { + Some((keychain, index)) + } + _ => None, + } + }); + if let Some((keychain, index)) = change_index { + graph.index.unmark_used(keychain, index); + } + bail!(e); + } + } + } else { + // encode raw tx hex + let hex = consensus::serialize(&tx).to_lower_hex_string(); + let mut obj = serde_json::Map::new(); + obj.insert("tx".to_string(), json!(hex)); + println!("{}", serde_json::to_string_pretty(&obj)?); + } + + Ok(()) + } + }, + } +} + +/// The initial state returned by [`init_or_load`]. +pub struct Init { + /// CLI args + pub args: Args, + /// Indexed graph + pub graph: Mutex, + /// Local chain + pub chain: Mutex, + /// Database + pub db: Mutex>, + /// Network + pub network: Network, +} + +/// Loads from persistence or creates new +pub fn init_or_load( + db_magic: &[u8], + db_path: &str, +) -> anyhow::Result>> { + let args = Args::::parse(); + + match args.command { + // initialize new db + Commands::Init { .. } => initialize::(args, db_magic, db_path).map(|_| None), + // generate keys + Commands::Generate { network } => generate_bip86_helper(network).map(|_| None), + // try load + _ => { + let (mut db, changeset) = + Store::::load(db_magic, db_path).context("could not open file store")?; + + let changeset = changeset.expect("should not be empty"); + let network = changeset.network.expect("changeset network"); + + let chain = Mutex::new({ + let (mut chain, _) = + LocalChain::from_genesis(constants::genesis_block(network).block_hash()); + chain.apply_changeset(&changeset.local_chain)?; + chain + }); + + let (graph, changeset) = IndexedTxGraph::from_changeset( + (changeset.tx_graph, changeset.indexer).into(), + |c| -> anyhow::Result<_> { + let mut indexer = + KeychainTxOutIndex::from_changeset(DEFAULT_LOOKAHEAD, true, c); + if let Some(desc) = changeset.descriptor { + indexer.insert_descriptor(Keychain::External, desc)?; + } + if let Some(change_desc) = changeset.change_descriptor { + indexer.insert_descriptor(Keychain::Internal, change_desc)?; + } + Ok(indexer) + }, + )?; + db.append(&ChangeSet { + indexer: changeset.indexer, + tx_graph: changeset.tx_graph, + ..Default::default() + })?; + + let graph = Mutex::new(graph); + let db = Mutex::new(db); + + Ok(Some(Init { + args, + graph, + chain, + db, + network, + })) + } + } +} + +/// Initialize db backend. +fn initialize(args: Args, db_magic: &[u8], db_path: &str) -> anyhow::Result<()> +where + CS: clap::Subcommand, + S: clap::Args, +{ + if let Commands::Init { + network, + descriptor, + change_descriptor, + } = args.command + { + let mut changeset = ChangeSet::default(); + + // parse descriptors + let secp = Secp256k1::new(); + let mut index = KeychainTxOutIndex::default(); + let (descriptor, _) = + Descriptor::::parse_descriptor(&secp, &descriptor)?; + let _ = index.insert_descriptor(Keychain::External, descriptor.clone())?; + changeset.descriptor = Some(descriptor); + + if let Some(desc) = change_descriptor { + let (change_descriptor, _) = + Descriptor::::parse_descriptor(&secp, &desc)?; + let _ = index.insert_descriptor(Keychain::Internal, change_descriptor.clone())?; + changeset.change_descriptor = Some(change_descriptor); + } + + // create new + let (_, chain_changeset) = + LocalChain::from_genesis(constants::genesis_block(network).block_hash()); + changeset.network = Some(network); + changeset.local_chain = chain_changeset; + let mut db = Store::::create(db_magic, db_path)?; + db.append(&changeset)?; + println!("New database {db_path}"); + } + + Ok(()) +} + +/// Generate BIP86 descriptors. +fn generate_bip86_helper(network: impl Into) -> anyhow::Result<()> { + let secp = Secp256k1::new(); + let mut seed = [0x00; 32]; + thread_rng().fill_bytes(&mut seed); + + let m = bip32::Xpriv::new_master(network, &seed)?; + let fp = m.fingerprint(&secp); + let path = if m.network.is_mainnet() { + "86h/0h/0h" + } else { + "86h/1h/0h" + }; + + let descriptors: Vec = [0, 1] + .iter() + .map(|i| format!("tr([{fp}]{m}/{path}/{i}/*)")) + .collect(); + let external_desc = &descriptors[0]; + let internal_desc = &descriptors[1]; + let (descriptor, keymap) = + >::parse_descriptor(&secp, external_desc)?; + let (internal_descriptor, internal_keymap) = + >::parse_descriptor(&secp, internal_desc)?; + println!("Public"); + println!("{descriptor}"); + println!("{internal_descriptor}"); + println!("\nPrivate"); + println!("{}", descriptor.to_string_with_secret(&keymap)); + println!( + "{}", + internal_descriptor.to_string_with_secret(&internal_keymap) + ); + + Ok(()) +} + +impl Merge for ChangeSet { + fn merge(&mut self, other: Self) { + if other.descriptor.is_some() { + self.descriptor = other.descriptor; + } + if other.change_descriptor.is_some() { + self.change_descriptor = other.change_descriptor; + } + if other.network.is_some() { + self.network = other.network; + } + Merge::merge(&mut self.local_chain, other.local_chain); + Merge::merge(&mut self.tx_graph, other.tx_graph); + Merge::merge(&mut self.indexer, other.indexer); + } + + fn is_empty(&self) -> bool { + self.descriptor.is_none() + && self.change_descriptor.is_none() + && self.network.is_none() + && self.local_chain.is_empty() + && self.tx_graph.is_empty() + && self.indexer.is_empty() + } +} diff --git a/examples/example_electrum/src/main.rs b/examples/example_electrum/src/main.rs new file mode 100644 index 0000000000..d111a09fd1 --- /dev/null +++ b/examples/example_electrum/src/main.rs @@ -0,0 +1,294 @@ +use std::io::{self, Write}; + +use bdk_chain::{ + bitcoin::Network, + collections::BTreeSet, + indexed_tx_graph, + spk_client::{FullScanRequest, SyncRequest}, + ConfirmationBlockTime, Merge, +}; +use bdk_electrum::{ + electrum_client::{self, Client, ElectrumApi}, + BdkElectrumClient, +}; +use example_cli::{ + self, + anyhow::{self, Context}, + clap::{self, Parser, Subcommand}, + ChangeSet, Keychain, +}; + +const DB_MAGIC: &[u8] = b"bdk_example_electrum"; +const DB_PATH: &str = ".bdk_example_electrum.db"; + +#[derive(Subcommand, Debug, Clone)] +enum ElectrumCommands { + /// Scans the addresses in the wallet using the electrum API. + Scan { + /// When a gap this large has been found for a keychain, it will stop. + #[clap(long, default_value = "5")] + stop_gap: usize, + #[clap(flatten)] + scan_options: ScanOptions, + #[clap(flatten)] + electrum_args: ElectrumArgs, + }, + /// Scans particular addresses using the electrum API. + Sync { + /// Scan all the unused addresses. + #[clap(long)] + unused_spks: bool, + /// Scan every address that you have derived. + #[clap(long)] + all_spks: bool, + /// Scan unspent outpoints for spends or changes to confirmation status of residing tx. + #[clap(long)] + utxos: bool, + /// Scan unconfirmed transactions for updates. + #[clap(long)] + unconfirmed: bool, + #[clap(flatten)] + scan_options: ScanOptions, + #[clap(flatten)] + electrum_args: ElectrumArgs, + }, +} + +impl ElectrumCommands { + fn electrum_args(&self) -> ElectrumArgs { + match self { + ElectrumCommands::Scan { electrum_args, .. } => electrum_args.clone(), + ElectrumCommands::Sync { electrum_args, .. } => electrum_args.clone(), + } + } +} + +#[derive(clap::Args, Debug, Clone)] +pub struct ElectrumArgs { + /// The electrum url to use to connect to. If not provided it will use a default electrum + /// server for your chosen network. + electrum_url: Option, +} + +impl ElectrumArgs { + pub fn client(&self, network: Network) -> anyhow::Result { + let electrum_url = self.electrum_url.as_deref().unwrap_or(match network { + Network::Bitcoin => "ssl://electrum.blockstream.info:50002", + Network::Testnet => "ssl://electrum.blockstream.info:60002", + Network::Regtest => "tcp://localhost:60401", + Network::Signet => "tcp://signet-electrumx.wakiyamap.dev:50001", + _ => panic!("Unknown network"), + }); + let config = electrum_client::Config::builder() + .validate_domain(matches!(network, Network::Bitcoin)) + .build(); + + Ok(electrum_client::Client::from_config(electrum_url, config)?) + } +} + +#[derive(Parser, Debug, Clone, PartialEq)] +pub struct ScanOptions { + /// Set batch size for each script_history call to electrum client. + #[clap(long, default_value = "25")] + pub batch_size: usize, +} + +fn main() -> anyhow::Result<()> { + let example_cli::Init { + args, + graph, + chain, + db, + network, + } = match example_cli::init_or_load::(DB_MAGIC, DB_PATH)? { + Some(init) => init, + None => return Ok(()), + }; + + let electrum_cmd = match &args.command { + example_cli::Commands::ChainSpecific(electrum_cmd) => electrum_cmd, + general_cmd => { + return example_cli::handle_commands( + &graph, + &chain, + &db, + network, + |electrum_args, tx| { + let client = electrum_args.client(network)?; + client.transaction_broadcast(tx)?; + Ok(()) + }, + general_cmd.clone(), + ); + } + }; + + let client = BdkElectrumClient::new(electrum_cmd.electrum_args().client(network)?); + + // Tell the electrum client about the txs and anchors we've already got locally so it doesn't + // re-download .them + { + let graph = graph.lock().unwrap(); + client.populate_tx_cache(graph.graph().full_txs().map(|tx_node| tx_node.tx)); + client.populate_anchor_cache(graph.graph().all_anchors().clone()); + } + + let (chain_update, tx_update, keychain_update) = match electrum_cmd.clone() { + ElectrumCommands::Scan { + stop_gap, + scan_options, + .. + } => { + let request = { + let graph = &*graph.lock().unwrap(); + let chain = &*chain.lock().unwrap(); + + FullScanRequest::builder() + .chain_tip(chain.tip()) + .spks_for_keychain( + Keychain::External, + graph + .index + .unbounded_spk_iter(Keychain::External) + .into_iter() + .flatten(), + ) + .spks_for_keychain( + Keychain::Internal, + graph + .index + .unbounded_spk_iter(Keychain::Internal) + .into_iter() + .flatten(), + ) + .inspect({ + let mut once = BTreeSet::new(); + move |k, spk_i, _| { + if once.insert(k) { + eprint!("\nScanning {k}: {spk_i} "); + } else { + eprint!("{spk_i} "); + } + io::stdout().flush().expect("must flush"); + } + }) + }; + + let res = client + .full_scan::<_>(request, stop_gap, scan_options.batch_size, false) + .context("scanning the blockchain")?; + ( + res.chain_update, + res.tx_update, + Some(res.last_active_indices), + ) + } + ElectrumCommands::Sync { + mut unused_spks, + all_spks, + mut utxos, + mut unconfirmed, + scan_options, + .. + } => { + // Get a short lock on the tracker to get the spks we're interested in + let graph = graph.lock().unwrap(); + let chain = chain.lock().unwrap(); + + if !(all_spks || unused_spks || utxos || unconfirmed) { + unused_spks = true; + unconfirmed = true; + utxos = true; + } else if all_spks { + unused_spks = false; + } + + let chain_tip = chain.tip(); + let mut request = + SyncRequest::builder() + .chain_tip(chain_tip.clone()) + .inspect(|item, progress| { + let pc = (100 * progress.consumed()) as f32 / progress.total() as f32; + match item { + bdk_chain::spk_client::SyncItem::Spk((keychain, index), spk) => { + eprintln!( + "[ SCANNING {pc:3.0}% ] script {} {} {}", + keychain, index, spk + ); + } + bdk_chain::spk_client::SyncItem::Txid(txid) => { + eprintln!("[ SCANNING {pc:3.0}% ] txid {}", txid); + } + bdk_chain::spk_client::SyncItem::OutPoint(op) => { + eprintln!("[ SCANNING {pc:3.0}% ] outpoint {}", op); + } + } + let _ = io::stderr().flush(); + }); + + let canonical_view = + chain.canonical_view(graph.graph(), chain_tip.block_id(), Default::default()); + + request = request + .expected_spk_txids(canonical_view.list_expected_spk_txids(&graph.index, ..)); + if all_spks { + request = request.spks_with_indexes(graph.index.revealed_spks(..)); + } + if unused_spks { + request = request.spks_with_indexes(graph.index.unused_spks()); + } + if utxos { + let init_outpoints = graph.index.outpoints(); + request = request.outpoints( + canonical_view + .filter_unspent_outpoints(init_outpoints.iter().cloned()) + .map(|(_, utxo)| utxo.outpoint), + ); + }; + if unconfirmed { + request = request.txids( + canonical_view + .txs() + .filter(|canonical_tx| !canonical_tx.pos.is_confirmed()) + .map(|canonical_tx| canonical_tx.txid), + ); + } + + let res = client + .sync(request, scan_options.batch_size, false) + .context("scanning the blockchain")?; + + // drop lock on graph and chain + drop((graph, chain)); + + (res.chain_update, res.tx_update, None) + } + }; + + let db_changeset = { + let mut chain = chain.lock().unwrap(); + let mut graph = graph.lock().unwrap(); + + let chain_changeset = chain.apply_update(chain_update.expect("request has chain tip"))?; + + let mut indexed_tx_graph_changeset = + indexed_tx_graph::ChangeSet::::default(); + if let Some(keychain_update) = keychain_update { + let keychain_changeset = graph.index.reveal_to_target_multi(&keychain_update); + indexed_tx_graph_changeset.merge(keychain_changeset.into()); + } + indexed_tx_graph_changeset.merge(graph.apply_update(tx_update)); + + ChangeSet { + local_chain: chain_changeset, + tx_graph: indexed_tx_graph_changeset.tx_graph, + indexer: indexed_tx_graph_changeset.indexer, + ..Default::default() + } + }; + + let mut db = db.lock().unwrap(); + db.append(&db_changeset)?; + Ok(()) +} diff --git a/examples/example_esplora/src/main.rs b/examples/example_esplora/src/main.rs new file mode 100644 index 0000000000..320b4796d7 --- /dev/null +++ b/examples/example_esplora/src/main.rs @@ -0,0 +1,298 @@ +use core::f32; +use std::{ + collections::BTreeSet, + io::{self, Write}, +}; + +use bdk_chain::{ + bitcoin::Network, + keychain_txout::FullScanRequestBuilderExt, + spk_client::{FullScanRequest, SyncRequest}, + Merge, +}; +use bdk_esplora::{esplora_client, EsploraExt}; +use example_cli::{ + anyhow::{self, Context}, + clap::{self, Parser, Subcommand}, + ChangeSet, Keychain, +}; + +const DB_MAGIC: &[u8] = b"bdk_example_esplora"; +const DB_PATH: &str = ".bdk_example_esplora.db"; + +#[derive(Subcommand, Debug, Clone)] +enum EsploraCommands { + /// Scans the addresses in the wallet using the esplora API. + Scan { + /// When a gap this large has been found for a keychain, it will stop. + #[clap(long, short = 'g', default_value = "10")] + stop_gap: usize, + #[clap(flatten)] + scan_options: ScanOptions, + #[clap(flatten)] + esplora_args: EsploraArgs, + }, + /// Scan for particular addresses and unconfirmed transactions using the esplora API. + Sync { + /// Scan all the unused addresses. + #[clap(long)] + unused_spks: bool, + /// Scan every address that you have derived. + #[clap(long)] + all_spks: bool, + /// Scan unspent outpoints for spends or changes to confirmation status of residing tx. + #[clap(long)] + utxos: bool, + /// Scan unconfirmed transactions for updates. + #[clap(long)] + unconfirmed: bool, + #[clap(flatten)] + scan_options: ScanOptions, + #[clap(flatten)] + esplora_args: EsploraArgs, + }, +} + +impl EsploraCommands { + fn esplora_args(&self) -> EsploraArgs { + match self { + EsploraCommands::Scan { esplora_args, .. } => esplora_args.clone(), + EsploraCommands::Sync { esplora_args, .. } => esplora_args.clone(), + } + } +} + +#[derive(clap::Args, Debug, Clone)] +pub struct EsploraArgs { + /// The esplora url endpoint to connect to. + #[clap(long, short = 'u', env = "ESPLORA_SERVER")] + esplora_url: Option, +} + +impl EsploraArgs { + pub fn client(&self, network: Network) -> anyhow::Result { + let esplora_url = self.esplora_url.as_deref().unwrap_or(match network { + Network::Bitcoin => "https://blockstream.info/api", + Network::Testnet => "https://blockstream.info/testnet/api", + Network::Regtest => "http://localhost:3002", + Network::Signet => "http://signet.bitcoindevkit.net", + _ => panic!("unsupported network"), + }); + + let client = esplora_client::Builder::new(esplora_url).build_blocking(); + Ok(client) + } +} + +#[derive(Parser, Debug, Clone, PartialEq)] +pub struct ScanOptions { + /// Max number of concurrent esplora server requests. + #[clap(long, default_value = "2")] + pub parallel_requests: usize, +} + +fn main() -> anyhow::Result<()> { + let example_cli::Init { + args, + graph, + chain, + db, + network, + } = match example_cli::init_or_load::(DB_MAGIC, DB_PATH)? { + Some(init) => init, + None => return Ok(()), + }; + + let esplora_cmd = match &args.command { + // These are commands that are handled by this example (sync, scan). + example_cli::Commands::ChainSpecific(esplora_cmd) => esplora_cmd, + // These are general commands handled by example_cli. Execute the cmd and return. + general_cmd => { + return example_cli::handle_commands( + &graph, + &chain, + &db, + network, + |esplora_args, tx| { + let client = esplora_args.client(network)?; + client + .broadcast(tx) + .map(|_| ()) + .map_err(anyhow::Error::from) + }, + general_cmd.clone(), + ); + } + }; + + let client = esplora_cmd.esplora_args().client(network)?; + // Prepare the `IndexedTxGraph` and `LocalChain` updates based on whether we are scanning or + // syncing. + // + // Scanning: We are iterating through spks of all keychains and scanning for transactions for + // each spk. We start with the lowest derivation index spk and stop scanning after `stop_gap` + // number of consecutive spks have no transaction history. A Scan is done in situations of + // wallet restoration. It is a special case. Applications should use "sync" style updates + // after an initial scan. + // + // Syncing: We only check for specified spks, utxos and txids to update their confirmation + // status or fetch missing transactions. + let (local_chain_changeset, indexed_tx_graph_changeset) = match &esplora_cmd { + EsploraCommands::Scan { + stop_gap, + scan_options, + .. + } => { + let request = { + let chain_tip = chain.lock().expect("mutex must not be poisoned").tip(); + let indexed_graph = &*graph.lock().expect("mutex must not be poisoned"); + FullScanRequest::builder() + .chain_tip(chain_tip) + .spks_from_indexer(&indexed_graph.index) + .inspect({ + let mut once = BTreeSet::::new(); + move |keychain, spk_i, _| { + if once.insert(keychain) { + eprint!("\nscanning {keychain}: "); + } + eprint!("{spk_i} "); + // Flush early to ensure we print at every iteration. + let _ = io::stderr().flush(); + } + }) + .build() + }; + + // The client scans keychain spks for transaction histories, stopping after `stop_gap` + // is reached. It returns a `TxGraph` update (`tx_update`) and a structure that + // represents the last active spk derivation indices of keychains + // (`keychain_indices_update`). + let update = client + .full_scan(request, *stop_gap, scan_options.parallel_requests) + .context("scanning for transactions")?; + + let mut graph = graph.lock().expect("mutex must not be poisoned"); + let mut chain = chain.lock().expect("mutex must not be poisoned"); + // Because we did a stop gap based scan we are likely to have some updates to our + // deriviation indices. Usually before a scan you are on a fresh wallet with no + // addresses derived so we need to derive up to last active addresses the scan found + // before adding the transactions. + ( + chain.apply_update(update.chain_update.expect("request included chain tip"))?, + { + let index_changeset = graph + .index + .reveal_to_target_multi(&update.last_active_indices); + let mut indexed_tx_graph_changeset = graph.apply_update(update.tx_update); + indexed_tx_graph_changeset.merge(index_changeset.into()); + indexed_tx_graph_changeset + }, + ) + } + EsploraCommands::Sync { + mut unused_spks, + all_spks, + mut utxos, + mut unconfirmed, + scan_options, + .. + } => { + if !(*all_spks || unused_spks || utxos || unconfirmed) { + // If nothing is specifically selected, we select everything (except all spks). + unused_spks = true; + unconfirmed = true; + utxos = true; + } else if *all_spks { + // If all spks is selected, we don't need to also select unused spks (as unused spks + // is a subset of all spks). + unused_spks = false; + } + + let local_tip = chain.lock().expect("mutex must not be poisoned").tip(); + // Spks, outpoints and txids we want updates on will be accumulated here. + let mut request = + SyncRequest::builder() + .chain_tip(local_tip.clone()) + .inspect(|item, progress| { + let pc = (100 * progress.consumed()) as f32 / progress.total() as f32; + match item { + bdk_chain::spk_client::SyncItem::Spk((keychain, index), spk) => { + eprintln!( + "[ SCANNING {pc:3.0}% ] script {} {} {}", + keychain, index, spk + ); + } + bdk_chain::spk_client::SyncItem::Txid(txid) => { + eprintln!("[ SCANNING {pc:3.0}% ] txid {}", txid); + } + bdk_chain::spk_client::SyncItem::OutPoint(op) => { + eprintln!("[ SCANNING {pc:3.0}% ] outpoint {}", op); + } + } + let _ = io::stderr().flush(); + }); + + // Get a short lock on the structures to get spks, utxos, and txs that we are interested + // in. + { + let graph = graph.lock().unwrap(); + let chain = chain.lock().unwrap(); + let canonical_view = + chain.canonical_view(graph.graph(), local_tip.block_id(), Default::default()); + + request = request + .expected_spk_txids(canonical_view.list_expected_spk_txids(&graph.index, ..)); + if *all_spks { + request = request.spks_with_indexes(graph.index.revealed_spks(..)); + } + if unused_spks { + request = request.spks_with_indexes(graph.index.unused_spks()); + } + if utxos { + // We want to search for whether the UTXO is spent, and spent by which + // transaction. We provide the outpoint of the UTXO to + // `EsploraExt::update_tx_graph_without_keychain`. + let init_outpoints = graph.index.outpoints(); + request = request.outpoints( + canonical_view + .filter_unspent_outpoints(init_outpoints.iter().cloned()) + .map(|(_, utxo)| utxo.outpoint), + ); + }; + if unconfirmed { + // We want to search for whether the unconfirmed transaction is now confirmed. + // We provide the unconfirmed txids to + // `EsploraExt::update_tx_graph_without_keychain`. + request = request.txids( + canonical_view + .txs() + .filter(|canonical_tx| !canonical_tx.pos.is_confirmed()) + .map(|canonical_tx| canonical_tx.txid), + ); + } + } + + let update = client.sync(request, scan_options.parallel_requests)?; + + ( + chain + .lock() + .unwrap() + .apply_update(update.chain_update.expect("request has chain tip"))?, + graph.lock().unwrap().apply_update(update.tx_update), + ) + } + }; + + println!(); + + // We persist the changes + let mut db = db.lock().unwrap(); + db.append(&ChangeSet { + local_chain: local_chain_changeset, + tx_graph: indexed_tx_graph_changeset.tx_graph, + indexer: indexed_tx_graph_changeset.indexer, + ..Default::default() + })?; + Ok(()) +} From 7d8217af66d823f2f22e1e23d07fdc465ec32047 Mon Sep 17 00:00:00 2001 From: Leonardo Lima Date: Tue, 19 May 2026 16:32:39 -0300 Subject: [PATCH 3/8] refactor(chain)!: remove `CanonicalIter` APIs The codebase has been updated to the new `LocalChain::canonical_view` method. It's now safe to remove the `CanonicalIter` it's the old APIs relying on it, eg. `try_canonical_view`. --- crates/chain/src/canonical_iter.rs | 245 --------------------------- crates/chain/src/canonical_task.rs | 2 +- crates/chain/src/canonical_view.rs | 149 +++------------- crates/chain/src/indexed_tx_graph.rs | 39 +---- crates/chain/src/lib.rs | 2 - crates/chain/src/tx_graph.rs | 40 +---- 6 files changed, 33 insertions(+), 444 deletions(-) delete mode 100644 crates/chain/src/canonical_iter.rs diff --git a/crates/chain/src/canonical_iter.rs b/crates/chain/src/canonical_iter.rs deleted file mode 100644 index 7866d12da2..0000000000 --- a/crates/chain/src/canonical_iter.rs +++ /dev/null @@ -1,245 +0,0 @@ -use crate::collections::{HashMap, HashSet, VecDeque}; -use crate::tx_graph::{TxAncestors, TxDescendants}; -use crate::{Anchor, CanonicalReason, CanonicalizationParams, ChainOracle, ObservedIn, 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; - -/// 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; - } - } -} diff --git a/crates/chain/src/canonical_task.rs b/crates/chain/src/canonical_task.rs index 7fabb21df4..4a2347c367 100644 --- a/crates/chain/src/canonical_task.rs +++ b/crates/chain/src/canonical_task.rs @@ -305,7 +305,7 @@ impl<'g, A: Anchor> ChainQuery for CanonicalizationTask<'g, A> { } } - CanonicalView::from_parts(self.chain_tip, view_order, view_txs, view_spends) + CanonicalView::new(self.chain_tip, view_order, view_txs, view_spends) } } diff --git a/crates/chain/src/canonical_view.rs b/crates/chain/src/canonical_view.rs index ced3a44925..b89e9efe1d 100644 --- a/crates/chain/src/canonical_view.rs +++ b/crates/chain/src/canonical_view.rs @@ -6,14 +6,15 @@ //! ## Example //! //! ``` -//! # use bdk_chain::{CanonicalView, TxGraph, CanonicalizationParams, local_chain::LocalChain}; +//! # use bdk_chain::{TxGraph, CanonicalizationParams, CanonicalizationTask, 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 chain_tip = chain.tip().block_id(); //! let params = CanonicalizationParams::default(); -//! let view = CanonicalView::new(&tx_graph, &chain, chain_tip, params).unwrap(); +//! let task = CanonicalizationTask::new(&tx_graph, chain_tip, params); +//! let view = chain.canonicalize(task); //! //! // Iterate over canonical transactions //! for tx in view.txs() { @@ -30,10 +31,7 @@ use alloc::vec::Vec; use bdk_core::BlockId; use bitcoin::{Amount, OutPoint, ScriptBuf, Transaction, Txid}; -use crate::{ - spk_txout::SpkTxOutIndex, tx_graph::TxNode, Anchor, Balance, CanonicalIter, CanonicalReason, - CanonicalizationParams, ChainOracle, ChainPosition, FullTxOut, ObservedIn, TxGraph, -}; +use crate::{spk_txout::SpkTxOutIndex, Anchor, Balance, ChainPosition, FullTxOut}; /// A single canonical transaction with its chain position. /// @@ -97,7 +95,7 @@ impl CanonicalView { /// after completing the canonicalization process. It takes the processed transaction /// data including the canonical ordering, transaction map with chain positions, and /// spend information. - pub(crate) fn from_parts( + pub(crate) fn new( tip: BlockId, order: Vec, txs: HashMap, ChainPosition)>, @@ -111,117 +109,6 @@ 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; - } - }; - - view.order.push(txid); - - if !tx.is_coinbase() { - view.spends - .extend(tx.input.iter().map(|txin| (txin.previous_output, txid))); - } - - let pos = match why { - CanonicalReason::Assumed { descendant } => match descendant { - Some(_) => match find_direct_anchor(&tx_node, chain, chain_tip)? { - Some(anchor) => ChainPosition::Confirmed { - anchor, - transitively: None, - }, - None => ChainPosition::Unconfirmed { - first_seen: tx_node.first_seen, - last_seen: tx_node.last_seen, - }, - }, - None => ChainPosition::Unconfirmed { - first_seen: tx_node.first_seen, - last_seen: tx_node.last_seen, - }, - }, - CanonicalReason::Anchor { anchor, descendant } => match descendant { - Some(_) => match find_direct_anchor(&tx_node, chain, chain_tip)? { - Some(anchor) => ChainPosition::Confirmed { - anchor, - transitively: None, - }, - None => ChainPosition::Confirmed { - anchor, - transitively: descendant, - }, - }, - None => ChainPosition::Confirmed { - anchor, - transitively: None, - }, - }, - CanonicalReason::ObservedIn { observed_in, .. } => match observed_in { - ObservedIn::Mempool(last_seen) => ChainPosition::Unconfirmed { - first_seen: tx_node.first_seen, - last_seen: Some(last_seen), - }, - ObservedIn::Block(_) => ChainPosition::Unconfirmed { - first_seen: tx_node.first_seen, - last_seen: None, - }, - }, - }; - view.txs.insert(txid, (tx_node.tx, pos)); - } - - Ok(view) - } - /// Get a single canonical transaction by its transaction ID. /// /// Returns `Some(CanonicalViewTx)` if the transaction exists in the canonical view, @@ -267,12 +154,14 @@ impl CanonicalView { /// # Example /// /// ``` - /// # use bdk_chain::{CanonicalView, TxGraph, local_chain::LocalChain}; + /// # use bdk_chain::{TxGraph, CanonicalizationTask, 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 task = CanonicalizationTask::new(&tx_graph, chain_tip, Default::default()); + /// # let view = chain.canonicalize(task); /// // Iterate over all canonical transactions /// for tx in view.txs() { /// println!("TX {}: {:?}", tx.txid, tx.pos); @@ -300,12 +189,14 @@ impl CanonicalView { /// # Example /// /// ``` - /// # use bdk_chain::{CanonicalView, TxGraph, local_chain::LocalChain, keychain_txout::KeychainTxOutIndex}; + /// # use bdk_chain::{TxGraph, CanonicalizationTask, 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 task = CanonicalizationTask::new(&tx_graph, chain_tip, Default::default()); + /// # let view = chain.canonicalize(task); /// # let indexer = KeychainTxOutIndex::<&str>::default(); /// // Get all outputs from an indexer /// for (keychain, txout) in view.filter_outpoints(indexer.outpoints().clone()) { @@ -329,12 +220,14 @@ impl CanonicalView { /// # Example /// /// ``` - /// # use bdk_chain::{CanonicalView, TxGraph, local_chain::LocalChain, keychain_txout::KeychainTxOutIndex}; + /// # use bdk_chain::{TxGraph, CanonicalizationTask, 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 task = CanonicalizationTask::new(&tx_graph, chain_tip, Default::default()); + /// # let view = chain.canonicalize(task); /// # let indexer = KeychainTxOutIndex::<&str>::default(); /// // Get unspent outputs (UTXOs) from an indexer /// for (keychain, utxo) in view.filter_unspent_outpoints(indexer.outpoints().clone()) { @@ -375,12 +268,14 @@ impl CanonicalView { /// # Example /// /// ``` - /// # use bdk_chain::{CanonicalView, TxGraph, local_chain::LocalChain, keychain_txout::KeychainTxOutIndex}; + /// # use bdk_chain::{CanonicalizationTask, 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 task = CanonicalizationTask::new(&tx_graph, chain_tip, Default::default()); + /// # let view = chain.canonicalize(task); /// # let indexer = KeychainTxOutIndex::<&str>::default(); /// // Calculate balance with 6 confirmations, trusting all outputs /// let balance = view.balance( diff --git a/crates/chain/src/indexed_tx_graph.rs b/crates/chain/src/indexed_tx_graph.rs index 32ac444a05..5c18080778 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, CanonicalizationTask, ChainOracle, - Indexer, Merge, TxPosInBlock, + Anchor, BlockId, CanonicalizationParams, CanonicalizationTask, Indexer, Merge, TxPosInBlock, }; /// A [`TxGraph`] paired with an indexer `I`, enforcing that every insertion into the graph is @@ -427,32 +426,16 @@ 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`]. - /// - /// This is the infallible version of [`try_canonical_view`](Self::try_canonical_view). - pub fn canonical_view<'a, C: ChainOracle>( - &'a self, - chain: &'a C, - chain_tip: BlockId, - params: CanonicalizationParams, - ) -> CanonicalView { - self.graph.canonical_view(chain, chain_tip, params) - } - /// Creates a [`CanonicalizationTask`] to determine the [`CanonicalView`] of transactions. /// /// This method delegates to the underlying [`TxGraph`] to create a [`CanonicalizationTask`] @@ -468,12 +451,6 @@ where } } -impl AsRef> for IndexedTxGraph { - fn as_ref(&self) -> &TxGraph { - &self.graph - } -} - /// Represents changes to an [`IndexedTxGraph`]. #[derive(Clone, Debug, PartialEq)] #[cfg_attr( diff --git a/crates/chain/src/lib.rs b/crates/chain/src/lib.rs index 3d9b5a7cfa..2e0a83c277 100644 --- a/crates/chain/src/lib.rs +++ b/crates/chain/src/lib.rs @@ -44,8 +44,6 @@ 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_task; pub use canonical_task::*; mod canonical_view; diff --git a/crates/chain/src/tx_graph.rs b/crates/chain/src/tx_graph.rs index c9a7951370..8ec7ab75a1 100644 --- a/crates/chain/src/tx_graph.rs +++ b/crates/chain/src/tx_graph.rs @@ -127,12 +127,9 @@ //! [`insert_txout`]: TxGraph::insert_txout use crate::collections::*; -use crate::BlockId; -use crate::CanonicalIter; -use crate::CanonicalView; use crate::CanonicalizationParams; use crate::CanonicalizationTask; -use crate::{Anchor, ChainOracle, Merge}; +use crate::{Anchor, BlockId, Merge}; use alloc::collections::vec_deque::VecDeque; use alloc::sync::Arc; use alloc::vec::Vec; @@ -140,10 +137,7 @@ use bdk_core::ConfirmationBlockTime; pub use bdk_core::TxUpdate; 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 { @@ -1023,36 +1017,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(); From 6ec2298a0e074cedc2bc093fde2c5e1682c665c6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=BF=97=E5=AE=87?= Date: Fri, 13 Feb 2026 12:57:39 +0000 Subject: [PATCH 4/8] refactor(chain)!: split canonicalization into two tasks with generic `Canonical` Separate concerns by splitting `CanonicalizationTask` into two phases: 1. `CanonicalTask` determines which transactions are canonical and why (`CanonicalReason`), outputting `CanonicalTxs`. 2. `CanonicalViewTask` resolves reasons into `ChainPosition`s (confirmed vs unconfirmed), outputting `CanonicalView`. Make `Canonical`, `CanonicalTx

`, and `FullTxOut

` generic over the position type so the same structs serve both phases. Add `LocalChain::canonical_view()` convenience method for the common two-step pipeline. Renames: - `CanonicalizationTask` -> `CanonicalTask` - `CanonicalizationParams` -> `CanonicalParams` - `canonicalization_task()` -> `canonical_task()` - `FullTxOut::chain_position` -> `FullTxOut::pos` Co-Authored-By: Claude Opus 4.6 --- crates/chain/src/canonical_task.rs | 344 ++++++++++++-------- crates/chain/src/canonical_view.rs | 173 +++++----- crates/chain/src/chain_data.rs | 28 +- crates/chain/src/indexed_tx_graph.rs | 16 +- crates/chain/src/local_chain.rs | 21 +- crates/chain/src/tx_graph.rs | 28 +- crates/chain/tests/common/tx_template.rs | 6 +- crates/chain/tests/test_indexed_tx_graph.rs | 8 +- examples/example_cli/src/lib.rs | 12 +- 9 files changed, 371 insertions(+), 265 deletions(-) diff --git a/crates/chain/src/canonical_task.rs b/crates/chain/src/canonical_task.rs index 4a2347c367..8f4ce48f3d 100644 --- a/crates/chain/src/canonical_task.rs +++ b/crates/chain/src/canonical_task.rs @@ -1,6 +1,6 @@ use crate::collections::{HashMap, HashSet, VecDeque}; use crate::tx_graph::{TxAncestors, TxDescendants}; -use crate::{Anchor, CanonicalView, ChainPosition, TxGraph}; +use crate::{Anchor, CanonicalTxs, CanonicalView, ChainPosition, TxGraph}; use alloc::boxed::Box; use alloc::collections::BTreeSet; use alloc::sync::Arc; @@ -23,8 +23,6 @@ enum CanonicalStage { SeenTxs, /// Processing leftover transactions. LeftOverTxs, - /// Processing transitively anchored transactions. - TransitivelyAnchoredTxs, /// All processing is complete. Finished, } @@ -35,8 +33,7 @@ impl CanonicalStage { CanonicalStage::AssumedTxs => Self::AnchoredTxs, CanonicalStage::AnchoredTxs => Self::SeenTxs, CanonicalStage::SeenTxs => Self::LeftOverTxs, - CanonicalStage::LeftOverTxs => Self::TransitivelyAnchoredTxs, - CanonicalStage::TransitivelyAnchoredTxs => Self::Finished, + CanonicalStage::LeftOverTxs => Self::Finished, CanonicalStage::Finished => Self::Finished, }; } @@ -44,7 +41,7 @@ impl CanonicalStage { /// Modifies the canonicalization algorithm. #[derive(Debug, Default, Clone)] -pub struct CanonicalizationParams { +pub struct CanonicalParams { /// Transactions that will supersede all other transactions. /// /// In case of conflicting transactions within `assume_canonical`, transactions that appear @@ -52,8 +49,14 @@ pub struct CanonicalizationParams { pub assume_canonical: Vec, } -/// Manages the canonicalization process without direct I/O operations. -pub struct CanonicalizationTask<'g, A> { +/// 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`]). The output is a [`CanonicalTxs`] which can then be +/// further processed by [`CanonicalViewTask`] to resolve reasons into +/// [`ChainPosition`]s. +pub struct CanonicalTask<'g, A> { tx_graph: &'g TxGraph, chain_tip: BlockId, @@ -61,7 +64,6 @@ pub struct CanonicalizationTask<'g, A> { unprocessed_anchored_txs: VecDeque<(Txid, Arc, &'g BTreeSet)>, unprocessed_seen_txs: Box, u64)> + 'g>, unprocessed_leftover_txs: VecDeque<(Txid, Arc, u32)>, - unprocessed_transitively_anchored_txs: VecDeque<(Txid, Arc, &'g BTreeSet)>, canonical: CanonicalMap, not_canonical: NotCanonicalSet, @@ -69,15 +71,12 @@ pub struct CanonicalizationTask<'g, A> { // Store canonical transactions in order canonical_order: Vec, - // Track which transactions have direct anchors (not transitive) - direct_anchors: HashMap, - // Track the current stage of processing current_stage: CanonicalStage, } -impl<'g, A: Anchor> ChainQuery for CanonicalizationTask<'g, A> { - type Output = CanonicalView; +impl<'g, A: Anchor> ChainQuery for CanonicalTask<'g, A> { + type Output = CanonicalTxs; fn tip(&self) -> BlockId { self.chain_tip @@ -131,15 +130,6 @@ impl<'g, A: Anchor> ChainQuery for CanonicalizationTask<'g, A> { continue; } } - CanonicalStage::TransitivelyAnchoredTxs => { - if let Some((_txid, _, anchors)) = - self.unprocessed_transitively_anchored_txs.front() - { - let block_ids = - anchors.iter().map(|anchor| anchor.anchor_block()).collect(); - return Some(block_ids); - } - } CanonicalStage::Finished => return None, } @@ -148,9 +138,6 @@ impl<'g, A: Anchor> ChainQuery for CanonicalizationTask<'g, A> { } fn resolve_query(&mut self, response: ChainResponse) { - // Only AnchoredTxs and TransitivelyAnchoredTxs stages should receive query - // responses Other stages don't generate queries and thus shouldn't call - // resolve_query match self.current_stage { CanonicalStage::AnchoredTxs => { // Process directly anchored transaction response @@ -166,7 +153,6 @@ impl<'g, A: Anchor> ChainQuery for CanonicalizationTask<'g, A> { match best_anchor { Some(best_anchor) => { // Transaction has a confirmed anchor - self.direct_anchors.insert(txid, best_anchor.clone()); if !self.is_canonicalized(txid) { self.mark_canonical( txid, @@ -193,28 +179,6 @@ impl<'g, A: Anchor> ChainQuery for CanonicalizationTask<'g, A> { } } } - CanonicalStage::TransitivelyAnchoredTxs => { - // Process transitively anchored transaction response - if let Some((txid, _tx, anchors)) = - self.unprocessed_transitively_anchored_txs.pop_front() - { - // Find the anchor that matches the confirmed BlockId - let best_anchor = response.and_then(|block_id| { - anchors - .iter() - .find(|anchor| anchor.anchor_block() == block_id) - .cloned() - }); - - if let Some(best_anchor) = best_anchor { - // Found a confirmed anchor for this transitively anchored transaction - self.direct_anchors.insert(txid, best_anchor.clone()); - // Note: We don't re-mark as canonical since it's already marked - // from being transitively anchored by its descendant - } - // If no confirmed anchor, we keep the transitive canonicalization status - } - } CanonicalStage::AssumedTxs | CanonicalStage::SeenTxs | CanonicalStage::LeftOverTxs @@ -230,7 +194,6 @@ impl<'g, A: Anchor> ChainQuery for CanonicalizationTask<'g, A> { } fn finish(self) -> Self::Output { - // Build the canonical view let mut view_order = Vec::new(); let mut view_txs = HashMap::new(); let mut view_spends = HashMap::new(); @@ -246,76 +209,17 @@ impl<'g, A: Anchor> ChainQuery for CanonicalizationTask<'g, A> { } } - // 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 based on reason - let chain_position = match reason { - CanonicalReason::Assumed { descendant } => match descendant { - Some(_) => match self.direct_anchors.get(txid) { - 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 self.direct_anchors.get(txid) { - 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.clone(), chain_position.cloned())); + view_txs.insert(*txid, (tx.clone(), reason.clone())); } } - CanonicalView::new(self.chain_tip, view_order, view_txs, view_spends) + CanonicalTxs::new(self.chain_tip, view_order, view_txs, view_spends) } } -impl<'g, A: Anchor> CanonicalizationTask<'g, A> { +impl<'g, A: Anchor> CanonicalTask<'g, A> { /// Creates a new canonicalization task. - pub fn new( - tx_graph: &'g TxGraph, - chain_tip: BlockId, - params: CanonicalizationParams, - ) -> Self { + 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 @@ -342,13 +246,11 @@ impl<'g, A: Anchor> CanonicalizationTask<'g, A> { unprocessed_anchored_txs, unprocessed_seen_txs, unprocessed_leftover_txs: VecDeque::new(), - unprocessed_transitively_anchored_txs: VecDeque::new(), canonical: HashMap::new(), not_canonical: HashSet::new(), canonical_order: Vec::new(), - direct_anchors: HashMap::new(), current_stage: CanonicalStage::default(), } } @@ -378,8 +280,6 @@ impl<'g, A: Anchor> CanonicalizationTask<'g, A> { reason.clone() } else { // This is an ancestor being marked transitively - // Check if it has its own anchor that needs to be verified later - // We'll check anchors after marking it canonical reason.to_transitive(starting_txid) }; @@ -435,27 +335,199 @@ impl<'g, A: Anchor> CanonicalizationTask<'g, A> { } // Add to canonical order - for (txid, tx, reason) in &staged_canonical { + for (txid, _, _) in &staged_canonical { self.canonical_order.push(*txid); + } + } +} + +/// Represents the current stage of view task processing. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +enum ViewStage { + /// Processing transactions to resolve their chain positions. + #[default] + ResolvingPositions, + /// 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`]), 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. +pub struct CanonicalViewTask<'g, A> { + tx_graph: &'g TxGraph, + tip: BlockId, + + /// Transactions in canonical order with their reasons. + canonical_order: Vec, + canonical_txs: HashMap, CanonicalReason)>, + spends: HashMap, - // ObservedIn transactions don't need anchor verification - if matches!(reason, CanonicalReason::ObservedIn { .. }) { - continue; + /// Transactions that need anchor verification (transitively anchored). + unprocessed_anchor_checks: VecDeque<(Txid, &'g BTreeSet)>, + + /// Resolved direct anchors for transitively anchored transactions. + direct_anchors: HashMap, + + current_stage: ViewStage, +} + +impl<'g, A: Anchor> ChainQuery for CanonicalViewTask<'g, A> { + type Output = CanonicalView; + + fn tip(&self) -> BlockId { + self.tip + } + + fn next_query(&mut self) -> Option { + loop { + match self.current_stage { + ViewStage::ResolvingPositions => { + if let Some((_txid, anchors)) = self.unprocessed_anchor_checks.front() { + let block_ids = + anchors.iter().map(|anchor| anchor.anchor_block()).collect(); + return Some(block_ids); + } + } + ViewStage::Finished => return None, } - // Check if this transaction was marked transitively and needs its own anchors verified - if reason.is_transitive() { - if let Some(anchors) = self.tx_graph.all_anchors().get(txid) { - // only check anchors we haven't already confirmed - if !self.direct_anchors.contains_key(txid) { - self.unprocessed_transitively_anchored_txs.push_back(( - *txid, - tx.clone(), - anchors, - )); + self.current_stage = ViewStage::Finished; + } + } + + fn resolve_query(&mut self, response: ChainResponse) { + match self.current_stage { + ViewStage::ResolvingPositions => { + if let Some((txid, anchors)) = self.unprocessed_anchor_checks.pop_front() { + let best_anchor = response.and_then(|block_id| { + anchors + .iter() + .find(|anchor| anchor.anchor_block() == block_id) + .cloned() + }); + + if let Some(best_anchor) = best_anchor { + self.direct_anchors.insert(txid, best_anchor); } } } + ViewStage::Finished => { + debug_assert!(false, "resolve_query called in Finished stage"); + } + } + } + + fn finish(self) -> Self::Output { + let mut view_order = Vec::new(); + let mut view_txs = HashMap::new(); + + for txid in &self.canonical_order { + if let Some((tx, 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 based on reason + let chain_position = match reason { + CanonicalReason::Assumed { descendant } => match descendant { + Some(_) => match self.direct_anchors.get(txid) { + 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 self.direct_anchors.get(txid) { + 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.clone(), chain_position.cloned())); + } + } + + CanonicalView::new(self.tip, view_order, view_txs, self.spends) + } +} + +impl CanonicalTxs { + /// Creates a [`CanonicalViewTask`] that resolves [`CanonicalReason`]s into [`ChainPosition`]s. + /// + /// This is the second phase of the canonicalization pipeline. The resulting task + /// queries the chain to verify anchors for transitively anchored transactions and + /// produces a [`CanonicalView`] with resolved chain positions. + pub fn view_task<'g>(self, tx_graph: &'g TxGraph) -> CanonicalViewTask<'g, A> { + let all_anchors = tx_graph.all_anchors(); + + // Find transactions that need anchor verification + let mut unprocessed_anchor_checks = VecDeque::new(); + for txid in &self.order { + if let Some((_, reason)) = self.txs.get(txid) { + // Skip ObservedIn transactions - they don't have anchors to verify + if matches!(reason, CanonicalReason::ObservedIn { .. }) { + continue; + } + // Transitively anchored transactions need their own anchor checked + if reason.is_transitive() { + if let Some(anchors) = all_anchors.get(txid) { + unprocessed_anchor_checks.push_back((*txid, anchors)); + } + } + } + } + + CanonicalViewTask { + tx_graph, + tip: self.tip, + canonical_order: self.order, + canonical_txs: self.txs, + spends: self.spends, + unprocessed_anchor_checks, + direct_anchors: HashMap::new(), + current_stage: ViewStage::default(), } } } @@ -596,10 +668,12 @@ mod tests { }; let _ = tx_graph.insert_anchor(txid, anchor); - // Create canonicalization task and canonicalize using the chain - let params = CanonicalizationParams::default(); - let task = CanonicalizationTask::new(&tx_graph, chain_tip, params); - let canonical_view = chain.canonicalize(task); + // Create canonicalization task and canonicalize using the two-step pipeline + let params = CanonicalParams::default(); + let task = CanonicalTask::new(&tx_graph, chain_tip, params); + let canonical_txs = chain.canonicalize(task); + let view_task = canonical_txs.view_task(&tx_graph); + let canonical_view = chain.canonicalize(view_task); // Should have one canonical transaction assert_eq!(canonical_view.txs().len(), 1); diff --git a/crates/chain/src/canonical_view.rs b/crates/chain/src/canonical_view.rs index b89e9efe1d..03a9633cd1 100644 --- a/crates/chain/src/canonical_view.rs +++ b/crates/chain/src/canonical_view.rs @@ -6,14 +6,14 @@ //! ## Example //! //! ``` -//! # use bdk_chain::{TxGraph, CanonicalizationParams, CanonicalizationTask, local_chain::LocalChain}; +//! # use bdk_chain::{TxGraph, CanonicalParams, CanonicalTask, 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 task = CanonicalizationTask::new(&tx_graph, chain_tip, params); +//! let params = CanonicalParams::default(); +//! let task = CanonicalTask::new(&tx_graph, chain_tip, params); //! let view = chain.canonicalize(task); //! //! // Iterate over canonical transactions @@ -33,25 +33,27 @@ use bitcoin::{Amount, OutPoint, ScriptBuf, Transaction, Txid}; use crate::{spk_txout::SpkTxOutIndex, Anchor, Balance, ChainPosition, FullTxOut}; -/// 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, } -impl Ord for CanonicalTx { +impl Ord for CanonicalTx

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

{ fn partial_cmp(&self, other: &Self) -> Option { Some(self.cmp(other)) } } -/// A view of canonical transactions from a [`TxGraph`]. +/// 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 +/// +/// [`TxGraph`]: crate::TxGraph #[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 and position. + pub(crate) txs: HashMap, P)>, /// 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, + /// Marker for the anchor type. + pub(crate) _anchor: core::marker::PhantomData, } -impl CanonicalView { - /// Creates a [`CanonicalView`] from its constituent parts. +/// Type alias for canonical transactions with resolved [`ChainPosition`]s. +pub type CanonicalView = Canonical>; + +/// Type alias for canonical transactions with unresolved +/// [`CanonicalReason`](crate::canonical_task::CanonicalReason)s. +pub type CanonicalTxs = Canonical>; + +impl Canonical { + /// Creates a [`Canonical`] from its constituent parts. /// - /// This internal constructor is used by [`CanonicalizationTask`] to build the view + /// 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 chain positions, and + /// data including the canonical ordering, transaction map with positions, and /// spend information. pub(crate) fn new( tip: BlockId, order: Vec, - txs: HashMap, ChainPosition)>, + txs: HashMap, P)>, spends: HashMap, ) -> Self { Self { @@ -106,14 +124,20 @@ impl CanonicalView { order, txs, spends, + _anchor: core::marker::PhantomData, } } + /// Get the chain tip used to construct this canonical set. + pub fn tip(&self) -> BlockId { + self.tip + } + /// 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> { + pub fn tx(&self, txid: Txid) -> Option> { self.txs .get(&txid) .cloned() @@ -126,10 +150,10 @@ 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> { + pub fn txout(&self, op: OutPoint) -> Option> { let (tx, pos) = self.txs.get(&op.txid)?; let vout: usize = op.vout.try_into().ok()?; let txout = tx.output.get(vout)?; @@ -138,7 +162,7 @@ impl CanonicalView { (spent_by_pos.clone(), *spent_by_txid) }); Some(FullTxOut { - chain_position: pos.clone(), + pos: pos.clone(), outpoint: op, txout: txout.clone(), spent_by, @@ -154,13 +178,13 @@ impl CanonicalView { /// # Example /// /// ``` - /// # use bdk_chain::{TxGraph, CanonicalizationTask, local_chain::LocalChain}; + /// # use bdk_chain::{TxGraph, CanonicalTask, 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 task = CanonicalizationTask::new(&tx_graph, chain_tip, Default::default()); + /// # let task = CanonicalTask::new(&tx_graph, chain_tip, Default::default()); /// # let view = chain.canonicalize(task); /// // Iterate over all canonical transactions /// for tx in view.txs() { @@ -170,7 +194,7 @@ 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 } @@ -180,7 +204,7 @@ impl CanonicalView { /// 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, full_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 @@ -189,13 +213,13 @@ impl CanonicalView { /// # Example /// /// ``` - /// # use bdk_chain::{TxGraph, CanonicalizationTask, local_chain::LocalChain, keychain_txout::KeychainTxOutIndex}; + /// # use bdk_chain::{TxGraph, CanonicalTask, 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 chain_tip = chain.tip().block_id(); - /// # let task = CanonicalizationTask::new(&tx_graph, chain_tip, Default::default()); + /// # let task = CanonicalTask::new(&tx_graph, chain_tip, Default::default()); /// # let view = chain.canonicalize(task); /// # let indexer = KeychainTxOutIndex::<&str>::default(); /// // Get all outputs from an indexer @@ -206,7 +230,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)?))) @@ -220,13 +244,13 @@ impl CanonicalView { /// # Example /// /// ``` - /// # use bdk_chain::{TxGraph, CanonicalizationTask, local_chain::LocalChain, keychain_txout::KeychainTxOutIndex}; + /// # use bdk_chain::{TxGraph, CanonicalTask, 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 chain_tip = chain.tip().block_id(); - /// # let task = CanonicalizationTask::new(&tx_graph, chain_tip, Default::default()); + /// # let task = CanonicalTask::new(&tx_graph, chain_tip, Default::default()); /// # let view = chain.canonicalize(task); /// # let indexer = KeychainTxOutIndex::<&str>::default(); /// // Get unspent outputs (UTXOs) from an indexer @@ -237,11 +261,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 @@ -268,14 +321,13 @@ impl CanonicalView { /// # Example /// /// ``` - /// # use bdk_chain::{CanonicalizationTask, 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 chain_tip = chain.tip().block_id(); - /// # let task = CanonicalizationTask::new(&tx_graph, chain_tip, Default::default()); - /// # let view = chain.canonicalize(task); + /// # 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( @@ -287,7 +339,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, &FullTxOut>) -> bool, min_confirmations: u32, ) -> Balance { let mut immature = Amount::ZERO; @@ -296,7 +348,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 @@ -336,31 +388,4 @@ impl CanonicalView { confirmed, } } - - /// List transaction IDs that are expected to exist for the given script pubkeys. - /// - /// This method is primarily used for synchronization with external sources, helping to - /// identify which transactions are expected to exist for a set of script pubkeys. It's - /// commonly used with - /// [`SyncRequestBuilder::expected_spk_txids`](bdk_core::spk_client::SyncRequestBuilder::expected_spk_txids) - /// to inform sync operations about known transactions. - pub fn list_expected_spk_txids<'v, I>( - &'v self, - indexer: &'v impl AsRef>, - spk_index_range: impl RangeBounds + 'v, - ) -> impl Iterator + 'v - where - I: fmt::Debug + Clone + Ord + 'v, - { - let indexer = indexer.as_ref(); - self.txs().flat_map(move |c_tx| -> Vec<_> { - let range = &spk_index_range; - let relevant_spks = indexer.relevant_spks_of_tx(&c_tx.tx); - relevant_spks - .into_iter() - .filter(|(i, _)| range.contains(i)) - .map(|(_, spk)| (spk, c_tx.txid)) - .collect() - }) - } } diff --git a/crates/chain/src/chain_data.rs b/crates/chain/src/chain_data.rs index 43d41e2ed4..9bbf3a85c7 100644 --- a/crates/chain/src/chain_data.rs +++ b/crates/chain/src/chain_data.rs @@ -161,38 +161,42 @@ impl PartialOrd for ChainPosition { } } -/// A `TxOut` with as much data as we can retrieve about it +/// A `TxOut` with as much data as we can retrieve about it. +/// +/// 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 FullTxOut { +pub struct FullTxOut

{ /// The position of the transaction in `outpoint` in the overall chain. - pub chain_position: ChainPosition, + pub pos: P, /// 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)>, + /// 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, } -impl Ord for FullTxOut { +impl Ord for FullTxOut

{ fn cmp(&self, other: &Self) -> core::cmp::Ordering { - self.chain_position - .cmp(&other.chain_position) + 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 FullTxOut { +impl PartialOrd for FullTxOut

{ fn partial_cmp(&self, other: &Self) -> Option { Some(self.cmp(other)) } } -impl FullTxOut { +impl FullTxOut> { /// Whether the `txout` is considered mature. /// /// Depending on the implementation of [`confirmation_height_upper_bound`] in [`Anchor`], this @@ -202,7 +206,7 @@ impl FullTxOut { /// [`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() { + let conf_height = match self.pos.confirmation_height_upper_bound() { Some(height) => height, None => { debug_assert!(false, "coinbase tx can never be unconfirmed"); @@ -232,7 +236,7 @@ impl FullTxOut { return false; } - let conf_height = match self.chain_position.confirmation_height_upper_bound() { + let conf_height = match self.pos.confirmation_height_upper_bound() { Some(height) => height, None => return false, }; diff --git a/crates/chain/src/indexed_tx_graph.rs b/crates/chain/src/indexed_tx_graph.rs index 5c18080778..c56f7d58de 100644 --- a/crates/chain/src/indexed_tx_graph.rs +++ b/crates/chain/src/indexed_tx_graph.rs @@ -7,7 +7,7 @@ use bitcoin::{Block, OutPoint, Transaction, TxOut, Txid}; use crate::{ tx_graph::{self, TxGraph}, - Anchor, BlockId, CanonicalizationParams, CanonicalizationTask, 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 @@ -436,18 +436,20 @@ impl IndexedTxGraph where A: Anchor, { - /// Creates a [`CanonicalizationTask`] to determine the [`CanonicalView`] of transactions. + /// Creates a [`CanonicalTask`] to determine the [`CanonicalView`] of transactions. /// - /// This method delegates to the underlying [`TxGraph`] to create a [`CanonicalizationTask`] + /// 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 canonicalization_task( + /// + /// [`CanonicalView`]: crate::CanonicalView + pub fn canonical_task( &'_ self, chain_tip: BlockId, - params: CanonicalizationParams, - ) -> CanonicalizationTask<'_, A> { - self.graph.canonicalization_task(chain_tip, params) + params: CanonicalParams, + ) -> CanonicalTask<'_, A> { + self.graph.canonical_task(chain_tip, params) } } diff --git a/crates/chain/src/local_chain.rs b/crates/chain/src/local_chain.rs index 96a671065a..38fc30c464 100644 --- a/crates/chain/src/local_chain.rs +++ b/crates/chain/src/local_chain.rs @@ -5,7 +5,7 @@ use core::fmt; use core::ops::RangeBounds; use crate::collections::BTreeMap; -use crate::{Anchor, BlockId, CanonicalView, CanonicalizationParams, ChainOracle, Merge, TxGraph}; +use crate::{Anchor, BlockId, CanonicalParams, CanonicalView, ChainOracle, Merge, TxGraph}; use bdk_core::{ChainQuery, CheckPointEntry, ToBlockHash}; pub use bdk_core::{CheckPoint, CheckPointIter}; use bitcoin::block::Header; @@ -108,13 +108,13 @@ impl LocalChain { /// # Example /// /// ``` - /// # use bdk_chain::{CanonicalizationTask, CanonicalizationParams, TxGraph, local_chain::LocalChain}; + /// # use bdk_chain::{CanonicalTask, CanonicalParams, TxGraph, local_chain::LocalChain}; /// # use bdk_core::BlockId; /// # use bitcoin::hashes::Hash; /// # let tx_graph: TxGraph = TxGraph::default(); /// # let chain = LocalChain::from_blocks([(0, bitcoin::BlockHash::all_zeros())].into_iter().collect()).unwrap(); /// let chain_tip = chain.tip().block_id(); - /// let task = CanonicalizationTask::new(&tx_graph, chain_tip, CanonicalizationParams::default()); + /// let task = CanonicalTask::new(&tx_graph, chain_tip, CanonicalParams::default()); /// let view = chain.canonicalize(task); /// ``` pub fn canonicalize(&self, mut task: Q) -> Q::Output @@ -139,24 +139,21 @@ impl LocalChain { task.finish() } - /// A convenience method that creates [`CanonicalizationTask`] task, canonicalize it and returns - /// a [`CanonicalView`]. + /// Convenience method that runs both canonicalization phases and returns a [`CanonicalView`]. /// /// This is equivalent to: /// ```ignore - /// let task = graph.canonicalization_task(chain_tip, Default::default()); - /// let canonical_view = chain.canonicalize(task); + /// let canonical_txs = chain.canonicalize(tx_graph.canonical_task(tip, params)); + /// let view = chain.canonicalize(canonical_txs.view_task(tx_graph)); /// ``` - /// - /// [`CanonicalizationTask`]: crate::CanonicalizationTask pub fn canonical_view( &self, tx_graph: &TxGraph, tip: BlockId, - params: CanonicalizationParams, + params: CanonicalParams, ) -> CanonicalView { - let task = tx_graph.canonicalization_task(tip, params); - self.canonicalize(task) + let canonical_txs = self.canonicalize(tx_graph.canonical_task(tip, params)); + self.canonicalize(canonical_txs.view_task(tx_graph)) } /// Update the chain with a given [`Header`] at `height` which you claim is connected to a diff --git a/crates/chain/src/tx_graph.rs b/crates/chain/src/tx_graph.rs index 8ec7ab75a1..560217b3b6 100644 --- a/crates/chain/src/tx_graph.rs +++ b/crates/chain/src/tx_graph.rs @@ -23,10 +23,9 @@ //! //! The canonicalization process uses a two-step, sans-IO approach: //! -//! 1. **Create a canonicalization task** using -//! [`canonicalization_task`](TxGraph::canonicalization_task): ```ignore let task = -//! tx_graph.canonicalization_task(params); ``` This creates a [`CanonicalizationTask`] that -//! encapsulates the canonicalization logic without performing any I/O operations. +//! 1. **Create a canonicalization task** using [`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 @@ -124,11 +123,14 @@ //! let changeset = graph.apply_update(update); //! assert!(changeset.is_empty()); //! ``` +//! //! [`insert_txout`]: TxGraph::insert_txout +//! [`CanonicalView`]: crate::CanonicalView +//! [`canonical_task`]: TxGraph::canonical_task use crate::collections::*; -use crate::CanonicalizationParams; -use crate::CanonicalizationTask; +use crate::CanonicalParams; +use crate::CanonicalTask; use crate::{Anchor, BlockId, Merge}; use alloc::collections::vec_deque::VecDeque; use alloc::sync::Arc; @@ -976,18 +978,20 @@ impl TxGraph { } } - /// Creates a [`CanonicalizationTask`] to determine the [`CanonicalView`] of transactions. + /// Creates a [`CanonicalTask`] to determine the [`CanonicalView`] of transactions. /// - /// This method delegates to the underlying [`TxGraph`] to create a [`CanonicalizationTask`] + /// 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 canonicalization_task( + /// + /// [`CanonicalView`]: crate::CanonicalView + pub fn canonical_task( &'_ self, chain_tip: BlockId, - params: CanonicalizationParams, - ) -> CanonicalizationTask<'_, A> { - CanonicalizationTask::new(self, chain_tip, params) + params: CanonicalParams, + ) -> CanonicalTask<'_, A> { + CanonicalTask::new(self, chain_tip, params) } } diff --git a/crates/chain/tests/common/tx_template.rs b/crates/chain/tests/common/tx_template.rs index 29f36169ad..7bdac78a50 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_indexed_tx_graph.rs b/crates/chain/tests/test_indexed_tx_graph.rs index ad65bab808..d6fabbab3b 100644 --- a/crates/chain/tests/test_indexed_tx_graph.rs +++ b/crates/chain/tests/test_indexed_tx_graph.rs @@ -489,7 +489,7 @@ fn test_list_owned_txouts() { 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 @@ -500,7 +500,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 @@ -511,7 +511,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 @@ -522,7 +522,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 diff --git a/examples/example_cli/src/lib.rs b/examples/example_cli/src/lib.rs index 3b660ea8fd..1a3c6b3960 100644 --- a/examples/example_cli/src/lib.rs +++ b/examples/example_cli/src/lib.rs @@ -23,7 +23,7 @@ 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, ChainOracle, ChainPosition, DescriptorExt, FullTxOut, IndexedTxGraph, Merge, }; use bdk_coin_select::{ metrics::LowestFee, Candidate, ChangePolicy, CoinSelector, DrainWeights, FeeRate, Target, @@ -278,9 +278,9 @@ pub fn create_tx( 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()), } @@ -417,7 +417,7 @@ pub fn create_tx( } // Alias the elements of `planned_utxos` -pub type PlanUtxo = (Plan, FullTxOut); +pub type PlanUtxo = (Plan, FullTxOut>); pub fn planned_utxos( graph: &KeychainTxGraph, @@ -570,8 +570,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::>(); From 5aaf4caf4485fc96a79db9e4274c51c3605aca21 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=BF=97=E5=AE=87?= Date: Fri, 13 Feb 2026 14:43:13 +0000 Subject: [PATCH 5/8] refactor(chain)!: split `canonical_view` into `canonical`,`canonical_view_task` It moves the shared types: `CanonicalTx`, `Canonical`, `CanonicalView`, `CanonicalTxs` and other convenience methods into `canonical.rs`. Keep the phase-2 task (`CanonicalViewTask` in `canonical_view_task.rs`. Also, rename `FullTxOut` to `CanonicalTxOut`, and move it to `canonical.rs`. Co-Authored-By: Claude Opus 4.6 --- .../src/{canonical_view.rs => canonical.rs} | 130 +++++++++++- crates/chain/src/canonical_task.rs | 199 +----------------- crates/chain/src/canonical_view_task.rs | 199 ++++++++++++++++++ crates/chain/src/chain_data.rs | 100 +-------- crates/chain/src/lib.rs | 6 +- examples/example_cli/src/lib.rs | 4 +- 6 files changed, 333 insertions(+), 305 deletions(-) rename crates/chain/src/{canonical_view.rs => canonical.rs} (77%) create mode 100644 crates/chain/src/canonical_view_task.rs diff --git a/crates/chain/src/canonical_view.rs b/crates/chain/src/canonical.rs similarity index 77% rename from crates/chain/src/canonical_view.rs rename to crates/chain/src/canonical.rs index 03a9633cd1..ac80a45c90 100644 --- a/crates/chain/src/canonical_view.rs +++ b/crates/chain/src/canonical.rs @@ -24,14 +24,15 @@ use crate::collections::HashMap; use alloc::sync::Arc; -use core::{fmt, ops::RangeBounds}; - use alloc::vec::Vec; +use core::{fmt, ops::RangeBounds}; use bdk_core::BlockId; -use bitcoin::{Amount, OutPoint, ScriptBuf, Transaction, Txid}; +use bitcoin::{ + constants::COINBASE_MATURITY, Amount, OutPoint, ScriptBuf, Transaction, TxOut, Txid, +}; -use crate::{spk_txout::SpkTxOutIndex, Anchor, Balance, ChainPosition, FullTxOut}; +use crate::{spk_txout::SpkTxOutIndex, Anchor, Balance, CanonicalViewTask, ChainPosition, TxGraph}; /// A single canonical transaction with its position. /// @@ -68,6 +69,104 @@ impl PartialOrd for CanonicalTx

{ } } +/// 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, +} + +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)) + } +} + +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`]. /// /// `Canonical` provides an ordered, conflict-resolved set of transactions. It determines @@ -153,7 +252,7 @@ impl Canonical { /// - 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> { + pub fn txout(&self, op: OutPoint) -> Option> { let (tx, pos) = self.txs.get(&op.txid)?; let vout: usize = op.vout.try_into().ok()?; let txout = tx.output.get(vout)?; @@ -161,7 +260,7 @@ impl Canonical { let (_, spent_by_pos) = &self.txs[spent_by_txid]; (spent_by_pos.clone(), *spent_by_txid) }); - Some(FullTxOut { + Some(CanonicalTxOut { pos: pos.clone(), outpoint: op, txout: txout.clone(), @@ -204,7 +303,7 @@ impl Canonical { /// 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 set. + /// 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 @@ -230,7 +329,7 @@ impl Canonical { 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)?))) @@ -261,7 +360,7 @@ impl Canonical { 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()) } @@ -339,7 +438,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; @@ -389,3 +488,14 @@ impl CanonicalView { } } } + +impl CanonicalTxs { + /// Creates a [`CanonicalViewTask`] that resolves [`CanonicalReason`]s into [`ChainPosition`]s. + /// + /// This is the second phase of the canonicalization pipeline. The resulting task + /// queries the chain to verify anchors for transitively anchored transactions and + /// produces a [`CanonicalView`] with resolved chain positions. + pub fn view_task<'g>(self, tx_graph: &'g TxGraph) -> CanonicalViewTask<'g, A> { + CanonicalViewTask::new(tx_graph, self.tip, self.order, self.txs, self.spends) + } +} diff --git a/crates/chain/src/canonical_task.rs b/crates/chain/src/canonical_task.rs index 8f4ce48f3d..702a909dac 100644 --- a/crates/chain/src/canonical_task.rs +++ b/crates/chain/src/canonical_task.rs @@ -1,6 +1,6 @@ use crate::collections::{HashMap, HashSet, VecDeque}; use crate::tx_graph::{TxAncestors, TxDescendants}; -use crate::{Anchor, CanonicalTxs, CanonicalView, ChainPosition, TxGraph}; +use crate::{Anchor, CanonicalTxs, TxGraph}; use alloc::boxed::Box; use alloc::collections::BTreeSet; use alloc::sync::Arc; @@ -341,197 +341,6 @@ impl<'g, A: Anchor> CanonicalTask<'g, A> { } } -/// Represents the current stage of view task processing. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] -enum ViewStage { - /// Processing transactions to resolve their chain positions. - #[default] - ResolvingPositions, - /// 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`]), 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. -pub struct CanonicalViewTask<'g, A> { - tx_graph: &'g TxGraph, - tip: BlockId, - - /// Transactions in canonical order with their reasons. - canonical_order: Vec, - canonical_txs: HashMap, CanonicalReason)>, - spends: HashMap, - - /// Transactions that need anchor verification (transitively anchored). - unprocessed_anchor_checks: VecDeque<(Txid, &'g BTreeSet)>, - - /// Resolved direct anchors for transitively anchored transactions. - direct_anchors: HashMap, - - current_stage: ViewStage, -} - -impl<'g, A: Anchor> ChainQuery for CanonicalViewTask<'g, A> { - type Output = CanonicalView; - - fn tip(&self) -> BlockId { - self.tip - } - - fn next_query(&mut self) -> Option { - loop { - match self.current_stage { - ViewStage::ResolvingPositions => { - if let Some((_txid, anchors)) = self.unprocessed_anchor_checks.front() { - let block_ids = - anchors.iter().map(|anchor| anchor.anchor_block()).collect(); - return Some(block_ids); - } - } - ViewStage::Finished => return None, - } - - self.current_stage = ViewStage::Finished; - } - } - - fn resolve_query(&mut self, response: ChainResponse) { - match self.current_stage { - ViewStage::ResolvingPositions => { - if let Some((txid, anchors)) = self.unprocessed_anchor_checks.pop_front() { - let best_anchor = response.and_then(|block_id| { - anchors - .iter() - .find(|anchor| anchor.anchor_block() == block_id) - .cloned() - }); - - if let Some(best_anchor) = best_anchor { - self.direct_anchors.insert(txid, best_anchor); - } - } - } - ViewStage::Finished => { - debug_assert!(false, "resolve_query called in Finished stage"); - } - } - } - - fn finish(self) -> Self::Output { - let mut view_order = Vec::new(); - let mut view_txs = HashMap::new(); - - for txid in &self.canonical_order { - if let Some((tx, 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 based on reason - let chain_position = match reason { - CanonicalReason::Assumed { descendant } => match descendant { - Some(_) => match self.direct_anchors.get(txid) { - 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 self.direct_anchors.get(txid) { - 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.clone(), chain_position.cloned())); - } - } - - CanonicalView::new(self.tip, view_order, view_txs, self.spends) - } -} - -impl CanonicalTxs { - /// Creates a [`CanonicalViewTask`] that resolves [`CanonicalReason`]s into [`ChainPosition`]s. - /// - /// This is the second phase of the canonicalization pipeline. The resulting task - /// queries the chain to verify anchors for transitively anchored transactions and - /// produces a [`CanonicalView`] with resolved chain positions. - pub fn view_task<'g>(self, tx_graph: &'g TxGraph) -> CanonicalViewTask<'g, A> { - let all_anchors = tx_graph.all_anchors(); - - // Find transactions that need anchor verification - let mut unprocessed_anchor_checks = VecDeque::new(); - for txid in &self.order { - if let Some((_, reason)) = self.txs.get(txid) { - // Skip ObservedIn transactions - they don't have anchors to verify - if matches!(reason, CanonicalReason::ObservedIn { .. }) { - continue; - } - // Transitively anchored transactions need their own anchor checked - if reason.is_transitive() { - if let Some(anchors) = all_anchors.get(txid) { - unprocessed_anchor_checks.push_back((*txid, anchors)); - } - } - } - } - - CanonicalViewTask { - tx_graph, - tip: self.tip, - canonical_order: self.order, - canonical_txs: self.txs, - spends: self.spends, - unprocessed_anchor_checks, - direct_anchors: HashMap::new(), - current_stage: ViewStage::default(), - } - } -} - /// Represents when and where a transaction was last observed in. #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] pub enum ObservedIn { @@ -625,6 +434,11 @@ impl CanonicalReason { 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)] @@ -632,6 +446,7 @@ impl CanonicalReason { mod tests { use super::*; use crate::local_chain::LocalChain; + use crate::ChainPosition; use bitcoin::{hashes::Hash, BlockHash, TxIn, TxOut}; #[test] diff --git a/crates/chain/src/canonical_view_task.rs b/crates/chain/src/canonical_view_task.rs new file mode 100644 index 0000000000..a6d0d87a45 --- /dev/null +++ b/crates/chain/src/canonical_view_task.rs @@ -0,0 +1,199 @@ +//! 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::sync::Arc; +use alloc::vec::Vec; + +use bdk_core::{BlockId, ChainQuery, ChainRequest, ChainResponse}; +use bitcoin::{OutPoint, Transaction, Txid}; + +use crate::{Anchor, CanonicalView, ChainPosition, TxGraph}; + +/// Represents the current stage of view task processing. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +enum ViewStage { + /// Processing transactions to resolve their chain positions. + #[default] + ResolvingPositions, + /// 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. +pub struct CanonicalViewTask<'g, A> { + tx_graph: &'g TxGraph, + tip: BlockId, + + /// Transactions in canonical order with their reasons. + canonical_order: Vec, + canonical_txs: HashMap, CanonicalReason)>, + spends: HashMap, + + /// Transactions that need anchor verification (transitively anchored). + unprocessed_anchor_checks: VecDeque<(Txid, &'g BTreeSet)>, + + /// Resolved direct anchors for transitively anchored transactions. + direct_anchors: HashMap, + + current_stage: ViewStage, +} + +impl<'g, A: Anchor> CanonicalViewTask<'g, A> { + /// Creates a new [`CanonicalViewTask`]. + /// + /// Accepts canonical transaction data and a reference to the [`TxGraph`]. + /// Scans transactions to find those needing anchor verification. + pub fn new( + tx_graph: &'g TxGraph, + tip: BlockId, + order: Vec, + txs: HashMap, CanonicalReason)>, + spends: HashMap, + ) -> Self { + let all_anchors = tx_graph.all_anchors(); + + let mut unprocessed_anchor_checks = VecDeque::new(); + for txid in &order { + if let Some((_, reason)) = txs.get(txid) { + if matches!(reason, CanonicalReason::ObservedIn { .. }) { + continue; + } + if reason.is_transitive() || reason.is_assumed() { + if let Some(anchors) = all_anchors.get(txid) { + unprocessed_anchor_checks.push_back((*txid, anchors)); + } + } + } + } + + Self { + tx_graph, + tip, + canonical_order: order, + canonical_txs: txs, + spends, + unprocessed_anchor_checks, + direct_anchors: HashMap::new(), + current_stage: ViewStage::default(), + } + } +} + +impl<'g, A: Anchor> ChainQuery for CanonicalViewTask<'g, A> { + type Output = CanonicalView; + + fn tip(&self) -> BlockId { + self.tip + } + + fn next_query(&mut self) -> Option { + loop { + match self.current_stage { + ViewStage::ResolvingPositions => { + if let Some((_txid, anchors)) = self.unprocessed_anchor_checks.front() { + let block_ids = + anchors.iter().map(|anchor| anchor.anchor_block()).collect(); + return Some(block_ids); + } + } + ViewStage::Finished => return None, + } + + self.current_stage = ViewStage::Finished; + } + } + + fn resolve_query(&mut self, response: ChainResponse) { + match self.current_stage { + ViewStage::ResolvingPositions => { + if let Some((txid, anchors)) = self.unprocessed_anchor_checks.pop_front() { + let best_anchor = response.and_then(|block_id| { + anchors + .iter() + .find(|anchor| anchor.anchor_block() == block_id) + .cloned() + }); + + if let Some(best_anchor) = best_anchor { + self.direct_anchors.insert(txid, best_anchor); + } + } + } + ViewStage::Finished => { + debug_assert!(false, "resolve_query called in Finished stage"); + } + } + } + + fn finish(self) -> Self::Output { + let mut view_order = Vec::new(); + let mut view_txs = HashMap::new(); + + for txid in &self.canonical_order { + if let Some((tx, 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 based on reason + let chain_position = match reason { + CanonicalReason::Assumed { .. } => match self.direct_anchors.get(txid) { + Some(anchor) => ChainPosition::Confirmed { + anchor, + transitively: None, + }, + None => ChainPosition::Unconfirmed { + first_seen: tx_node.first_seen, + last_seen: tx_node.last_seen, + }, + }, + CanonicalReason::Anchor { anchor, descendant } => match descendant { + Some(_) => match self.direct_anchors.get(txid) { + 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.clone(), chain_position.cloned())); + } + } + + CanonicalView::new(self.tip, view_order, view_txs, self.spends) + } +} diff --git a/crates/chain/src/chain_data.rs b/crates/chain/src/chain_data.rs index 9bbf3a85c7..7ec88fba8f 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,104 +161,6 @@ impl PartialOrd for ChainPosition { } } -/// A `TxOut` with as much data as we can retrieve about it. -/// -/// 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 FullTxOut

{ - /// 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, -} - -impl Ord for FullTxOut

{ - 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 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.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 - } -} - #[cfg(test)] #[cfg_attr(coverage_nightly, coverage(off))] mod test { diff --git a/crates/chain/src/lib.rs b/crates/chain/src/lib.rs index 2e0a83c277..8c42473ba4 100644 --- a/crates/chain/src/lib.rs +++ b/crates/chain/src/lib.rs @@ -46,8 +46,10 @@ mod chain_oracle; pub use chain_oracle::*; mod canonical_task; pub use canonical_task::*; -mod canonical_view; -pub use canonical_view::*; +mod canonical; +pub use canonical::*; +mod canonical_view_task; +pub use canonical_view_task::*; #[doc(hidden)] pub mod example_utils; diff --git a/examples/example_cli/src/lib.rs b/examples/example_cli/src/lib.rs index 1a3c6b3960..f3dd86c516 100644 --- a/examples/example_cli/src/lib.rs +++ b/examples/example_cli/src/lib.rs @@ -23,7 +23,7 @@ use bdk_chain::ConfirmationBlockTime; use bdk_chain::{ indexer::keychain_txout::{self, KeychainTxOutIndex}, local_chain::{self, LocalChain}, - tx_graph, ChainOracle, ChainPosition, DescriptorExt, FullTxOut, IndexedTxGraph, Merge, + tx_graph, CanonicalTxOut, ChainOracle, ChainPosition, DescriptorExt, IndexedTxGraph, Merge, }; use bdk_coin_select::{ metrics::LowestFee, Candidate, ChangePolicy, CoinSelector, DrainWeights, FeeRate, Target, @@ -417,7 +417,7 @@ pub fn create_tx( } // Alias the elements of `planned_utxos` -pub type PlanUtxo = (Plan, FullTxOut>); +pub type PlanUtxo = (Plan, CanonicalTxOut>); pub fn planned_utxos( graph: &KeychainTxGraph, From 36d654d19e5936d9736bdf2cc744012cbc6cfd5f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=BF=97=E5=AE=87?= Date: Sun, 8 Mar 2026 23:50:34 +0000 Subject: [PATCH 6/8] chore(chain,example)!: remove `ChainOracle`, update docs --- crates/chain/src/canonical.rs | 3 +- crates/chain/src/canonical_task.rs | 6 +-- crates/chain/src/chain_oracle.rs | 25 --------- crates/chain/src/indexed_tx_graph.rs | 3 +- crates/chain/src/lib.rs | 2 - crates/chain/src/local_chain.rs | 58 ++++++++++----------- crates/chain/tests/test_indexed_tx_graph.rs | 2 +- crates/chain/tests/test_tx_graph.rs | 4 +- examples/example_cli/src/lib.rs | 8 ++- 9 files changed, 42 insertions(+), 69 deletions(-) delete mode 100644 crates/chain/src/chain_oracle.rs diff --git a/crates/chain/src/canonical.rs b/crates/chain/src/canonical.rs index ac80a45c90..3a32d0fdf6 100644 --- a/crates/chain/src/canonical.rs +++ b/crates/chain/src/canonical.rs @@ -490,7 +490,8 @@ impl CanonicalView { } impl CanonicalTxs { - /// Creates a [`CanonicalViewTask`] that resolves [`CanonicalReason`]s into [`ChainPosition`]s. + /// Creates a [`CanonicalViewTask`] that resolves [`CanonicalReason`](crate::CanonicalReason)s + /// into [`ChainPosition`]s. /// /// This is the second phase of the canonicalization pipeline. The resulting task /// queries the chain to verify anchors for transitively anchored transactions and diff --git a/crates/chain/src/canonical_task.rs b/crates/chain/src/canonical_task.rs index 702a909dac..0cb8ffbf4a 100644 --- a/crates/chain/src/canonical_task.rs +++ b/crates/chain/src/canonical_task.rs @@ -53,9 +53,9 @@ pub struct CanonicalParams { /// /// 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`]). The output is a [`CanonicalTxs`] which can then be -/// further processed by [`CanonicalViewTask`] to resolve reasons into -/// [`ChainPosition`]s. +/// (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> { tx_graph: &'g TxGraph, chain_tip: BlockId, diff --git a/crates/chain/src/chain_oracle.rs b/crates/chain/src/chain_oracle.rs deleted file mode 100644 index 08e697ed4c..0000000000 --- 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 c56f7d58de..496367ef26 100644 --- a/crates/chain/src/indexed_tx_graph.rs +++ b/crates/chain/src/indexed_tx_graph.rs @@ -436,7 +436,8 @@ impl IndexedTxGraph where A: Anchor, { - /// Creates a [`CanonicalTask`] to determine the [`CanonicalView`] of transactions. + /// Creates a [`CanonicalTask`] to determine the [`CanonicalView`](crate::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 diff --git a/crates/chain/src/lib.rs b/crates/chain/src/lib.rs index 8c42473ba4..41ed7cc098 100644 --- a/crates/chain/src/lib.rs +++ b/crates/chain/src/lib.rs @@ -42,8 +42,6 @@ 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_task; pub use canonical_task::*; mod canonical; diff --git a/crates/chain/src/local_chain.rs b/crates/chain/src/local_chain.rs index 38fc30c464..ee1d67fe0d 100644 --- a/crates/chain/src/local_chain.rs +++ b/crates/chain/src/local_chain.rs @@ -1,13 +1,12 @@ -//! 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::{Anchor, BlockId, CanonicalParams, CanonicalView, ChainOracle, Merge, TxGraph}; -use bdk_core::{ChainQuery, CheckPointEntry, ToBlockHash}; -pub use bdk_core::{CheckPoint, CheckPointIter}; +use crate::{Anchor, BlockId, CanonicalParams, CanonicalView, Merge, TxGraph}; +use bdk_core::{ChainQuery, ToBlockHash}; +pub use bdk_core::{CheckPoint, CheckPointEntry, CheckPointIter}; use bitcoin::block::Header; use bitcoin::BlockHash; @@ -61,7 +60,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, @@ -73,33 +72,38 @@ 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> { +// Methods for `LocalChain` +impl LocalChain { + /// Check if a block is in the chain. + /// + /// # Arguments + /// * `block` - The block to check + /// * `chain_tip` - The chain tip to check against + /// + /// # 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) { // 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), + _ => return None, }; - match chain_tip_cp.get(block.height) { - Some(cp) => Ok(Some(cp.hash() == block.hash)), - None => Ok(None), - } + chain_tip_cp + .get(block.height) + .map(|cp| cp.hash() == block.hash) } - fn get_chain_tip(&self) -> Result { - Ok(self.tip.block_id()) + /// Get the chain tip. + /// + /// # Returns + /// The [`BlockId`] of the chain tip. + pub fn chain_tip(&self) -> BlockId { + self.tip.block_id() } -} -// Methods for `LocalChain` -impl LocalChain { /// Canonicalize a transaction graph using this chain. /// /// This method processes any type implementing [`ChainQuery`], handling all its requests @@ -125,11 +129,7 @@ impl LocalChain { while let Some(request) = task.next_query() { let mut best_block_id = None; for block_id in &request { - if self - .is_block_in_chain(*block_id, chain_tip) - .expect("infallible") - == Some(true) - { + if self.is_block_in_chain(*block_id, chain_tip) == Some(true) { best_block_id = Some(*block_id); break; } diff --git a/crates/chain/tests/test_indexed_tx_graph.rs b/crates/chain/tests/test_indexed_tx_graph.rs index d6fabbab3b..96cafcb8ed 100644 --- a/crates/chain/tests/test_indexed_tx_graph.rs +++ b/crates/chain/tests/test_indexed_tx_graph.rs @@ -303,7 +303,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: /// diff --git a/crates/chain/tests/test_tx_graph.rs b/crates/chain/tests/test_tx_graph.rs index 3cbfe3414f..621bd67067 100644 --- a/crates/chain/tests/test_tx_graph.rs +++ b/crates/chain/tests/test_tx_graph.rs @@ -7,7 +7,7 @@ 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, } } diff --git a/examples/example_cli/src/lib.rs b/examples/example_cli/src/lib.rs index f3dd86c516..497e6f47a5 100644 --- a/examples/example_cli/src/lib.rs +++ b/examples/example_cli/src/lib.rs @@ -23,7 +23,7 @@ use bdk_chain::ConfirmationBlockTime; use bdk_chain::{ indexer::keychain_txout::{self, KeychainTxOutIndex}, local_chain::{self, LocalChain}, - tx_graph, CanonicalTxOut, ChainOracle, ChainPosition, DescriptorExt, IndexedTxGraph, Merge, + tx_graph, CanonicalTxOut, ChainPosition, DescriptorExt, IndexedTxGraph, Merge, }; use bdk_coin_select::{ metrics::LowestFee, Candidate, ChangePolicy, CoinSelector, DrainWeights, FeeRate, Target, @@ -389,9 +389,7 @@ pub fn create_tx( 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 { @@ -551,7 +549,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 { From a3b2e104a8164e2c8c51d456613317f5c18162e3 Mon Sep 17 00:00:00 2001 From: Leonardo Lima Date: Mon, 16 Mar 2026 20:01:22 -0300 Subject: [PATCH 7/8] fix(chain): position resolution for assumed txs - add new `test_canonical_view_task.rs` to handle different scenarios of chain position resolution. - fixes the assumed canonical txs chain position resolution, especially for transitively assumed canonical transactions, where there's an anchored/confirmed descendant. --- crates/chain/src/canonical_view_task.rs | 44 +++- .../chain/tests/test_canonical_view_task.rs | 190 ++++++++++++++++++ 2 files changed, 224 insertions(+), 10 deletions(-) create mode 100644 crates/chain/tests/test_canonical_view_task.rs diff --git a/crates/chain/src/canonical_view_task.rs b/crates/chain/src/canonical_view_task.rs index a6d0d87a45..d981bcef44 100644 --- a/crates/chain/src/canonical_view_task.rs +++ b/crates/chain/src/canonical_view_task.rs @@ -2,6 +2,7 @@ use crate::canonical_task::{CanonicalReason, ObservedIn}; use crate::collections::{HashMap, VecDeque}; +use crate::tx_graph::TxDescendants; use alloc::collections::BTreeSet; use alloc::sync::Arc; use alloc::vec::Vec; @@ -152,16 +153,39 @@ impl<'g, A: Anchor> ChainQuery for CanonicalViewTask<'g, A> { // Determine chain position based on reason let chain_position = match reason { - CanonicalReason::Assumed { .. } => match self.direct_anchors.get(txid) { - Some(anchor) => ChainPosition::Confirmed { - anchor, - transitively: None, - }, - None => ChainPosition::Unconfirmed { - first_seen: tx_node.first_seen, - last_seen: tx_node.last_seen, - }, - }, + CanonicalReason::Assumed { .. } => { + match self.direct_anchors.get(txid) { + // it has a direct anchor found + // regardless if it's directly or transitively assumed canonical + Some(anchor) => ChainPosition::Confirmed { + anchor, + transitively: None, + }, + None => TxDescendants::new_exclude_root( + self.tx_graph, + *txid, + // ensure descendant is canonical + |_, desc_txid| -> Option { + self.canonical_txs + .contains_key(&desc_txid) + .then_some(desc_txid) + }, + ) + // ensure descendant has direct anchor + .find_map(|desc_txid| { + self.direct_anchors.get(&desc_txid).map(|anchor| { + ChainPosition::Confirmed { + anchor, + transitively: Some(desc_txid), + } + }) + }) + .unwrap_or(ChainPosition::Unconfirmed { + first_seen: tx_node.first_seen, + last_seen: tx_node.last_seen, + }), + } + } CanonicalReason::Anchor { anchor, descendant } => match descendant { Some(_) => match self.direct_anchors.get(txid) { Some(anchor) => ChainPosition::Confirmed { diff --git a/crates/chain/tests/test_canonical_view_task.rs b/crates/chain/tests/test_canonical_view_task.rs new file mode 100644 index 0000000000..397819f4fc --- /dev/null +++ b/crates/chain/tests/test_canonical_view_task.rs @@ -0,0 +1,190 @@ +#![cfg(feature = "miniscript")] + +mod common; + +use bdk_chain::{CanonicalReason, ChainPosition}; +use bdk_testenv::{block_id, hash, local_chain}; +use bitcoin::Txid; +use common::*; +use std::collections::HashSet; + +#[test] +fn test_assumed_canonical_scenarios() { + // scenario: "txC spends txB; txB spends txA; txB is anchored; txC is assumed canonical" + + // create a local chain + let local_chain = local_chain![ + (0, hash!("genesis")), + (1, hash!("block1")), + (2, hash!("block2")), + (3, hash!("block3")), + (4, hash!("block4")), + (5, hash!("block5")), + (6, hash!("block6")), + (7, hash!("block7")), + (8, hash!("block8")), + (9, hash!("block9")), + (10, hash!("block10")) + ]; + let chain_tip = local_chain.tip().block_id(); + + // create arrays before scenario to avoid lifetime issues + let tx_templates = [ + TxTemplate { + tx_name: "txA", + inputs: &[TxInTemplate::Bogus], + outputs: &[TxOutTemplate::new(100000, Some(0))], + anchors: &[], + last_seen: None, + assume_canonical: false, + }, + TxTemplate { + tx_name: "txB", + inputs: &[TxInTemplate::PrevTx("txA", 0)], + outputs: &[TxOutTemplate::new(50000, Some(0))], + anchors: &[block_id!(5, "block5")], + last_seen: None, + assume_canonical: false, + }, + TxTemplate { + tx_name: "txC", + inputs: &[TxInTemplate::PrevTx("txB", 0)], + outputs: &[TxOutTemplate::new(25000, Some(0))], + anchors: &[], + last_seen: None, + assume_canonical: true, + }, + ]; + + let exp_canonical_txs = HashSet::from(["txA", "txB", "txC"]); + + let env = init_graph(&tx_templates); + + // get the actual txid from given tx_name. + let txid_c = *env.txid_to_name.get("txC").unwrap(); + + // build the expected `CanonicalReason` with specific descendant txid's + // + // in this scenario: txC is assumed canonical, and it's descendant of txB and txA + // therefore the whole chain should become assumed canonical. + // + // the descendant txid field refers to the directly **assumed canonical txC** + let exp_reasons = vec![ + ( + "txA", + CanonicalReason::Assumed { + descendant: Some(txid_c), + }, + ), + ( + "txB", + CanonicalReason::Assumed { + descendant: Some(txid_c), + }, + ), + ("txC", CanonicalReason::Assumed { descendant: None }), + ]; + + // build task & canonicalize + let canonical_params = env.canonicalization_params; + let canonical_task = env.tx_graph.canonical_task(chain_tip, canonical_params); + let canonical_txs = local_chain.canonicalize(canonical_task); + + // assert canonical transactions + let exp_canonical_txids: HashSet = exp_canonical_txs + .iter() + .map(|tx_name| { + *env.txid_to_name + .get(tx_name) + .expect("txid should exist for tx_name") + }) + .collect::>(); + + let canonical_txids = canonical_txs + .txs() + .map(|canonical_tx| canonical_tx.txid) + .collect::>(); + + assert_eq!( + canonical_txids, exp_canonical_txids, + "[{}] canonical transactions mismatch", + "txC spends txB; txB spends txA; txB is anchored; txC is assumed canonical" + ); + + // assert canonical reasons + for (tx_name, exp_reason) in exp_reasons { + let txid = env + .txid_to_name + .get(tx_name) + .expect("txid should exist for tx_name"); + + let canonical_reason = canonical_txs + .txs() + .find(|ctx| &ctx.txid == txid) + .expect("expected txid should exist in canonical txs") + .pos; + + assert_eq!( + canonical_reason, exp_reason, + "[{}] canonical reason mismatch for {}", + "txC spends txB; txB spends txA; txB is anchored; txC is assumed canonical", tx_name + ) + } + + let txid_b = *env.txid_to_name.get("txB").unwrap(); + + // build the expected `ChainPosition` with specific txid's for transitively confirmed txs. + // + // in this scenario: + // + // txA: should be confirmed transitively by txB. + // txB: should be confirmed, has a direct anchor(block5). + // txC: should be unconfirmed, has been assumed canonical though has no direct anchors. + let exp_positions = vec![ + ( + "txA", + ChainPosition::Confirmed { + anchor: block_id!(5, "block5"), + transitively: Some(txid_b), + }, + ), + ( + "txB", + ChainPosition::Confirmed { + anchor: block_id!(5, "block5"), + transitively: None, + }, + ), + ( + "txC", + ChainPosition::Unconfirmed { + first_seen: None, + last_seen: None, + }, + ), + ]; + + // build task & resolve positions + let view_task = canonical_txs.view_task(&env.tx_graph); + let canonical_view = local_chain.canonicalize(view_task); + + // assert final positions + for (tx_name, exp_position) in exp_positions { + let txid = *env + .txid_to_name + .get(tx_name) + .expect("txid should exist for tx_name"); + + let canonical_position = canonical_view + .txs() + .find(|ctx| ctx.txid == txid) + .expect("expected txid should exist in canonical view") + .pos; + + assert_eq!( + canonical_position, exp_position, + "[{}] canonical position mismatch for {}", + "txC spends txB; txB spends txA; txB is anchored; txC is assumed canonical", tx_name + ); + } +} From 5969a648b5ae4b76616763c8f95d365e9e46ba20 Mon Sep 17 00:00:00 2001 From: Leonardo Lima Date: Fri, 12 Sep 2025 14:39:49 +1000 Subject: [PATCH 8/8] feat(chain): add topological ordering to `CanonicalView` - add `sort_topologically` to `CanonicalViewTask::finish()` using Kahn's algorithm, ensuring `CanonicalView` returns `Txid`s in topological order (parents before children) - add `test_list_ordered_canonical_txs` with scenarios covering various transaction graph shapes --- crates/chain/src/canonical_view_task.rs | 80 +++- crates/chain/tests/test_tx_graph.rs | 510 ++++++++++++++++++++++++ 2 files changed, 589 insertions(+), 1 deletion(-) diff --git a/crates/chain/src/canonical_view_task.rs b/crates/chain/src/canonical_view_task.rs index d981bcef44..d92e304741 100644 --- a/crates/chain/src/canonical_view_task.rs +++ b/crates/chain/src/canonical_view_task.rs @@ -1,7 +1,7 @@ //! Phase 2 task: resolves canonical reasons into chain positions. use crate::canonical_task::{CanonicalReason, ObservedIn}; -use crate::collections::{HashMap, VecDeque}; +use crate::collections::{HashMap, HashSet, VecDeque}; use crate::tx_graph::TxDescendants; use alloc::collections::BTreeSet; use alloc::sync::Arc; @@ -88,6 +88,83 @@ impl<'g, A: Anchor> CanonicalViewTask<'g, A> { } } +/// Topologically sort [`Txid`]s (parents before children) using Kahn's algorithm. +/// +/// Given a set of canonical transactions and their spending relationships, +/// returns a new ordering where for every spending relationship A -> B +/// (where B spends an output of A), A appears before B. +/// +/// The algorithm works in three phases: +/// +/// 1. **Build the dependency graph**: Using the `spends` map, derive +/// parent->child edges. Each `(outpoint, child_txid)` entry means +/// `outpoint.txid` (the parent) must come before `child_txid`. Only edges +/// where both parent and child are in the canonical set are considered. +/// +/// 2. **Find roots**: [`Txid`]s with `in_degree == 0` have no canonical +/// parents and can appear first. These seed the processing queue. +/// +/// 3. **BFS traversal**: Dequeue a [`Txid`], append it to the result, +/// and decrement the `in_degree` of each of its children. Whenever a +/// child reaches `in_degree == 0`, all its parents have been placed, +/// so it is enqueued. +/// +/// # Note +/// +/// The relative order among unrelated transactions (those with no spending +/// relationship) is not guaranteed to be deterministic across runs, since +/// it depends on `HashMap` iteration order. +fn sort_topologically(order: &[Txid], spends: &HashMap) -> Vec { + // Set of canonical txids — we only consider parent→child edges where + // both sides are in this set. + let canonical_set: HashSet = order.iter().copied().collect(); + + // in_degree[txid] tracks how many canonical parents this tx spends from. + // A tx with in_degree 0 has no canonical parents and can appear first. + let mut in_degree: HashMap = order.iter().map(|&txid| (txid, 0)).collect(); + + // Adjacency list: maps each parent txid to the children that spend its + // outputs. Derived from `spends` where each entry (outpoint → child_txid) + // gives us an edge from outpoint.txid (parent) to child_txid. + let mut children: HashMap> = HashMap::new(); + + for (outpoint, &child) in spends { + let parent = outpoint.txid; + // Only consider edges where the parent is also canonical — spending + // from a tx outside this canonical set is not a dependency. + if canonical_set.contains(&parent) { + children.entry(parent).or_default().push(child); + *in_degree.entry(child).or_default() += 1; + } + } + + // Seed the queue with root transactions (those with no canonical parents). + let mut queue: VecDeque = in_degree + .iter() + .filter(|(_, &d)| d == 0) + .map(|(&txid, _)| txid) + .collect(); + + // Process the queue: each time we "remove" a tx from the graph, we + // decrement the in_degree of its children. When a child reaches + // in_degree 0, all its parents have been placed so it is ready. + let mut sorted = Vec::with_capacity(order.len()); + while let Some(txid) = queue.pop_front() { + sorted.push(txid); + if let Some(deps) = children.get(&txid) { + for &child in deps { + let d = in_degree.get_mut(&child).unwrap(); + *d -= 1; + if *d == 0 { + queue.push_back(child); + } + } + } + } + + sorted +} + impl<'g, A: Anchor> ChainQuery for CanonicalViewTask<'g, A> { type Output = CanonicalView; @@ -218,6 +295,7 @@ impl<'g, A: Anchor> ChainQuery for CanonicalViewTask<'g, A> { } } + let view_order = sort_topologically(&view_order, &self.spends); CanonicalView::new(self.tip, view_order, view_txs, self.spends) } } diff --git a/crates/chain/tests/test_tx_graph.rs b/crates/chain/tests/test_tx_graph.rs index 621bd67067..2eab614cb2 100644 --- a/crates/chain/tests/test_tx_graph.rs +++ b/crates/chain/tests/test_tx_graph.rs @@ -9,6 +9,7 @@ use bdk_chain::{ tx_graph::{ChangeSet, TxGraph}, Anchor, ChainPosition, Merge, }; +use bdk_testenv::local_chain; use bdk_testenv::{block_id, hash, utils::new_tx}; use bitcoin::hex::FromHex; use bitcoin::Witness; @@ -1525,3 +1526,512 @@ fn test_get_first_seen_of_a_tx() { let first_seen = graph.get_tx_node(txid).unwrap().first_seen; assert_eq!(first_seen, Some(seen_at)); } + +/// A helper structure to constructs multiple [`TxGraph`] scenarios, used in +/// `test_list_ordered_canonical_txs`. +struct Scenario<'a> { + /// Name of the test scenario + name: &'a str, + /// Transaction templates + tx_templates: &'a [TxTemplate<'a, BlockId>], + /// Names of txs that must exist in the output of `list_canonical_txs` + exp_chain_txs: Vec<&'a str>, +} + +/// A helper method to assert the expected topological order for a given [`Vec`]. +fn is_ordered_topologically(txs: Vec, tx_graph: TxGraph) -> bool { + let mut seen: HashSet = HashSet::new(); + + for txid in txs { + let tx = tx_graph.get_tx(txid).expect("should exist"); + let inputs: Vec = tx + .input + .iter() + .map(|txin| txin.previous_output.txid) + .collect(); + + // assert that all the txin's have been seen already + for input_txid in inputs { + if !seen.contains(&input_txid) { + return false; + } + } + + // Add current transaction to seen set + seen.insert(txid); + } + + true +} + +#[test] +fn test_list_ordered_canonical_txs() { + // chain + let local_chain: LocalChain = local_chain!( + (0, hash!("A")), + (1, hash!("B")), + (2, hash!("C")), + (3, hash!("D")), + (4, hash!("E")), + (5, hash!("F")), + (6, hash!("G")) + ); + let chain_tip = local_chain.tip().block_id(); + + let scenarios = [ + // a0 b0 c0 + Scenario { + name: "a0, b0 and c0 are roots, does not spend from any other transaction, and are in the best chain", + tx_templates: &[ + TxTemplate { + tx_name: "a0", + inputs: &[], + outputs: &[TxOutTemplate::new(10000, Some(0))], + anchors: &[block_id!(1, "B")], + last_seen: None, + assume_canonical: false, + }, + TxTemplate { + tx_name: "b0", + inputs: &[], + outputs: &[TxOutTemplate::new(5000, Some(0))], + anchors: &[block_id!(1, "B")], + last_seen: None, + assume_canonical: false, + }, + TxTemplate { + tx_name: "c0", + inputs: &[], + outputs: &[TxOutTemplate::new(2500, Some(0))], + anchors: &[block_id!(1, "B")], + last_seen: None, + assume_canonical: false, + }, + ], + exp_chain_txs: Vec::from(["a0", "b0", "c0"]), + }, + // a0 b0 c0 + Scenario { + name: "a0, b0 and c0 are roots, does not spend from any other transaction, and have no anchor or last_seen", + tx_templates: &[ + TxTemplate { + tx_name: "a0", + inputs: &[], + outputs: &[TxOutTemplate::new(10000, Some(0))], + anchors: &[], + last_seen: None, + assume_canonical: false, + }, + TxTemplate { + tx_name: "b0", + inputs: &[], + outputs: &[TxOutTemplate::new(5000, Some(0))], + anchors: &[], + last_seen: None, + assume_canonical: false, + }, + TxTemplate { + tx_name: "c0", + inputs: &[], + outputs: &[TxOutTemplate::new(2500, Some(0))], + anchors: &[], + last_seen: None, + assume_canonical: false, + }, + ], + exp_chain_txs: Vec::from([]), + }, + // a0 b0 c0 + Scenario { + name: "A, B and C are roots, does not spend from any other transaction, and are all have the same `last_seen`", + tx_templates: &[ + TxTemplate { + tx_name: "A", + inputs: &[], + outputs: &[TxOutTemplate::new(10000, Some(0))], + anchors: &[], + last_seen: Some(1000), + assume_canonical: false, + }, + TxTemplate { + tx_name: "B", + inputs: &[], + outputs: &[TxOutTemplate::new(5000, Some(0))], + anchors: &[], + last_seen: Some(1000), + assume_canonical: false, + }, + TxTemplate { + tx_name: "C", + inputs: &[], + outputs: &[TxOutTemplate::new(2500, Some(0))], + anchors: &[], + last_seen: Some(1000), + assume_canonical: false, + }, + ], + exp_chain_txs: Vec::from(["A", "B", "C"]), + }, + // a0 + // \ + // b0 + // \ + // \ c0 + // \ / + // d0 + Scenario { + name: "b0 spends a0, d0 spends both b0 and c0, and are in the best chain", + tx_templates: &[ + TxTemplate { + tx_name: "a0", + inputs: &[], + outputs: &[TxOutTemplate::new(10000, Some(0))], + anchors: &[block_id!(1, "A")], + last_seen: None, + assume_canonical: false, + }, + TxTemplate { + tx_name: "b0", + inputs: &[TxInTemplate::PrevTx("a0", 0)], + outputs: &[TxOutTemplate::new(5000, Some(0))], + anchors: &[block_id!(2, "B")], + last_seen: None, + assume_canonical: false, + }, + TxTemplate { + tx_name: "c0", + inputs: &[], + outputs: &[TxOutTemplate::new(5000, Some(0))], + anchors: &[block_id!(3, "C")], + last_seen: None, + assume_canonical: false, + }, + TxTemplate { + tx_name: "d0", + inputs: &[TxInTemplate::PrevTx("b0", 0), TxInTemplate::PrevTx("c0", 0)], + outputs: &[TxOutTemplate::new(5000, Some(0))], + anchors: &[block_id!(3, "C")], + last_seen: None, + assume_canonical: false, + }, + ], + exp_chain_txs: Vec::from(["a0", "b0", "c0", "d0"]), + }, + // a0 c0 + // \ + // b0 + // \ + // d0 + Scenario { + name: "b0 spends a0, d0 spends b0, and a0, b0 and c0 are in the best chain", + tx_templates: &[ + TxTemplate { + tx_name: "a0", + inputs: &[], + outputs: &[TxOutTemplate::new(10000, Some(0))], + anchors: &[block_id!(1, "A")], + last_seen: None, + assume_canonical: false, + }, + TxTemplate { + tx_name: "b0", + inputs: &[TxInTemplate::PrevTx("a0", 0)], + outputs: &[TxOutTemplate::new(5000, Some(0))], + anchors: &[block_id!(2, "B")], + last_seen: None, + assume_canonical: false, + }, + TxTemplate { + tx_name: "c0", + inputs: &[], + outputs: &[TxOutTemplate::new(5000, Some(0))], + anchors: &[block_id!(3, "C")], + last_seen: None, + assume_canonical: false, + }, + TxTemplate { + tx_name: "d0", + inputs: &[TxInTemplate::PrevTx("b0", 0)], + outputs: &[TxOutTemplate::new(2500, Some(0))], + anchors: &[], + last_seen: Some(1000), + assume_canonical: false, + }, + ], + exp_chain_txs: Vec::from(["a0", "b0", "c0", "d0"]), + }, + // a0 + // \ + // b0 + // \ + // c0 + Scenario { + name: "c0 spend a0, b0 spend a0, and a0, b0 are in the best chain", + tx_templates: &[ + TxTemplate { + tx_name: "a0", + inputs: &[], + outputs: &[TxOutTemplate::new(10000, Some(0))], + anchors: &[block_id!(1, "B")], + last_seen: None, + assume_canonical: false, + }, + TxTemplate { + tx_name: "b0", + inputs: &[TxInTemplate::PrevTx("a0", 0)], + outputs: &[TxOutTemplate::new(5000, Some(0))], + anchors: &[block_id!(1, "B")], + last_seen: None, + assume_canonical: false, + }, + TxTemplate { + tx_name: "c0", + inputs: &[TxInTemplate::PrevTx("b0", 0)], + outputs: &[TxOutTemplate::new(2500, Some(0))], + anchors: &[], + last_seen: Some(1000), + assume_canonical: false, + }, + ], + exp_chain_txs: Vec::from(["a0", "b0", "c0"]), + }, + // a0 + // / \ + // b0 b1 + // / \ \ + // c0 \ c1 + // \ / + // d0 + Scenario { + name: "c0 spend b0, b0 spend a0, d0 spends both b0 and c1, c1 spend b1, b1 spend a0, and are all in the best chain", + tx_templates: &[TxTemplate { + tx_name: "a0", + inputs: &[], + outputs: &[TxOutTemplate::new(10000, Some(0)), TxOutTemplate::new(10000, Some(1))], + anchors: &[block_id!(1, "B")], + last_seen: None, + assume_canonical: false, + }, TxTemplate { + tx_name: "b0", + inputs: &[TxInTemplate::PrevTx("a0", 0)], + outputs: &[TxOutTemplate::new(10000, Some(0)), TxOutTemplate::new(10000, Some(1))], + anchors: &[block_id!(1, "B")], + last_seen: None, + assume_canonical: false, + }, + TxTemplate { + tx_name: "c0", + inputs: &[TxInTemplate::PrevTx("b0", 0)], + outputs: &[TxOutTemplate::new(5000, Some(0))], + anchors: &[block_id!(1, "B")], + last_seen: None, + assume_canonical: false, + }, + TxTemplate { + tx_name: "b1", + inputs: &[TxInTemplate::PrevTx("a0", 1)], + outputs: &[TxOutTemplate::new(10000, Some(0))], + anchors: &[block_id!(1, "B")], + last_seen: None, + assume_canonical: false, + }, + TxTemplate { + tx_name: "c1", + inputs: &[TxInTemplate::PrevTx("b1", 0)], + outputs: &[TxOutTemplate::new(10000, Some(0))], + anchors: &[block_id!(1, "B")], + last_seen: None, + assume_canonical: false, + }, + TxTemplate { + tx_name: "d0", + inputs: &[TxInTemplate::PrevTx("b0", 1), TxInTemplate::PrevTx("c1", 0),], + outputs: &[TxOutTemplate::new(10000, Some(0))], + anchors: &[block_id!(1, "B")], + last_seen: None, + assume_canonical: false, + }], + exp_chain_txs: Vec::from(["a0", "b0", "c0", "b1", "c1", "d0"]), + }, + // a0 d0 e0 + // / / \ + // b0 f0 f1 + // / \ / + // c0 g0 + Scenario { + name: "c0 spend b0, b0 spend a0, d0 does not spend any nor is spent by, g0 spends f0, f1, and f0 and f1 spends e0, and a0, d0, and e0 are in the best chain", + tx_templates: &[TxTemplate { + tx_name: "a0", + inputs: &[], + outputs: &[TxOutTemplate::new(10000, Some(0))], + anchors: &[block_id!(1, "B")], + last_seen: None, + assume_canonical: false, + }, TxTemplate { + tx_name: "b0", + inputs: &[TxInTemplate::PrevTx("a0", 0)], + outputs: &[TxOutTemplate::new(5000, Some(0)), TxOutTemplate::new(10000, Some(1))], + anchors: &[block_id!(2, "C")], + last_seen: None, + assume_canonical: false, + }, + TxTemplate { + tx_name: "c0", + inputs: &[TxInTemplate::PrevTx("b0", 0)], + outputs: &[TxOutTemplate::new(2500, Some(0))], + anchors: &[block_id!(3, "D")], + last_seen: None, + assume_canonical: false, + }, + TxTemplate { + tx_name: "d0", + inputs: &[], + outputs: &[TxOutTemplate::new(10000, Some(0))], + anchors: &[block_id!(3, "D")], + last_seen: None, + assume_canonical: false, + }, + TxTemplate { + tx_name: "e0", + inputs: &[], + outputs: &[TxOutTemplate::new(10000, Some(0)), TxOutTemplate::new(10000, Some(1))], + anchors: &[block_id!(4, "E")], + last_seen: None, + assume_canonical: false, + }, + TxTemplate { + tx_name: "f0", + inputs: &[TxInTemplate::PrevTx("e0", 0)], + outputs: &[TxOutTemplate::new(5000, Some(0))], + anchors: &[block_id!(5, "F")], + last_seen: None, + assume_canonical: false, + }, + TxTemplate { + tx_name: "f1", + inputs: &[TxInTemplate::PrevTx("e0", 1)], + outputs: &[TxOutTemplate::new(5000, Some(0))], + anchors: &[block_id!(5, "F")], + last_seen: None, + assume_canonical: false, + }, + TxTemplate { + tx_name: "g0", + inputs: &[TxInTemplate::PrevTx("f0", 0), TxInTemplate::PrevTx("f1", 0)], + outputs: &[TxOutTemplate::new(1000, Some(0))], + anchors: &[], + last_seen: Some(1000), + assume_canonical: false, + } + ], + exp_chain_txs: Vec::from(["a0", "b0", "c0", "d0", "e0", "f0", "f1", "g0"]), + }, + // a0 + // / \ \ + // e0 / b1 + // / / \ + // f0 / \ + // \/ \ + // b0 \ + // / \ / + // c0 \ c1 + // \ / + // d0 + Scenario { + name: "c0 spend b0, b0 spends both f0 and a0, f0 spend e0, e0 spend a0, d0 spends both b0 and c1, c1 spend b1, b1 spend a0, and are all in the best chain", + tx_templates: &[TxTemplate { + tx_name: "a0", + inputs: &[], + outputs: &[TxOutTemplate::new(10000, Some(0)), TxOutTemplate::new(10000, Some(1)), TxOutTemplate::new(10000, Some(2))], + // outputs: &[TxOutTemplate::new(10000, Some(1)), TxOutTemplate::new(10000, Some(2))], + anchors: &[block_id!(1, "B")], + last_seen: None, + assume_canonical: false, + }, + TxTemplate { + tx_name: "e0", + inputs: &[TxInTemplate::PrevTx("a0", 0)], + outputs: &[TxOutTemplate::new(10000, Some(0))], + anchors: &[block_id!(1, "B")], + last_seen: None, + assume_canonical: false, + }, + TxTemplate { + tx_name: "f0", + inputs: &[TxInTemplate::PrevTx("e0", 0)], + outputs: &[TxOutTemplate::new(10000, Some(0))], + anchors: &[block_id!(1, "B")], + last_seen: None, + assume_canonical: false, + }, + TxTemplate { + tx_name: "b0", + inputs: &[TxInTemplate::PrevTx("f0", 0), TxInTemplate::PrevTx("a0", 1)], + outputs: &[TxOutTemplate::new(10000, Some(0)), TxOutTemplate::new(10000, Some(1))], + anchors: &[block_id!(1, "B")], + last_seen: None, + assume_canonical: false, + }, + TxTemplate { + tx_name: "c0", + inputs: &[TxInTemplate::PrevTx("b0", 0)], + outputs: &[TxOutTemplate::new(5000, Some(0))], + anchors: &[block_id!(1, "B")], + last_seen: None, + assume_canonical: false, + }, + TxTemplate { + tx_name: "b1", + inputs: &[TxInTemplate::PrevTx("a0", 2)], + outputs: &[TxOutTemplate::new(10000, Some(0))], + anchors: &[block_id!(1, "B")], + last_seen: None, + assume_canonical: false, + }, + TxTemplate { + tx_name: "c1", + inputs: &[TxInTemplate::PrevTx("b1", 0)], + outputs: &[TxOutTemplate::new(10000, Some(0))], + anchors: &[block_id!(1, "B")], + last_seen: None, + assume_canonical: false, + }, + TxTemplate { + tx_name: "d0", + inputs: &[TxInTemplate::PrevTx("b0", 1), TxInTemplate::PrevTx("c1", 0), ], + outputs: &[TxOutTemplate::new(10000, Some(0))], + anchors: &[block_id!(1, "B")], + last_seen: None, + assume_canonical: false, + }], + exp_chain_txs: Vec::from(["a0", "e0", "f0", "b0", "c0", "b1", "c1", "d0"]), + }]; + + for (_, scenario) in scenarios.iter().enumerate() { + let env = init_graph(scenario.tx_templates.iter()); + + let canonical_view = + local_chain.canonical_view(&env.tx_graph, chain_tip, env.canonicalization_params); + + let canonical_txs: Vec = canonical_view.txs().map(|tx| tx.txid).collect(); + + let exp_txs = scenario + .exp_chain_txs + .iter() + .map(|txid| *env.txid_to_name.get(txid).expect("txid must exist")) + .collect::>(); + + assert_eq!( + canonical_txs.iter().copied().collect::>(), + exp_txs, + "\n[{}] 'list_canonical_txs' failed", + scenario.name + ); + + assert!( + is_ordered_topologically(canonical_txs, env.tx_graph), + "\n[{}] 'list_canonical_txs' failed to output the txs in topological order", + scenario.name + ); + } +}