]> Untitled Git - bdk/commitdiff
refactor(chain)!: split `canonical_view` into `canonical`,`canonical_view_task`
author志宇 <hello@evanlinjin.me>
Fri, 13 Feb 2026 14:43:13 +0000 (14:43 +0000)
committer志宇 <hello@evanlinjin.me>
Sat, 13 Jun 2026 20:24:21 +0000 (20:24 +0000)
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 <noreply@anthropic.com>
crates/chain/src/canonical.rs [new file with mode: 0644]
crates/chain/src/canonical_task.rs
crates/chain/src/canonical_view.rs [deleted file]
crates/chain/src/canonical_view_task.rs [new file with mode: 0644]
crates/chain/src/chain_data.rs
crates/chain/src/lib.rs

diff --git a/crates/chain/src/canonical.rs b/crates/chain/src/canonical.rs
new file mode 100644 (file)
index 0000000..132a388
--- /dev/null
@@ -0,0 +1,501 @@
+//! Canonical view of transactions and unspent outputs.
+//!
+//! This module provides [`CanonicalView`], a utility for obtaining a canonical (ordered and
+//! conflict-resolved) view of transactions from a [`TxGraph`].
+//!
+//! ## Example
+//!
+//! ```
+//! # use bdk_chain::{TxGraph, CanonicalParams, CanonicalTask, local_chain::LocalChain};
+//! # use bdk_core::BlockId;
+//! # use bitcoin::hashes::Hash;
+//! # let tx_graph = TxGraph::<BlockId>::default();
+//! # let chain = LocalChain::from_blocks([(0, bitcoin::BlockHash::all_zeros())].into_iter().collect()).unwrap();
+//! let chain_tip = chain.tip().block_id();
+//! let params = CanonicalParams::default();
+//! let task = CanonicalTask::new(&tx_graph, chain_tip, params);
+//! let view = chain.canonicalize(task);
+//!
+//! // Iterate over canonical transactions
+//! for tx in view.txs() {
+//!     println!("Transaction {}: {:?}", tx.txid, tx.pos);
+//! }
+//! ```
+
+use crate::collections::HashMap;
+use alloc::sync::Arc;
+use alloc::vec::Vec;
+use core::{fmt, ops::RangeBounds};
+
+use bdk_core::BlockId;
+use bitcoin::{
+    constants::COINBASE_MATURITY, Amount, OutPoint, ScriptBuf, Transaction, TxOut, Txid,
+};
+
+use crate::{spk_txout::SpkTxOutIndex, Anchor, Balance, CanonicalViewTask, ChainPosition, TxGraph};
+
+/// 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 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<P> {
+    /// The position of this transaction.
+    ///
+    /// 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<Transaction>,
+}
+
+impl<P: Ord> Ord for CanonicalTx<P> {
+    fn cmp(&self, other: &Self) -> core::cmp::Ordering {
+        self.pos
+            .cmp(&other.pos)
+            // Txid tiebreaker for same position
+            .then_with(|| self.txid.cmp(&other.txid))
+    }
+}
+
+impl<P: Ord> PartialOrd for CanonicalTx<P> {
+    fn partial_cmp(&self, other: &Self) -> Option<core::cmp::Ordering> {
+        Some(self.cmp(other))
+    }
+}
+
+/// A canonical transaction output with position and spend information.
+///
+/// The position type `P` is generic - it can be [`ChainPosition`] for resolved views,
+/// or [`CanonicalReason`](crate::canonical_task::CanonicalReason) for unresolved canonicalization
+/// results.
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub struct CanonicalTxOut<P> {
+    /// 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<P: Ord> Ord for CanonicalTxOut<P> {
+    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<P: Ord> PartialOrd for CanonicalTxOut<P> {
+    fn partial_cmp(&self, other: &Self) -> Option<core::cmp::Ordering> {
+        Some(self.cmp(other))
+    }
+}
+
+impl<A: Anchor> CanonicalTxOut<ChainPosition<A>> {
+    /// 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 a conflict-resolved list 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<A>`] for resolved views (aka [`CanonicalView`])
+/// - [`CanonicalReason<A>`](crate::canonical_task::CanonicalReason) for unresolved results (aka
+///   [`CanonicalTxs`])
+///
+/// The view maintains:
+/// - A list of canonical transactions
+/// - A mapping of outpoints to the transactions that spend them
+/// - The chain tip used for canonicalization
+///
+/// [`TxGraph`]: crate::TxGraph
+#[derive(Debug)]
+pub struct Canonical<A, P> {
+    /// List of canonical transaction IDs.
+    pub(crate) order: Vec<Txid>,
+    /// Map of transaction IDs to their transaction data and position.
+    pub(crate) txs: HashMap<Txid, (Arc<Transaction>, P)>,
+    /// Map of outpoints to the transaction ID that spends them.
+    pub(crate) spends: HashMap<OutPoint, Txid>,
+    /// The chain tip at the time this view was created.
+    pub(crate) tip: BlockId,
+    /// Marker for the anchor type.
+    pub(crate) _anchor: core::marker::PhantomData<A>,
+}
+
+/// Type alias for canonical transactions with resolved [`ChainPosition`]s.
+pub type CanonicalView<A> = Canonical<A, ChainPosition<A>>;
+
+/// Type alias for canonical transactions with unresolved
+/// [`CanonicalReason`](crate::canonical_task::CanonicalReason)s.
+pub type CanonicalTxs<A> = Canonical<A, crate::canonical_task::CanonicalReason<A>>;
+
+impl<A, P: Clone> Canonical<A, P> {
+    /// Creates a [`Canonical`] from its constituent parts.
+    ///
+    /// This internal constructor is used by [`CanonicalTask`] to build the canonical set
+    /// after completing the canonicalization process. It takes the processed transaction
+    /// data including the canonical ordering, transaction map with positions, and
+    /// spend information.
+    pub(crate) fn new(
+        tip: BlockId,
+        order: Vec<Txid>,
+        txs: HashMap<Txid, (Arc<Transaction>, P)>,
+        spends: HashMap<OutPoint, Txid>,
+    ) -> Self {
+        Self {
+            tip,
+            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(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<CanonicalTx<P>> {
+        self.txs
+            .get(&txid)
+            .cloned()
+            .map(|(tx, pos)| CanonicalTx { pos, txid, tx })
+    }
+
+    /// Get a single canonical transaction output.
+    ///
+    /// Returns detailed information about a transaction output, including whether it has been
+    /// spent and by which transaction.
+    ///
+    /// Returns `None` if:
+    /// - The transaction doesn't exist in the canonical set
+    /// - The output index is out of bounds
+    /// - The transaction was excluded due to conflicts
+    pub fn txout(&self, op: OutPoint) -> Option<CanonicalTxOut<P>> {
+        let (tx, pos) = self.txs.get(&op.txid)?;
+        let vout: usize = op.vout.try_into().ok()?;
+        let txout = tx.output.get(vout)?;
+        let spent_by = self.spends.get(&op).map(|spent_by_txid| {
+            let (_, spent_by_pos) = &self.txs[spent_by_txid];
+            (spent_by_pos.clone(), *spent_by_txid)
+        });
+        Some(CanonicalTxOut {
+            pos: pos.clone(),
+            outpoint: op,
+            txout: txout.clone(),
+            spent_by,
+            is_on_coinbase: tx.is_coinbase(),
+        })
+    }
+
+    /// Get an iterator over all canonical transactions in order.
+    ///
+    /// Transactions are returned in canonical order, with confirmed transactions ordered by
+    /// block height and position, followed by unconfirmed transactions.
+    ///
+    /// # Example
+    ///
+    /// ```
+    /// # use bdk_chain::{TxGraph, CanonicalTask, local_chain::LocalChain};
+    /// # use bdk_core::BlockId;
+    /// # use bitcoin::hashes::Hash;
+    /// # let tx_graph = TxGraph::<BlockId>::default();
+    /// # let chain = LocalChain::from_blocks([(0, bitcoin::BlockHash::all_zeros())].into_iter().collect()).unwrap();
+    /// # let chain_tip = chain.tip().block_id();
+    /// # 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() {
+    ///     println!("TX {}: {:?}", tx.txid, tx.pos);
+    /// }
+    ///
+    /// // Get the total number of canonical transactions
+    /// println!("Total canonical transactions: {}", view.txs().len());
+    /// ```
+    pub fn txs(&self) -> impl ExactSizeIterator<Item = CanonicalTx<P>> + DoubleEndedIterator + '_ {
+        self.order.iter().map(|&txid| {
+            let (tx, pos) = self.txs[&txid].clone();
+            CanonicalTx { pos, txid, tx }
+        })
+    }
+
+    /// Get a filtered list of outputs from the given outpoints.
+    ///
+    /// This method takes an iterator of `(identifier, outpoint)` pairs and returns an iterator
+    /// of `(identifier, 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
+    /// or keys.
+    ///
+    /// # Example
+    ///
+    /// ```
+    /// # use bdk_chain::{TxGraph, CanonicalTask, local_chain::LocalChain, keychain_txout::KeychainTxOutIndex};
+    /// # use bdk_core::BlockId;
+    /// # use bitcoin::hashes::Hash;
+    /// # let tx_graph = TxGraph::<BlockId>::default();
+    /// # let chain = LocalChain::from_blocks([(0, bitcoin::BlockHash::all_zeros())].into_iter().collect()).unwrap();
+    /// # let chain_tip = chain.tip().block_id();
+    /// # 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
+    /// for (keychain, txout) in view.filter_outpoints(indexer.outpoints().clone()) {
+    ///     println!("{}: {} sats", keychain.0, txout.txout.value);
+    /// }
+    /// ```
+    pub fn filter_outpoints<'v, O: Clone + 'v>(
+        &'v self,
+        outpoints: impl IntoIterator<Item = (O, OutPoint)> + 'v,
+    ) -> impl Iterator<Item = (O, CanonicalTxOut<P>)> + 'v {
+        outpoints
+            .into_iter()
+            .filter_map(|(op_i, op)| Some((op_i, self.txout(op)?)))
+    }
+
+    /// Get a filtered list of unspent outputs (UTXOs) from the given outpoints.
+    ///
+    /// Similar to [`filter_outpoints`](Self::filter_outpoints), but only returns outputs that
+    /// have not been spent. This is useful for finding available UTXOs for spending.
+    ///
+    /// # Example
+    ///
+    /// ```
+    /// # use bdk_chain::{TxGraph, CanonicalTask, local_chain::LocalChain, keychain_txout::KeychainTxOutIndex};
+    /// # use bdk_core::BlockId;
+    /// # use bitcoin::hashes::Hash;
+    /// # let tx_graph = TxGraph::<BlockId>::default();
+    /// # let chain = LocalChain::from_blocks([(0, bitcoin::BlockHash::all_zeros())].into_iter().collect()).unwrap();
+    /// # let chain_tip = chain.tip().block_id();
+    /// # 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
+    /// for (keychain, utxo) in view.filter_unspent_outpoints(indexer.outpoints().clone()) {
+    ///     println!("{} UTXO: {} sats", keychain.0, utxo.txout.value);
+    /// }
+    /// ```
+    pub fn filter_unspent_outpoints<'v, O: Clone + 'v>(
+        &'v self,
+        outpoints: impl IntoIterator<Item = (O, OutPoint)> + 'v,
+    ) -> impl Iterator<Item = (O, CanonicalTxOut<P>)> + '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<SpkTxOutIndex<I>>,
+        spk_index_range: impl RangeBounds<I> + 'v,
+    ) -> impl Iterator<Item = (ScriptBuf, Txid)> + '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<A: Anchor> CanonicalView<A> {
+    /// Calculate the total balance of the given outpoints.
+    ///
+    /// This method computes a detailed balance breakdown for a set of outpoints, categorizing
+    /// outputs as confirmed, pending (trusted/untrusted), or immature based on their chain
+    /// position and the provided trust predicate.
+    ///
+    /// # Arguments
+    ///
+    /// * `outpoints` - Iterator of `(identifier, outpoint)` pairs to calculate balance for
+    /// * `trust_predicate` - Function that returns `true` for trusted scripts. Trusted outputs
+    ///   count toward `trusted_pending` balance, while untrusted ones count toward
+    ///   `untrusted_pending`
+    /// * `min_confirmations` - Minimum confirmations required for an output to be considered
+    ///   confirmed. Outputs with fewer confirmations are treated as pending.
+    ///
+    /// # Minimum Confirmations
+    ///
+    /// The `min_confirmations` parameter controls when outputs are considered confirmed. A
+    /// `min_confirmations` value of `0` is equivalent to `1` (require at least 1 confirmation).
+    ///
+    /// Outputs with fewer than `min_confirmations` are categorized as pending (trusted or
+    /// untrusted based on the trust predicate).
+    ///
+    /// # Example
+    ///
+    /// ```
+    /// # use bdk_chain::{CanonicalParams, TxGraph, local_chain::LocalChain, keychain_txout::KeychainTxOutIndex};
+    /// # use bdk_core::BlockId;
+    /// # use bitcoin::hashes::Hash;
+    /// # let tx_graph = TxGraph::<BlockId>::default();
+    /// # let chain = LocalChain::from_blocks([(0, bitcoin::BlockHash::all_zeros())].into_iter().collect()).unwrap();
+    /// # let chain_tip = chain.tip().block_id();
+    /// # let view = chain.canonical_view(&tx_graph, chain_tip, CanonicalParams::default());
+    /// # let indexer = KeychainTxOutIndex::<&str>::default();
+    /// // Calculate balance with 6 confirmations, trusting all outputs
+    /// let balance = view.balance(
+    ///     indexer.outpoints().into_iter().map(|(k, op)| (k.clone(), *op)),
+    ///     |_keychain, _script| true,  // Trust all outputs
+    ///     6,  // Require 6 confirmations
+    /// );
+    /// ```
+    pub fn balance<'v, O: Clone + 'v>(
+        &'v self,
+        outpoints: impl IntoIterator<Item = (O, OutPoint)> + 'v,
+        mut trust_predicate: impl FnMut(&O, &CanonicalTxOut<ChainPosition<A>>) -> bool,
+        min_confirmations: u32,
+    ) -> Balance {
+        let mut immature = Amount::ZERO;
+        let mut trusted_pending = Amount::ZERO;
+        let mut untrusted_pending = Amount::ZERO;
+        let mut confirmed = Amount::ZERO;
+
+        for (spk_i, txout) in self.filter_unspent_outpoints(outpoints) {
+            match &txout.pos {
+                ChainPosition::Confirmed { anchor, .. } => {
+                    let confirmation_height = anchor.confirmation_height_upper_bound();
+                    let confirmations = self
+                        .tip
+                        .height
+                        .saturating_sub(confirmation_height)
+                        .saturating_add(1);
+                    let min_confirmations = min_confirmations.max(1); // 0 and 1 behave identically
+
+                    if confirmations < min_confirmations {
+                        // Not enough confirmations, treat as trusted/untrusted pending
+                        if trust_predicate(&spk_i, &txout) {
+                            trusted_pending += txout.txout.value;
+                        } else {
+                            untrusted_pending += txout.txout.value;
+                        }
+                    } else if txout.is_confirmed_and_spendable(self.tip.height) {
+                        confirmed += txout.txout.value;
+                    } else if !txout.is_mature(self.tip.height) {
+                        immature += txout.txout.value;
+                    }
+                }
+                ChainPosition::Unconfirmed { .. } => {
+                    if trust_predicate(&spk_i, &txout) {
+                        trusted_pending += txout.txout.value;
+                    } else {
+                        untrusted_pending += txout.txout.value;
+                    }
+                }
+            }
+        }
+
+        Balance {
+            immature,
+            trusted_pending,
+            untrusted_pending,
+            confirmed,
+        }
+    }
+}
+
+impl<A: Anchor> CanonicalTxs<A> {
+    /// 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<A>) -> CanonicalViewTask<'g, A> {
+        CanonicalViewTask::new(tx_graph, self.tip, self.order, self.txs, self.spends)
+    }
+}
index 8f4ce48f3d6e4ded32bb6d5529b4d043e402ef00..702a909dac0f78b61316ec36e2233cc8a2f585b8 100644 (file)
@@ -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<A>,
-    tip: BlockId,
-
-    /// Transactions in canonical order with their reasons.
-    canonical_order: Vec<Txid>,
-    canonical_txs: HashMap<Txid, (Arc<Transaction>, CanonicalReason<A>)>,
-    spends: HashMap<bitcoin::OutPoint, Txid>,
-
-    /// Transactions that need anchor verification (transitively anchored).
-    unprocessed_anchor_checks: VecDeque<(Txid, &'g BTreeSet<A>)>,
-
-    /// Resolved direct anchors for transitively anchored transactions.
-    direct_anchors: HashMap<Txid, A>,
-
-    current_stage: ViewStage,
-}
-
-impl<'g, A: Anchor> ChainQuery for CanonicalViewTask<'g, A> {
-    type Output = CanonicalView<A>;
-
-    fn tip(&self) -> BlockId {
-        self.tip
-    }
-
-    fn next_query(&mut self) -> Option<ChainRequest> {
-        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<A: Anchor> CanonicalTxs<A> {
-    /// 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<A>) -> 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<A: Clone> CanonicalReason<A> {
     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<A: Clone> CanonicalReason<A> {
 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.rs b/crates/chain/src/canonical_view.rs
deleted file mode 100644 (file)
index b1d9fda..0000000
+++ /dev/null
@@ -1,391 +0,0 @@
-//! Canonical view of transactions and unspent outputs.
-//!
-//! This module provides [`CanonicalView`], a utility for obtaining a canonical (ordered and
-//! conflict-resolved) view of transactions from a [`TxGraph`].
-//!
-//! ## Example
-//!
-//! ```
-//! # use bdk_chain::{TxGraph, CanonicalParams, CanonicalTask, local_chain::LocalChain};
-//! # use bdk_core::BlockId;
-//! # use bitcoin::hashes::Hash;
-//! # let tx_graph = TxGraph::<BlockId>::default();
-//! # let chain = LocalChain::from_blocks([(0, bitcoin::BlockHash::all_zeros())].into_iter().collect()).unwrap();
-//! let chain_tip = chain.tip().block_id();
-//! let params = CanonicalParams::default();
-//! let task = CanonicalTask::new(&tx_graph, chain_tip, params);
-//! let view = chain.canonicalize(task);
-//!
-//! // Iterate over canonical transactions
-//! for tx in view.txs() {
-//!     println!("Transaction {}: {:?}", tx.txid, tx.pos);
-//! }
-//! ```
-
-use crate::collections::HashMap;
-use alloc::sync::Arc;
-use core::{fmt, ops::RangeBounds};
-
-use alloc::vec::Vec;
-
-use bdk_core::BlockId;
-use bitcoin::{Amount, OutPoint, ScriptBuf, Transaction, Txid};
-
-use crate::{spk_txout::SpkTxOutIndex, Anchor, Balance, ChainPosition, FullTxOut};
-
-/// 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 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<P> {
-    /// The position of this transaction.
-    ///
-    /// 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<Transaction>,
-}
-
-impl<P: Ord> Ord for CanonicalTx<P> {
-    fn cmp(&self, other: &Self) -> core::cmp::Ordering {
-        self.pos
-            .cmp(&other.pos)
-            // Txid tiebreaker for same position
-            .then_with(|| self.txid.cmp(&other.txid))
-    }
-}
-
-impl<P: Ord> PartialOrd for CanonicalTx<P> {
-    fn partial_cmp(&self, other: &Self) -> Option<core::cmp::Ordering> {
-        Some(self.cmp(other))
-    }
-}
-
-/// Canonical set of transactions from a [`TxGraph`].
-///
-/// `Canonical` provides a conflict-resolved list 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<A>`] for resolved views (aka [`CanonicalView`])
-/// - [`CanonicalReason<A>`](crate::canonical_task::CanonicalReason) for unresolved results (aka
-///   [`CanonicalTxs`])
-///
-/// The view maintains:
-/// - A list of canonical transactions
-/// - A mapping of outpoints to the transactions that spend them
-/// - The chain tip used for canonicalization
-///
-/// [`TxGraph`]: crate::TxGraph
-#[derive(Debug)]
-pub struct Canonical<A, P> {
-    /// List of canonical transaction IDs.
-    pub(crate) order: Vec<Txid>,
-    /// Map of transaction IDs to their transaction data and position.
-    pub(crate) txs: HashMap<Txid, (Arc<Transaction>, P)>,
-    /// Map of outpoints to the transaction ID that spends them.
-    pub(crate) spends: HashMap<OutPoint, Txid>,
-    /// The chain tip at the time this view was created.
-    pub(crate) tip: BlockId,
-    /// Marker for the anchor type.
-    pub(crate) _anchor: core::marker::PhantomData<A>,
-}
-
-/// Type alias for canonical transactions with resolved [`ChainPosition`]s.
-pub type CanonicalView<A> = Canonical<A, ChainPosition<A>>;
-
-/// Type alias for canonical transactions with unresolved
-/// [`CanonicalReason`](crate::canonical_task::CanonicalReason)s.
-pub type CanonicalTxs<A> = Canonical<A, crate::canonical_task::CanonicalReason<A>>;
-
-impl<A, P: Clone> Canonical<A, P> {
-    /// Creates a [`Canonical`] from its constituent parts.
-    ///
-    /// This internal constructor is used by [`CanonicalTask`] to build the canonical set
-    /// after completing the canonicalization process. It takes the processed transaction
-    /// data including the canonical ordering, transaction map with positions, and
-    /// spend information.
-    pub(crate) fn new(
-        tip: BlockId,
-        order: Vec<Txid>,
-        txs: HashMap<Txid, (Arc<Transaction>, P)>,
-        spends: HashMap<OutPoint, Txid>,
-    ) -> Self {
-        Self {
-            tip,
-            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(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<CanonicalTx<P>> {
-        self.txs
-            .get(&txid)
-            .cloned()
-            .map(|(tx, pos)| CanonicalTx { pos, txid, tx })
-    }
-
-    /// Get a single canonical transaction output.
-    ///
-    /// Returns detailed information about a transaction output, including whether it has been
-    /// spent and by which transaction.
-    ///
-    /// Returns `None` if:
-    /// - The transaction doesn't exist in the canonical set
-    /// - The output index is out of bounds
-    /// - The transaction was excluded due to conflicts
-    pub fn txout(&self, op: OutPoint) -> Option<FullTxOut<P>> {
-        let (tx, pos) = self.txs.get(&op.txid)?;
-        let vout: usize = op.vout.try_into().ok()?;
-        let txout = tx.output.get(vout)?;
-        let spent_by = self.spends.get(&op).map(|spent_by_txid| {
-            let (_, spent_by_pos) = &self.txs[spent_by_txid];
-            (spent_by_pos.clone(), *spent_by_txid)
-        });
-        Some(FullTxOut {
-            pos: pos.clone(),
-            outpoint: op,
-            txout: txout.clone(),
-            spent_by,
-            is_on_coinbase: tx.is_coinbase(),
-        })
-    }
-
-    /// Get an iterator over all canonical transactions in order.
-    ///
-    /// Transactions are returned in canonical order, with confirmed transactions ordered by
-    /// block height and position, followed by unconfirmed transactions.
-    ///
-    /// # Example
-    ///
-    /// ```
-    /// # use bdk_chain::{TxGraph, CanonicalTask, local_chain::LocalChain};
-    /// # use bdk_core::BlockId;
-    /// # use bitcoin::hashes::Hash;
-    /// # let tx_graph = TxGraph::<BlockId>::default();
-    /// # let chain = LocalChain::from_blocks([(0, bitcoin::BlockHash::all_zeros())].into_iter().collect()).unwrap();
-    /// # let chain_tip = chain.tip().block_id();
-    /// # 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() {
-    ///     println!("TX {}: {:?}", tx.txid, tx.pos);
-    /// }
-    ///
-    /// // Get the total number of canonical transactions
-    /// println!("Total canonical transactions: {}", view.txs().len());
-    /// ```
-    pub fn txs(&self) -> impl ExactSizeIterator<Item = CanonicalTx<P>> + DoubleEndedIterator + '_ {
-        self.order.iter().map(|&txid| {
-            let (tx, pos) = self.txs[&txid].clone();
-            CanonicalTx { pos, txid, tx }
-        })
-    }
-
-    /// Get a filtered list of outputs from the given outpoints.
-    ///
-    /// This method takes an iterator of `(identifier, outpoint)` pairs and returns an iterator
-    /// of `(identifier, full_txout)` pairs for outpoints that exist in the canonical set.
-    /// Non-existent outpoints are silently filtered out.
-    ///
-    /// The identifier type `O` is useful for tracking which outpoints correspond to which addresses
-    /// or keys.
-    ///
-    /// # Example
-    ///
-    /// ```
-    /// # use bdk_chain::{TxGraph, CanonicalTask, local_chain::LocalChain, keychain_txout::KeychainTxOutIndex};
-    /// # use bdk_core::BlockId;
-    /// # use bitcoin::hashes::Hash;
-    /// # let tx_graph = TxGraph::<BlockId>::default();
-    /// # let chain = LocalChain::from_blocks([(0, bitcoin::BlockHash::all_zeros())].into_iter().collect()).unwrap();
-    /// # let chain_tip = chain.tip().block_id();
-    /// # 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
-    /// for (keychain, txout) in view.filter_outpoints(indexer.outpoints().clone()) {
-    ///     println!("{}: {} sats", keychain.0, txout.txout.value);
-    /// }
-    /// ```
-    pub fn filter_outpoints<'v, O: Clone + 'v>(
-        &'v self,
-        outpoints: impl IntoIterator<Item = (O, OutPoint)> + 'v,
-    ) -> impl Iterator<Item = (O, FullTxOut<P>)> + 'v {
-        outpoints
-            .into_iter()
-            .filter_map(|(op_i, op)| Some((op_i, self.txout(op)?)))
-    }
-
-    /// Get a filtered list of unspent outputs (UTXOs) from the given outpoints.
-    ///
-    /// Similar to [`filter_outpoints`](Self::filter_outpoints), but only returns outputs that
-    /// have not been spent. This is useful for finding available UTXOs for spending.
-    ///
-    /// # Example
-    ///
-    /// ```
-    /// # use bdk_chain::{TxGraph, CanonicalTask, local_chain::LocalChain, keychain_txout::KeychainTxOutIndex};
-    /// # use bdk_core::BlockId;
-    /// # use bitcoin::hashes::Hash;
-    /// # let tx_graph = TxGraph::<BlockId>::default();
-    /// # let chain = LocalChain::from_blocks([(0, bitcoin::BlockHash::all_zeros())].into_iter().collect()).unwrap();
-    /// # let chain_tip = chain.tip().block_id();
-    /// # 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
-    /// for (keychain, utxo) in view.filter_unspent_outpoints(indexer.outpoints().clone()) {
-    ///     println!("{} UTXO: {} sats", keychain.0, utxo.txout.value);
-    /// }
-    /// ```
-    pub fn filter_unspent_outpoints<'v, O: Clone + 'v>(
-        &'v self,
-        outpoints: impl IntoIterator<Item = (O, OutPoint)> + 'v,
-    ) -> impl Iterator<Item = (O, FullTxOut<P>)> + '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<SpkTxOutIndex<I>>,
-        spk_index_range: impl RangeBounds<I> + 'v,
-    ) -> impl Iterator<Item = (ScriptBuf, Txid)> + '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<A: Anchor> CanonicalView<A> {
-    /// Calculate the total balance of the given outpoints.
-    ///
-    /// This method computes a detailed balance breakdown for a set of outpoints, categorizing
-    /// outputs as confirmed, pending (trusted/untrusted), or immature based on their chain
-    /// position and the provided trust predicate.
-    ///
-    /// # Arguments
-    ///
-    /// * `outpoints` - Iterator of `(identifier, outpoint)` pairs to calculate balance for
-    /// * `trust_predicate` - Function that returns `true` for trusted scripts. Trusted outputs
-    ///   count toward `trusted_pending` balance, while untrusted ones count toward
-    ///   `untrusted_pending`
-    /// * `min_confirmations` - Minimum confirmations required for an output to be considered
-    ///   confirmed. Outputs with fewer confirmations are treated as pending.
-    ///
-    /// # Minimum Confirmations
-    ///
-    /// The `min_confirmations` parameter controls when outputs are considered confirmed. A
-    /// `min_confirmations` value of `0` is equivalent to `1` (require at least 1 confirmation).
-    ///
-    /// Outputs with fewer than `min_confirmations` are categorized as pending (trusted or
-    /// untrusted based on the trust predicate).
-    ///
-    /// # Example
-    ///
-    /// ```
-    /// # use bdk_chain::{CanonicalParams, TxGraph, local_chain::LocalChain, keychain_txout::KeychainTxOutIndex};
-    /// # use bdk_core::BlockId;
-    /// # use bitcoin::hashes::Hash;
-    /// # let tx_graph = TxGraph::<BlockId>::default();
-    /// # let chain = LocalChain::from_blocks([(0, bitcoin::BlockHash::all_zeros())].into_iter().collect()).unwrap();
-    /// # let chain_tip = chain.tip().block_id();
-    /// # let view = chain.canonical_view(&tx_graph, chain_tip, CanonicalParams::default());
-    /// # let indexer = KeychainTxOutIndex::<&str>::default();
-    /// // Calculate balance with 6 confirmations, trusting all outputs
-    /// let balance = view.balance(
-    ///     indexer.outpoints().into_iter().map(|(k, op)| (k.clone(), *op)),
-    ///     |_keychain, _script| true,  // Trust all outputs
-    ///     6,  // Require 6 confirmations
-    /// );
-    /// ```
-    pub fn balance<'v, O: Clone + 'v>(
-        &'v self,
-        outpoints: impl IntoIterator<Item = (O, OutPoint)> + 'v,
-        mut trust_predicate: impl FnMut(&O, &FullTxOut<ChainPosition<A>>) -> bool,
-        min_confirmations: u32,
-    ) -> Balance {
-        let mut immature = Amount::ZERO;
-        let mut trusted_pending = Amount::ZERO;
-        let mut untrusted_pending = Amount::ZERO;
-        let mut confirmed = Amount::ZERO;
-
-        for (spk_i, txout) in self.filter_unspent_outpoints(outpoints) {
-            match &txout.pos {
-                ChainPosition::Confirmed { anchor, .. } => {
-                    let confirmation_height = anchor.confirmation_height_upper_bound();
-                    let confirmations = self
-                        .tip
-                        .height
-                        .saturating_sub(confirmation_height)
-                        .saturating_add(1);
-                    let min_confirmations = min_confirmations.max(1); // 0 and 1 behave identically
-
-                    if confirmations < min_confirmations {
-                        // Not enough confirmations, treat as trusted/untrusted pending
-                        if trust_predicate(&spk_i, &txout) {
-                            trusted_pending += txout.txout.value;
-                        } else {
-                            untrusted_pending += txout.txout.value;
-                        }
-                    } else if txout.is_confirmed_and_spendable(self.tip.height) {
-                        confirmed += txout.txout.value;
-                    } else if !txout.is_mature(self.tip.height) {
-                        immature += txout.txout.value;
-                    }
-                }
-                ChainPosition::Unconfirmed { .. } => {
-                    if trust_predicate(&spk_i, &txout) {
-                        trusted_pending += txout.txout.value;
-                    } else {
-                        untrusted_pending += txout.txout.value;
-                    }
-                }
-            }
-        }
-
-        Balance {
-            immature,
-            trusted_pending,
-            untrusted_pending,
-            confirmed,
-        }
-    }
-}
diff --git a/crates/chain/src/canonical_view_task.rs b/crates/chain/src/canonical_view_task.rs
new file mode 100644 (file)
index 0000000..a6d0d87
--- /dev/null
@@ -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<A>,
+    tip: BlockId,
+
+    /// Transactions in canonical order with their reasons.
+    canonical_order: Vec<Txid>,
+    canonical_txs: HashMap<Txid, (Arc<Transaction>, CanonicalReason<A>)>,
+    spends: HashMap<OutPoint, Txid>,
+
+    /// Transactions that need anchor verification (transitively anchored).
+    unprocessed_anchor_checks: VecDeque<(Txid, &'g BTreeSet<A>)>,
+
+    /// Resolved direct anchors for transitively anchored transactions.
+    direct_anchors: HashMap<Txid, A>,
+
+    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<A>,
+        tip: BlockId,
+        order: Vec<Txid>,
+        txs: HashMap<Txid, (Arc<Transaction>, CanonicalReason<A>)>,
+        spends: HashMap<OutPoint, Txid>,
+    ) -> 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<A>;
+
+    fn tip(&self) -> BlockId {
+        self.tip
+    }
+
+    fn next_query(&mut self) -> Option<ChainRequest> {
+        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)
+    }
+}
index 9bbf3a85c7c655b9593a1ad795dff4aff69889b8..7ec88fba8ff082f18eb68892169f4c51cf7c86de 100644 (file)
@@ -1,4 +1,4 @@
-use bitcoin::{constants::COINBASE_MATURITY, OutPoint, TxOut, Txid};
+use bitcoin::Txid;
 
 use crate::Anchor;
 
@@ -161,104 +161,6 @@ impl<A: Ord> PartialOrd for ChainPosition<A> {
     }
 }
 
-/// 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<P> {
-    /// 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<P: Ord> Ord for FullTxOut<P> {
-    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<P: Ord> PartialOrd for FullTxOut<P> {
-    fn partial_cmp(&self, other: &Self) -> Option<core::cmp::Ordering> {
-        Some(self.cmp(other))
-    }
-}
-
-impl<A: Anchor> FullTxOut<ChainPosition<A>> {
-    /// 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 {
index 2e0a83c277bd93a9d705fab8bb4d676de8fc0ed0..8c42473ba4eeea05ae28499d5b40d8decad0a006 100644 (file)
@@ -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;