From: Leonardo Lima Date: Tue, 19 May 2026 18:53:10 +0000 (-0300) Subject: feat(core,chain): introduce `CanonicalizationTask` and `ChainQuery` X-Git-Url: http://internal-gitweb-vhost/blockdata/script/encode/-script/-progress/Network.html?a=commitdiff_plain;h=987da73fce7b7f4e015aa05cc8fa4e36deebf4df;p=bdk 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 --- diff --git a/crates/chain/src/canonical_iter.rs b/crates/chain/src/canonical_iter.rs index 204ead45..7866d12d 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 00000000..7fabb21d --- /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 0191f450..ced3a449 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 69337840..32ac444a 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 be9170b1..3d9b5a7c 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 5c938ee4..96a67106 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 72f8f487..c9a79513 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 00000000..660c1c74 --- /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 cf02a99b..3d9b47aa 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::*;