]> Untitled Git - bdk/commitdiff
feat(core,chain): introduce `CanonicalizationTask` and `ChainQuery`
authorLeonardo Lima <oleonardolima@users.noreply.github.com>
Tue, 19 May 2026 18:53:10 +0000 (15:53 -0300)
committerLeonardo Lima <oleonardolima@users.noreply.github.com>
Tue, 2 Jun 2026 20:39:58 +0000 (17:39 -0300)
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 <noreply@anthropic.com>
crates/chain/src/canonical_iter.rs
crates/chain/src/canonical_task.rs [new file with mode: 0644]
crates/chain/src/canonical_view.rs
crates/chain/src/indexed_tx_graph.rs
crates/chain/src/lib.rs
crates/chain/src/local_chain.rs
crates/chain/src/tx_graph.rs
crates/core/src/chain_query.rs [new file with mode: 0644]
crates/core/src/lib.rs

index 204ead4511f3586ce7f048ae4e7ad7dd2f679f23..7866d12da208ca0da2195dada9b79dadc87c299c 100644 (file)
@@ -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<A> = HashMap<Txid, (Arc<Transaction>, CanonicalReason<A>)>;
 type NotCanonicalSet = HashSet<Txid>;
 
-/// 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<Txid>,
-}
-
 /// Iterates over canonical txs.
 pub struct CanonicalIter<'g, A, C> {
     tx_graph: &'g TxGraph<A>,
@@ -253,92 +243,3 @@ impl<A: Anchor, C: ChainOracle> 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<A> {
-    /// 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<Txid>,
-    },
-    /// 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<Txid>,
-    },
-    /// 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<Txid>,
-    },
-}
-
-impl<A: Clone> CanonicalReason<A> {
-    /// 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<Txid> {
-        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 (file)
index 0000000..7fabb21
--- /dev/null
@@ -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<A> = HashMap<Txid, (Arc<Transaction>, CanonicalReason<A>)>;
+type NotCanonicalSet = HashSet<Txid>;
+
+/// 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<Txid>,
+}
+
+/// Manages the canonicalization process without direct I/O operations.
+pub struct CanonicalizationTask<'g, A> {
+    tx_graph: &'g TxGraph<A>,
+    chain_tip: BlockId,
+
+    unprocessed_assumed_txs: Box<dyn Iterator<Item = (Txid, Arc<Transaction>)> + 'g>,
+    unprocessed_anchored_txs: VecDeque<(Txid, Arc<Transaction>, &'g BTreeSet<A>)>,
+    unprocessed_seen_txs: Box<dyn Iterator<Item = (Txid, Arc<Transaction>, u64)> + 'g>,
+    unprocessed_leftover_txs: VecDeque<(Txid, Arc<Transaction>, u32)>,
+    unprocessed_transitively_anchored_txs: VecDeque<(Txid, Arc<Transaction>, &'g BTreeSet<A>)>,
+
+    canonical: CanonicalMap<A>,
+    not_canonical: NotCanonicalSet,
+
+    // Store canonical transactions in order
+    canonical_order: Vec<Txid>,
+
+    // Track which transactions have direct anchors (not transitive)
+    direct_anchors: HashMap<Txid, A>,
+
+    // Track the current stage of processing
+    current_stage: CanonicalStage,
+}
+
+impl<'g, A: Anchor> ChainQuery for CanonicalizationTask<'g, A> {
+    type Output = CanonicalView<A>;
+
+    fn tip(&self) -> BlockId {
+        self.chain_tip
+    }
+
+    fn next_query(&mut self) -> Option<ChainRequest> {
+        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<A>,
+        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<Transaction>, reason: CanonicalReason<A>) {
+        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::<Txid>::new();
+        let mut staged_canonical = Vec::<(Txid, Arc<Transaction>, CanonicalReason<A>)>::new();
+
+        // Process ancestors
+        TxAncestors::new_include_root(
+            self.tx_graph,
+            tx,
+            |_: usize, tx: Arc<Transaction>| -> Option<Txid> {
+                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<A> {
+    /// 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<Txid>,
+    },
+    /// 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<Txid>,
+    },
+    /// 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<Txid>,
+    },
+}
+
+impl<A: Clone> CanonicalReason<A> {
+    /// 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<Txid> {
+        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 { .. }));
+    }
+}
index 0191f4507122992336b9d3319ed9c0359da7b026..ced3a44925dbdfcb9e6b4b9729c5a27338c12134 100644 (file)
@@ -91,6 +91,26 @@ pub struct CanonicalView<A> {
 }
 
 impl<A: Anchor> CanonicalView<A> {
+    /// 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<Txid>,
+        txs: HashMap<Txid, (Arc<Transaction>, ChainPosition<A>)>,
+        spends: HashMap<OutPoint, Txid>,
+    ) -> 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
index 693378401718a3ac37d42c2d84a9b302de9b529b..32ac444a055559a1904784f284c700acbd3b0558 100644 (file)
@@ -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<A>`] paired with an indexer `I`, enforcing that every insertion into the graph is
@@ -452,6 +452,20 @@ where
     ) -> CanonicalView<A> {
         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<A, I> AsRef<TxGraph<A>> for IndexedTxGraph<A, I> {
index be9170b1a5b310161ec41aa5e392a0cd768c743a..3d9b5a7cfa3ef258c56d11543a59d2f5ff9a7de5 100644 (file)
@@ -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::*;
 
index 5c938ee47374bf2d87b150d87735e2303871dbfb..96a671065ab0c4698797df2792148497042464e7 100644 (file)
@@ -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<D> ChainOracle for LocalChain<D> {
 
 // Methods for `LocalChain<BlockHash>`
 impl LocalChain<BlockHash> {
+    /// 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<BlockId> = 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<Q>(&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<A: Anchor>(
+        &self,
+        tx_graph: &TxGraph<A>,
+        tip: BlockId,
+        params: CanonicalizationParams,
+    ) -> CanonicalView<A> {
+        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.
     ///
index 72f8f4876fd989b22a34f819f09598deafe22cf2..c9a79513701b8ae66822e250b0c80f5aef787f1f 100644 (file)
 //! 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<A: Anchor> TxGraph<A> {
             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<A: Anchor> TxGraph<A> {
diff --git a/crates/core/src/chain_query.rs b/crates/core/src/chain_query.rs
new file mode 100644 (file)
index 0000000..660c1c7
--- /dev/null
@@ -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<BlockId>;
+
+/// Response containing the best confirmed [`BlockId`], if any.
+pub type ChainResponse = Option<BlockId>;
+
+/// 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<ChainRequest>;
+
+    /// 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;
+}
index cf02a99b0573b29b44c6823440ceb65c0fb03703..3d9b47aa5f0ad19a85e1bc6282006b2f53229b4c 100644 (file)
@@ -75,3 +75,6 @@ mod merge;
 pub use merge::*;
 
 pub mod spk_client;
+
+mod chain_query;
+pub use chain_query::*;