From 8ad012866ff0bb8a8f8523044cbc9e8f1f0c11c3 Mon Sep 17 00:00:00 2001 From: Leonardo Lima Date: Thu, 18 Sep 2025 02:56:13 -0300 Subject: [PATCH 01/15] refactor(chain)!: introduce `CanonicalizationTask` replace `CanonicalIter` with sans-io `CanonicalizationTask` Introduces new `CanonicalizationTask`, which implements the canonicalization process through a request/response pattern, that allow us to remove the `ChainOracle` dependency in the future. The `CanonicalizationTask` handles direct/transitive anchor determination, also tracks already confirmed anchors to avoid redundant queries. After all the `CanonicalizationRequest`'s have been resolved, the `CanonicalizationTask` can be finalized returning the final `CanonicalView`. It batches all the anchors, which require a chain query, for a given transaction into a single `CanonicalizationRequest`, instead of having multiple requests for each one. - Add new `CanonicalizationTask`, relying on `Canonicalization{Request|Response}` for chain queries. It - Replaces the old `CanonicalIter` usage, with new `CanonicalizationTask`. BREAKING CHANGE: It replaces direct `ChainOracle` querying in canonicalization process, with the new request/response pattern by `CanonicalizationTask`. --- crates/chain/src/canonical_task.rs | 468 +++++++++++++++++++++++++++++ crates/chain/src/canonical_view.rs | 142 +++------ crates/chain/src/lib.rs | 2 + 3 files changed, 520 insertions(+), 92 deletions(-) create mode 100644 crates/chain/src/canonical_task.rs diff --git a/crates/chain/src/canonical_task.rs b/crates/chain/src/canonical_task.rs new file mode 100644 index 000000000..a5e1ddbd7 --- /dev/null +++ b/crates/chain/src/canonical_task.rs @@ -0,0 +1,468 @@ +use crate::canonical_iter::{CanonicalReason, CanonicalizationParams, ObservedIn}; +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; +use bitcoin::{Transaction, Txid}; + +/// A request to check which anchors are confirmed in the chain. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct CanonicalizationRequest { + /// The anchors to check. + pub anchors: Vec, + /// The chain tip to check against. + pub chain_tip: BlockId, +} + +/// Response containing the best confirmed anchor, if any. +pub type CanonicalizationResponse = Option; + +type CanonicalMap = HashMap, CanonicalReason)>; +type NotCanonicalSet = HashSet; + +/// Manages the canonicalization process without direct I/O operations. +pub struct CanonicalizationTask<'g, A> { + tx_graph: &'g TxGraph, + chain_tip: BlockId, + + unprocessed_assumed_txs: Box)> + 'g>, + unprocessed_anchored_txs: + Box, &'g BTreeSet)> + 'g>, + unprocessed_seen_txs: Box, u64)> + 'g>, + unprocessed_leftover_txs: VecDeque<(Txid, Arc, u32)>, + + canonical: CanonicalMap, + not_canonical: NotCanonicalSet, + + pending_anchor_checks: VecDeque<(Txid, Arc, Vec)>, + + // Store canonical transactions in order + canonical_order: Vec, + + // Track which transactions have confirmed anchors + confirmed_anchors: HashMap, +} + +impl<'g, A: Anchor> CanonicalizationTask<'g, A> { + /// Creates a new canonicalization task. + /// + /// Returns the task and an optional initial request. + pub fn new( + tx_graph: &'g TxGraph, + chain_tip: BlockId, + params: CanonicalizationParams, + ) -> (Self, Option>) { + let anchors = tx_graph.all_anchors(); + let unprocessed_assumed_txs = Box::new( + params + .assume_canonical + .into_iter() + .rev() + .filter_map(|txid| Some((txid, tx_graph.get_tx(txid)?))), + ); + let unprocessed_anchored_txs = Box::new( + tx_graph + .txids_by_descending_anchor_height() + .filter_map(|(_, txid)| Some((txid, tx_graph.get_tx(txid)?, anchors.get(&txid)?))), + ); + let unprocessed_seen_txs = Box::new( + tx_graph + .txids_by_descending_last_seen() + .filter_map(|(last_seen, txid)| Some((txid, tx_graph.get_tx(txid)?, last_seen))), + ); + + let mut task = Self { + tx_graph, + chain_tip, + + unprocessed_assumed_txs, + unprocessed_anchored_txs, + unprocessed_seen_txs, + unprocessed_leftover_txs: VecDeque::new(), + + canonical: HashMap::new(), + not_canonical: HashSet::new(), + + pending_anchor_checks: VecDeque::new(), + + canonical_order: Vec::new(), + confirmed_anchors: HashMap::new(), + }; + + // Process assumed transactions first (they don't need queries) + task.process_assumed_txs(); + + // Process anchored transactions and get the first request if needed + let initial_request = task.process_anchored_txs(); + + (task, initial_request) + } + + /// Returns the next query needed, if any. + pub fn next_query(&mut self) -> Option> { + // Check if we have pending anchor checks + if let Some((_, _, anchors)) = self.pending_anchor_checks.front() { + return Some(CanonicalizationRequest { + anchors: anchors.clone(), + chain_tip: self.chain_tip, + }); + } + + // Process more anchored transactions if available + self.process_anchored_txs() + } + + /// Resolves a query with the given response. + pub fn resolve_query(&mut self, response: CanonicalizationResponse) { + if let Some((txid, tx, anchors)) = self.pending_anchor_checks.pop_front() { + match response { + Some(best_anchor) => { + self.confirmed_anchors.insert(txid, best_anchor.clone()); + if !self.is_canonicalized(txid) { + self.mark_canonical(txid, tx, CanonicalReason::from_anchor(best_anchor)); + } + } + None => { + self.unprocessed_leftover_txs.push_back(( + txid, + tx, + anchors + .iter() + .last() + .expect( + "tx taken from `unprocessed_txs_with_anchors` so it must at least have an anchor", + ) + .confirmation_height_upper_bound(), + )) + } + } + } + } + + /// Returns true if the canonicalization process is complete. + pub fn is_finished(&self) -> bool { + self.pending_anchor_checks.is_empty() && self.unprocessed_anchored_txs.size_hint().0 == 0 + } + + /// Completes the canonicalization and returns a CanonicalView. + pub fn finish(mut self) -> CanonicalView { + // Process remaining transactions (seen and leftover) + self.process_seen_txs(); + self.process_leftover_txs(); + + // 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.confirmed_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.confirmed_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) + } + + fn is_canonicalized(&self, txid: Txid) -> bool { + self.canonical.contains_key(&txid) || self.not_canonical.contains(&txid) + } + + fn process_assumed_txs(&mut self) { + while let Some((txid, tx)) = self.unprocessed_assumed_txs.next() { + if !self.is_canonicalized(txid) { + self.mark_canonical(txid, tx, CanonicalReason::assumed()); + } + } + } + + fn process_anchored_txs(&mut self) -> Option> { + while let Some((txid, tx, anchors)) = self.unprocessed_anchored_txs.next() { + if !self.is_canonicalized(txid) { + self.pending_anchor_checks + .push_back((txid, tx, anchors.iter().cloned().collect())); + return self.next_query(); + } + } + None + } + + fn process_seen_txs(&mut self) { + while 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)); + } + } + } + + fn process_leftover_txs(&mut self) { + while 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)); + } + } + } + + fn mark_canonical(&mut self, txid: Txid, tx: Arc, reason: CanonicalReason) { + let starting_txid = txid; + let mut is_starting_tx = true; + + // We keep track of changes made so far so that we can undo it later in case we detect that + // `tx` double spends itself. + let mut detected_self_double_spend = false; + let mut undo_not_canonical = Vec::::new(); + let mut staged_canonical = Vec::<(Txid, Arc, CanonicalReason)>::new(); + + // Process ancestors + TxAncestors::new_include_root( + self.tx_graph, + tx, + |_: usize, tx: Arc| -> Option { + let this_txid = tx.compute_txid(); + let this_reason = if is_starting_tx { + is_starting_tx = false; + reason.clone() + } else { + // This is an ancestor being marked transitively + // Check if it has its own anchor that needs to be verified later + // We'll check anchors after marking it canonical + reason.to_transitive(starting_txid) + }; + + use crate::collections::hash_map::Entry; + let canonical_entry = match self.canonical.entry(this_txid) { + // Already visited tx before, exit early. + Entry::Occupied(_) => return None, + Entry::Vacant(entry) => entry, + }; + + // 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); + } + } else { + // Add to canonical order + for (txid, tx, reason) in &staged_canonical { + self.canonical_order.push(*txid); + + // If this was marked transitively, check if it has anchors to verify + let is_transitive = matches!( + reason, + CanonicalReason::Anchor { + descendant: Some(_), + .. + } | CanonicalReason::Assumed { + descendant: Some(_), + .. + } + ); + + if is_transitive { + if let Some(anchors) = self.tx_graph.all_anchors().get(txid) { + // only check anchors we haven't already confirmed + if !self.confirmed_anchors.contains_key(txid) { + self.pending_anchor_checks.push_back(( + *txid, + tx.clone(), + anchors.iter().cloned().collect(), + )); + } + } + } + } + } + } +} + +#[cfg(test)] +mod tests { + use core::convert::Infallible; + + use super::*; + use crate::{local_chain::LocalChain, ChainOracle}; + use bitcoin::{hashes::Hash, BlockHash, TxIn, TxOut}; + + fn handle_canonicalization_request( + chain: LocalChain, + request: &CanonicalizationRequest, + ) -> Result, Infallible> { + // Check each anchor and return the first confirmed one + for anchor in &request.anchors { + if chain.is_block_in_chain(anchor.anchor_block(), request.chain_tip)? == Some(true) { + return Ok(Some(anchor.clone())); + } + } + Ok(None) + } + + #[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 + let params = CanonicalizationParams::default(); + let (mut task, initial_request) = CanonicalizationTask::new(&tx_graph, chain_tip, params); + + // Process requests + if let Some(request) = initial_request { + let response = handle_canonicalization_request(chain.clone(), &request).unwrap(); + task.resolve_query(response); + } + + while let Some(request) = task.next_query() { + let response = handle_canonicalization_request(chain.clone(), &request).unwrap(); + task.resolve_query(response); + } + + // Get canonical view + let canonical_view = task.finish(); + + // Should have one canonical transaction + assert_eq!(canonical_view.txs().len(), 1); + let canon_tx = canonical_view.txs().next().unwrap(); + assert_eq!(canon_tx.txid, txid); + assert_eq!(canon_tx.tx.compute_txid(), txid); + + // Should be confirmed (anchored) + assert!(matches!(canon_tx.pos, ChainPosition::Confirmed { .. })); + } +} diff --git a/crates/chain/src/canonical_view.rs b/crates/chain/src/canonical_view.rs index 0191f4507..9c4dd7a85 100644 --- a/crates/chain/src/canonical_view.rs +++ b/crates/chain/src/canonical_view.rs @@ -31,8 +31,8 @@ use bdk_core::BlockId; use bitcoin::{Amount, OutPoint, ScriptBuf, Transaction, Txid}; use crate::{ - spk_txout::SpkTxOutIndex, tx_graph::TxNode, Anchor, Balance, CanonicalIter, CanonicalReason, - CanonicalizationParams, ChainOracle, ChainPosition, FullTxOut, ObservedIn, TxGraph, + spk_txout::SpkTxOutIndex, Anchor, Balance, CanonicalizationParams, CanonicalizationTask, + ChainOracle, ChainPosition, FullTxOut, TxGraph, }; /// A single canonical transaction with its chain position. @@ -91,14 +91,30 @@ pub struct CanonicalView { } impl CanonicalView { - /// Create a new canonical view from a transaction graph. + /// Creates a CanonicalView from its constituent parts. + /// This is used by CanonicalizationTask to build the view. + pub(crate) fn from_parts( + tip: BlockId, + order: Vec, + txs: HashMap, ChainPosition)>, + spends: HashMap, + ) -> Self { + Self { + tip, + order, + txs, + spends, + } + } + + /// Create a new [`CanonicalView`] from a transaction graph. /// - /// This constructor analyzes the given [`TxGraph`] and creates a canonical view of all + /// This constructor analyzes the given [`TxGraph`] and creates a [`CanonicalView`] of all /// transactions, resolving conflicts and ordering them according to their chain position. /// - /// # Returns + /// # Errors /// - /// Returns `Ok(CanonicalView)` on success, or an error if the chain oracle fails. + /// An error will occur if the [`ChainOracle`] fails. pub fn new<'g, C>( tx_graph: &'g TxGraph, chain: &'g C, @@ -108,98 +124,40 @@ impl CanonicalView { where C: ChainOracle, { - fn find_direct_anchor( - tx_node: &TxNode<'_, Arc, A>, - chain: &C, - chain_tip: BlockId, - ) -> Result, C::Error> { - tx_node - .anchors - .iter() - .find_map(|a| -> Option> { - match chain.is_block_in_chain(a.anchor_block(), chain_tip) { - Ok(Some(true)) => Some(Ok(a.clone())), - Ok(Some(false)) | Ok(None) => None, - Err(err) => Some(Err(err)), - } - }) - .transpose() - } - - let mut view = Self { - tip: chain_tip, - order: vec![], - txs: HashMap::new(), - spends: HashMap::new(), - }; + let (mut task, init_request) = CanonicalizationTask::new(tx_graph, chain_tip, params); - for r in CanonicalIter::new(tx_graph, chain, chain_tip, params) { - let (txid, tx, why) = r?; - - let tx_node = match tx_graph.get_tx_node(txid) { - Some(tx_node) => tx_node, - None => { - // TODO: Have the `CanonicalIter` return `TxNode`s. - debug_assert!(false, "tx node must exist!"); - continue; + // If an initial request is available, start by first processing it. + if let Some(request) = init_request { + // It verifies each `Anchor` provided in the request, resolving the query for the first + // confirmed one. + let mut best_anchor = None; + for anchor in &request.anchors { + if chain.is_block_in_chain(anchor.anchor_block(), request.chain_tip)? == Some(true) + { + best_anchor = Some(anchor.clone()); + break; } - }; - - view.order.push(txid); - - if !tx.is_coinbase() { - view.spends - .extend(tx.input.iter().map(|txin| (txin.previous_output, txid))); } + task.resolve_query(best_anchor); + } - let pos = match why { - CanonicalReason::Assumed { descendant } => match descendant { - Some(_) => match find_direct_anchor(&tx_node, chain, chain_tip)? { - Some(anchor) => ChainPosition::Confirmed { - anchor, - transitively: None, - }, - None => ChainPosition::Unconfirmed { - first_seen: tx_node.first_seen, - last_seen: tx_node.last_seen, - }, - }, - None => ChainPosition::Unconfirmed { - first_seen: tx_node.first_seen, - last_seen: tx_node.last_seen, - }, - }, - CanonicalReason::Anchor { anchor, descendant } => match descendant { - Some(_) => match find_direct_anchor(&tx_node, chain, chain_tip)? { - Some(anchor) => ChainPosition::Confirmed { - anchor, - transitively: None, - }, - None => ChainPosition::Confirmed { - anchor, - transitively: descendant, - }, - }, - None => ChainPosition::Confirmed { - anchor, - transitively: None, - }, - }, - CanonicalReason::ObservedIn { observed_in, .. } => match observed_in { - ObservedIn::Mempool(last_seen) => ChainPosition::Unconfirmed { - first_seen: tx_node.first_seen, - last_seen: Some(last_seen), - }, - ObservedIn::Block(_) => ChainPosition::Unconfirmed { - first_seen: tx_node.first_seen, - last_seen: None, - }, - }, - }; - view.txs.insert(txid, (tx_node.tx, pos)); + // Process all the following requests + while let Some(request) = task.next_query() { + // It verifies each `Anchor` provided in the request, resolving the query for the first + // confirmed one. + let mut best_anchor = None; + for anchor in &request.anchors { + if chain.is_block_in_chain(anchor.anchor_block(), request.chain_tip)? == Some(true) + { + best_anchor = Some(anchor.clone()); + break; + } + } + task.resolve_query(best_anchor); } - Ok(view) + // Return the finished canonical view + Ok(task.finish()) } /// Get a single canonical transaction by its transaction ID. diff --git a/crates/chain/src/lib.rs b/crates/chain/src/lib.rs index be9170b1a..3d9b5a7cf 100644 --- a/crates/chain/src/lib.rs +++ b/crates/chain/src/lib.rs @@ -46,6 +46,8 @@ mod chain_oracle; pub use chain_oracle::*; mod canonical_iter; pub use canonical_iter::*; +mod canonical_task; +pub use canonical_task::*; mod canonical_view; pub use canonical_view::*; From c6f11d9815663d800f6c362d10dbffa03813a1b6 Mon Sep 17 00:00:00 2001 From: Leonardo Lima Date: Mon, 22 Sep 2025 02:03:43 -0300 Subject: [PATCH 02/15] refactor(chain)!: use `CanonicalizationTask` instead of `CanonicalIter` The new API introduces a sans-io behavior, separating the canonicalization logic from `I/O` operations, it should be used as follows: 1. Create a new `CanonicalizationTask` with a `TxGraph`, by calling: `graph.canonicalization_task(params)` 2. Execute the canonicalization process with a chain oracle (e.g `LocalChain`, which implements `ChainOracle` trait), by calling: `chain.canonicalize(task, chain_tip)` - Replace `CanonicalView::new()` constructor with internal `CanonicalView::new()` for use by `CanonicalizationTask` - Remove `TxGraph::try_canonical_view()` and `TxGraph::canonical_view()` methods - Add `TxGraph::canonicalization_task()` method to create canonicalization tasks - Add `LocalChain::canonicalize()` method to process tasks and return `CanonicalView`'s - Update `IndexedTxGraph` to delegate canonicalization to underlying `TxGraph` BREAKING CHANGE: Remove `CanonicalView::new()` and `TxGraph::canonical_view()` methods in favor of task-based approach --- crates/bitcoind_rpc/examples/filter_iter.rs | 3 +- crates/bitcoind_rpc/tests/test_emitter.rs | 21 +++-- crates/chain/benches/canonicalization.rs | 27 +++--- crates/chain/benches/indexer.rs | 7 +- crates/chain/src/canonical_task.rs | 60 ++---------- crates/chain/src/canonical_view.rs | 94 +++++-------------- crates/chain/src/indexed_tx_graph.rs | 44 ++++----- crates/chain/src/local_chain.rs | 80 +++++++++++++++- crates/chain/src/tx_graph.rs | 84 +++++++---------- crates/chain/tests/test_canonical_view.rs | 18 ++-- crates/chain/tests/test_indexed_tx_graph.rs | 40 ++++---- crates/chain/tests/test_tx_graph.rs | 35 +++---- crates/chain/tests/test_tx_graph_conflicts.rs | 40 ++++---- crates/electrum/tests/test_electrum.rs | 25 +++-- crates/esplora/tests/async_ext.rs | 16 ++-- crates/esplora/tests/blocking_ext.rs | 16 ++-- .../example_bitcoind_rpc_polling/src/main.rs | 80 ++++++++-------- examples/example_cli/src/lib.rs | 45 ++++----- examples/example_electrum/src/main.rs | 10 +- examples/example_esplora/src/main.rs | 9 +- 20 files changed, 371 insertions(+), 383 deletions(-) diff --git a/crates/bitcoind_rpc/examples/filter_iter.rs b/crates/bitcoind_rpc/examples/filter_iter.rs index e79bde672..0cafb2599 100644 --- a/crates/bitcoind_rpc/examples/filter_iter.rs +++ b/crates/bitcoind_rpc/examples/filter_iter.rs @@ -69,7 +69,8 @@ fn main() -> anyhow::Result<()> { println!("\ntook: {}s", start.elapsed().as_secs()); println!("Local tip: {}", chain.tip().height()); - let canonical_view = graph.canonical_view(&chain, chain.tip().block_id(), Default::default()); + let task = graph.canonicalization_task(Default::default()); + let canonical_view = chain.canonicalize(task, Some(chain.tip().block_id())); let unspent: Vec<_> = canonical_view .filter_unspent_outpoints(graph.index.outpoints().clone()) diff --git a/crates/bitcoind_rpc/tests/test_emitter.rs b/crates/bitcoind_rpc/tests/test_emitter.rs index 67cbb329f..84899455e 100644 --- a/crates/bitcoind_rpc/tests/test_emitter.rs +++ b/crates/bitcoind_rpc/tests/test_emitter.rs @@ -320,9 +320,12 @@ fn get_balance( ) -> anyhow::Result { let chain_tip = recv_chain.tip().block_id(); let outpoints = recv_graph.index.outpoints().clone(); - let balance = recv_graph - .canonical_view(recv_chain, chain_tip, CanonicalizationParams::default()) - .balance(outpoints, |_, _| true, 1); + let task = recv_graph + .graph() + .canonicalization_task(CanonicalizationParams::default()); + let balance = recv_chain + .canonicalize(task, Some(chain_tip)) + .balance(outpoints, |_, _| true, 0); Ok(balance) } @@ -634,8 +637,9 @@ fn test_expect_tx_evicted() -> anyhow::Result<()> { let _txid_2 = core.send_raw_transaction(&tx1b)?; // Retrieve the expected unconfirmed txids and spks from the graph. - let exp_spk_txids = graph - .canonical_view(&chain, chain_tip, Default::default()) + let task = graph.canonicalization_task(Default::default()); + let exp_spk_txids = chain + .canonicalize(task, Some(chain_tip)) .list_expected_spk_txids(&graph.index, ..) .collect::>(); assert_eq!(exp_spk_txids, vec![(spk, txid_1)]); @@ -650,8 +654,11 @@ fn test_expect_tx_evicted() -> anyhow::Result<()> { // Update graph with evicted tx. let _ = graph.batch_insert_relevant_evicted_at(mempool_event.evicted); - let canonical_txids = graph - .canonical_view(&chain, chain_tip, CanonicalizationParams::default()) + let task = graph + .graph() + .canonicalization_task(CanonicalizationParams::default()); + let canonical_txids = chain + .canonicalize(task, Some(chain_tip)) .txs() .map(|tx| tx.txid) .collect::>(); diff --git a/crates/chain/benches/canonicalization.rs b/crates/chain/benches/canonicalization.rs index 074e38cc4..78cf69b93 100644 --- a/crates/chain/benches/canonicalization.rs +++ b/crates/chain/benches/canonicalization.rs @@ -95,31 +95,28 @@ fn setup(f: F) -> (KeychainTxGraph, Lo } fn run_list_canonical_txs(tx_graph: &KeychainTxGraph, chain: &LocalChain, exp_txs: usize) { - let view = tx_graph.canonical_view( - chain, - chain.tip().block_id(), - CanonicalizationParams::default(), - ); + let task = tx_graph + .graph() + .canonicalization_task(CanonicalizationParams::default()); + let view = chain.canonicalize(task, Some(chain.tip().block_id())); let txs = view.txs(); assert_eq!(txs.count(), exp_txs); } fn run_filter_chain_txouts(tx_graph: &KeychainTxGraph, chain: &LocalChain, exp_txos: usize) { - let view = tx_graph.canonical_view( - chain, - chain.tip().block_id(), - CanonicalizationParams::default(), - ); + let task = tx_graph + .graph() + .canonicalization_task(CanonicalizationParams::default()); + let view = chain.canonicalize(task, Some(chain.tip().block_id())); let utxos = view.filter_outpoints(tx_graph.index.outpoints().clone()); assert_eq!(utxos.count(), exp_txos); } fn run_filter_chain_unspents(tx_graph: &KeychainTxGraph, chain: &LocalChain, exp_utxos: usize) { - let view = tx_graph.canonical_view( - chain, - chain.tip().block_id(), - CanonicalizationParams::default(), - ); + let task = tx_graph + .graph() + .canonicalization_task(CanonicalizationParams::default()); + let view = chain.canonicalize(task, Some(chain.tip().block_id())); let utxos = view.filter_unspent_outpoints(tx_graph.index.outpoints().clone()); assert_eq!(utxos.count(), exp_utxos); } diff --git a/crates/chain/benches/indexer.rs b/crates/chain/benches/indexer.rs index 5907c76a0..a1144e3f9 100644 --- a/crates/chain/benches/indexer.rs +++ b/crates/chain/benches/indexer.rs @@ -84,8 +84,11 @@ fn do_bench(indexed_tx_graph: &KeychainTxGraph, chain: &LocalChain) { // Check balance let chain_tip = chain.tip().block_id(); let op = graph.index.outpoints().clone(); - let bal = graph - .canonical_view(chain, chain_tip, CanonicalizationParams::default()) + let task = graph + .graph() + .canonicalization_task(CanonicalizationParams::default()); + let bal = chain + .canonicalize(task, Some(chain_tip)) .balance(op, |_, _| false, 1); assert_eq!(bal.total(), AMOUNT * TX_CT as u64); } diff --git a/crates/chain/src/canonical_task.rs b/crates/chain/src/canonical_task.rs index a5e1ddbd7..b6ab3e4f3 100644 --- a/crates/chain/src/canonical_task.rs +++ b/crates/chain/src/canonical_task.rs @@ -14,8 +14,6 @@ use bitcoin::{Transaction, Txid}; pub struct CanonicalizationRequest { /// The anchors to check. pub anchors: Vec, - /// The chain tip to check against. - pub chain_tip: BlockId, } /// Response containing the best confirmed anchor, if any. @@ -27,7 +25,6 @@ type NotCanonicalSet = HashSet; /// Manages the canonicalization process without direct I/O operations. pub struct CanonicalizationTask<'g, A> { tx_graph: &'g TxGraph, - chain_tip: BlockId, unprocessed_assumed_txs: Box)> + 'g>, unprocessed_anchored_txs: @@ -49,13 +46,7 @@ pub struct CanonicalizationTask<'g, A> { impl<'g, A: Anchor> CanonicalizationTask<'g, A> { /// Creates a new canonicalization task. - /// - /// Returns the task and an optional initial request. - pub fn new( - tx_graph: &'g TxGraph, - chain_tip: BlockId, - params: CanonicalizationParams, - ) -> (Self, Option>) { + pub fn new(tx_graph: &'g TxGraph, params: CanonicalizationParams) -> Self { let anchors = tx_graph.all_anchors(); let unprocessed_assumed_txs = Box::new( params @@ -77,7 +68,6 @@ impl<'g, A: Anchor> CanonicalizationTask<'g, A> { let mut task = Self { tx_graph, - chain_tip, unprocessed_assumed_txs, unprocessed_anchored_txs, @@ -93,13 +83,10 @@ impl<'g, A: Anchor> CanonicalizationTask<'g, A> { confirmed_anchors: HashMap::new(), }; - // Process assumed transactions first (they don't need queries) + // process assumed transactions first (they don't need queries) task.process_assumed_txs(); - // Process anchored transactions and get the first request if needed - let initial_request = task.process_anchored_txs(); - - (task, initial_request) + task } /// Returns the next query needed, if any. @@ -108,7 +95,6 @@ impl<'g, A: Anchor> CanonicalizationTask<'g, A> { if let Some((_, _, anchors)) = self.pending_anchor_checks.front() { return Some(CanonicalizationRequest { anchors: anchors.clone(), - chain_tip: self.chain_tip, }); } @@ -149,7 +135,7 @@ impl<'g, A: Anchor> CanonicalizationTask<'g, A> { } /// Completes the canonicalization and returns a CanonicalView. - pub fn finish(mut self) -> CanonicalView { + pub fn finish(mut self, chain_tip: BlockId) -> CanonicalView { // Process remaining transactions (seen and leftover) self.process_seen_txs(); self.process_leftover_txs(); @@ -229,7 +215,7 @@ impl<'g, A: Anchor> CanonicalizationTask<'g, A> { } } - CanonicalView::from_parts(self.chain_tip, view_order, view_txs, view_spends) + CanonicalView::new(chain_tip, view_order, view_txs, view_spends) } fn is_canonicalized(&self, txid: Txid) -> bool { @@ -385,25 +371,10 @@ impl<'g, A: Anchor> CanonicalizationTask<'g, A> { #[cfg(test)] mod tests { - use core::convert::Infallible; - use super::*; - use crate::{local_chain::LocalChain, ChainOracle}; + use crate::local_chain::LocalChain; use bitcoin::{hashes::Hash, BlockHash, TxIn, TxOut}; - fn handle_canonicalization_request( - chain: LocalChain, - request: &CanonicalizationRequest, - ) -> Result, Infallible> { - // Check each anchor and return the first confirmed one - for anchor in &request.anchors { - if chain.is_block_in_chain(anchor.anchor_block(), request.chain_tip)? == Some(true) { - return Ok(Some(anchor.clone())); - } - } - Ok(None) - } - #[test] fn test_canonicalization_task_sans_io() { // Create a simple chain @@ -438,23 +409,10 @@ mod tests { }; let _ = tx_graph.insert_anchor(txid, anchor); - // Create canonicalization task + // Create canonicalization task and canonicalize using the chain let params = CanonicalizationParams::default(); - let (mut task, initial_request) = CanonicalizationTask::new(&tx_graph, chain_tip, params); - - // Process requests - if let Some(request) = initial_request { - let response = handle_canonicalization_request(chain.clone(), &request).unwrap(); - task.resolve_query(response); - } - - while let Some(request) = task.next_query() { - let response = handle_canonicalization_request(chain.clone(), &request).unwrap(); - task.resolve_query(response); - } - - // Get canonical view - let canonical_view = task.finish(); + let task = CanonicalizationTask::new(&tx_graph, params); + let canonical_view = chain.canonicalize(task, Some(chain_tip)); // Should have one canonical transaction assert_eq!(canonical_view.txs().len(), 1); diff --git a/crates/chain/src/canonical_view.rs b/crates/chain/src/canonical_view.rs index 9c4dd7a85..228b15454 100644 --- a/crates/chain/src/canonical_view.rs +++ b/crates/chain/src/canonical_view.rs @@ -6,14 +6,14 @@ //! ## Example //! //! ``` -//! # use bdk_chain::{CanonicalView, TxGraph, CanonicalizationParams, local_chain::LocalChain}; +//! # use bdk_chain::{TxGraph, CanonicalizationParams, CanonicalizationTask, local_chain::LocalChain}; //! # use bdk_core::BlockId; //! # use bitcoin::hashes::Hash; //! # let tx_graph = TxGraph::::default(); //! # let chain = LocalChain::from_blocks([(0, bitcoin::BlockHash::all_zeros())].into_iter().collect()).unwrap(); -//! # let chain_tip = chain.tip().block_id(); //! let params = CanonicalizationParams::default(); -//! let view = CanonicalView::new(&tx_graph, &chain, chain_tip, params).unwrap(); +//! let task = CanonicalizationTask::new(&tx_graph, params); +//! let view = chain.canonicalize(task, Some(chain.tip().block_id())); //! //! // Iterate over canonical transactions //! for tx in view.txs() { @@ -30,10 +30,7 @@ use alloc::vec::Vec; use bdk_core::BlockId; use bitcoin::{Amount, OutPoint, ScriptBuf, Transaction, Txid}; -use crate::{ - spk_txout::SpkTxOutIndex, Anchor, Balance, CanonicalizationParams, CanonicalizationTask, - ChainOracle, ChainPosition, FullTxOut, TxGraph, -}; +use crate::{spk_txout::SpkTxOutIndex, Anchor, Balance, ChainPosition, FullTxOut}; /// A single canonical transaction with its chain position. /// @@ -91,9 +88,13 @@ pub struct CanonicalView { } impl CanonicalView { - /// Creates a CanonicalView from its constituent parts. - /// This is used by CanonicalizationTask to build the view. - pub(crate) fn from_parts( + /// 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 new( tip: BlockId, order: Vec, txs: HashMap, ChainPosition)>, @@ -107,59 +108,6 @@ impl CanonicalView { } } - /// Create a new [`CanonicalView`] from a transaction graph. - /// - /// This constructor analyzes the given [`TxGraph`] and creates a [`CanonicalView`] of all - /// transactions, resolving conflicts and ordering them according to their chain position. - /// - /// # Errors - /// - /// An error will occur if the [`ChainOracle`] fails. - pub fn new<'g, C>( - tx_graph: &'g TxGraph, - chain: &'g C, - chain_tip: BlockId, - params: CanonicalizationParams, - ) -> Result - where - C: ChainOracle, - { - let (mut task, init_request) = CanonicalizationTask::new(tx_graph, chain_tip, params); - - // If an initial request is available, start by first processing it. - if let Some(request) = init_request { - // It verifies each `Anchor` provided in the request, resolving the query for the first - // confirmed one. - let mut best_anchor = None; - for anchor in &request.anchors { - if chain.is_block_in_chain(anchor.anchor_block(), request.chain_tip)? == Some(true) - { - best_anchor = Some(anchor.clone()); - break; - } - } - task.resolve_query(best_anchor); - } - - // Process all the following requests - while let Some(request) = task.next_query() { - // It verifies each `Anchor` provided in the request, resolving the query for the first - // confirmed one. - let mut best_anchor = None; - for anchor in &request.anchors { - if chain.is_block_in_chain(anchor.anchor_block(), request.chain_tip)? == Some(true) - { - best_anchor = Some(anchor.clone()); - break; - } - } - task.resolve_query(best_anchor); - } - - // Return the finished canonical view - Ok(task.finish()) - } - /// Get a single canonical transaction by its transaction ID. /// /// Returns `Some(CanonicalViewTx)` if the transaction exists in the canonical view, @@ -205,12 +153,13 @@ impl CanonicalView { /// # Example /// /// ``` - /// # use bdk_chain::{CanonicalView, TxGraph, local_chain::LocalChain}; + /// # use bdk_chain::{TxGraph, CanonicalizationTask, local_chain::LocalChain}; /// # use bdk_core::BlockId; /// # use bitcoin::hashes::Hash; /// # let tx_graph = TxGraph::::default(); /// # let chain = LocalChain::from_blocks([(0, bitcoin::BlockHash::all_zeros())].into_iter().collect()).unwrap(); - /// # let view = CanonicalView::new(&tx_graph, &chain, chain.tip().block_id(), Default::default()).unwrap(); + /// # let task = CanonicalizationTask::new(&tx_graph, Default::default()); + /// # let view = chain.canonicalize(task, Some(chain.tip().block_id())); /// // Iterate over all canonical transactions /// for tx in view.txs() { /// println!("TX {}: {:?}", tx.txid, tx.pos); @@ -238,12 +187,13 @@ impl CanonicalView { /// # Example /// /// ``` - /// # use bdk_chain::{CanonicalView, TxGraph, local_chain::LocalChain, keychain_txout::KeychainTxOutIndex}; + /// # use bdk_chain::{TxGraph, CanonicalizationTask, local_chain::LocalChain, keychain_txout::KeychainTxOutIndex}; /// # use bdk_core::BlockId; /// # use bitcoin::hashes::Hash; /// # let tx_graph = TxGraph::::default(); /// # let chain = LocalChain::from_blocks([(0, bitcoin::BlockHash::all_zeros())].into_iter().collect()).unwrap(); - /// # let view = CanonicalView::new(&tx_graph, &chain, chain.tip().block_id(), Default::default()).unwrap(); + /// # let task = CanonicalizationTask::new(&tx_graph, Default::default()); + /// # let view = chain.canonicalize(task, Some(chain.tip().block_id())); /// # let indexer = KeychainTxOutIndex::<&str>::default(); /// // Get all outputs from an indexer /// for (keychain, txout) in view.filter_outpoints(indexer.outpoints().clone()) { @@ -267,12 +217,13 @@ impl CanonicalView { /// # Example /// /// ``` - /// # use bdk_chain::{CanonicalView, TxGraph, local_chain::LocalChain, keychain_txout::KeychainTxOutIndex}; + /// # use bdk_chain::{TxGraph, CanonicalizationTask, local_chain::LocalChain, keychain_txout::KeychainTxOutIndex}; /// # use bdk_core::BlockId; /// # use bitcoin::hashes::Hash; /// # let tx_graph = TxGraph::::default(); /// # let chain = LocalChain::from_blocks([(0, bitcoin::BlockHash::all_zeros())].into_iter().collect()).unwrap(); - /// # let view = CanonicalView::new(&tx_graph, &chain, chain.tip().block_id(), Default::default()).unwrap(); + /// # let task = CanonicalizationTask::new(&tx_graph, Default::default()); + /// # let view = chain.canonicalize(task, Some(chain.tip().block_id())); /// # let indexer = KeychainTxOutIndex::<&str>::default(); /// // Get unspent outputs (UTXOs) from an indexer /// for (keychain, utxo) in view.filter_unspent_outpoints(indexer.outpoints().clone()) { @@ -313,12 +264,13 @@ impl CanonicalView { /// # Example /// /// ``` - /// # use bdk_chain::{CanonicalView, TxGraph, local_chain::LocalChain, keychain_txout::KeychainTxOutIndex}; + /// # use bdk_chain::{CanonicalizationTask, TxGraph, local_chain::LocalChain, keychain_txout::KeychainTxOutIndex}; /// # use bdk_core::BlockId; /// # use bitcoin::hashes::Hash; /// # let tx_graph = TxGraph::::default(); /// # let chain = LocalChain::from_blocks([(0, bitcoin::BlockHash::all_zeros())].into_iter().collect()).unwrap(); - /// # let view = CanonicalView::new(&tx_graph, &chain, chain.tip().block_id(), Default::default()).unwrap(); + /// # let task = CanonicalizationTask::new(&tx_graph, Default::default()); + /// # let view = chain.canonicalize(task, Some(chain.tip().block_id())); /// # let indexer = KeychainTxOutIndex::<&str>::default(); /// // Calculate balance with 6 confirmations, trusting all outputs /// let balance = view.balance( diff --git a/crates/chain/src/indexed_tx_graph.rs b/crates/chain/src/indexed_tx_graph.rs index 9adf7ed93..bcb60c4a9 100644 --- a/crates/chain/src/indexed_tx_graph.rs +++ b/crates/chain/src/indexed_tx_graph.rs @@ -1,14 +1,13 @@ //! Contains the [`IndexedTxGraph`] and associated types. Refer to the //! [`IndexedTxGraph`] documentation for more. -use core::{convert::Infallible, fmt::Debug}; +use core::fmt::Debug; use alloc::{sync::Arc, vec::Vec}; use bitcoin::{Block, OutPoint, Transaction, TxOut, Txid}; use crate::{ tx_graph::{self, TxGraph}, - Anchor, BlockId, CanonicalView, CanonicalizationParams, ChainOracle, Indexer, Merge, - TxPosInBlock, + Anchor, BlockId, CanonicalizationParams, CanonicalizationTask, Indexer, Merge, TxPosInBlock, }; /// A [`TxGraph`] paired with an indexer `I`, enforcing that every insertion into the graph is @@ -423,36 +422,27 @@ where } } +impl AsRef> for IndexedTxGraph { + fn as_ref(&self) -> &TxGraph { + &self.graph + } +} + impl IndexedTxGraph where A: Anchor, { - /// Returns a [`CanonicalView`]. - pub fn try_canonical_view<'a, C: ChainOracle>( - &'a self, - chain: &'a C, - chain_tip: BlockId, - params: CanonicalizationParams, - ) -> Result, C::Error> { - self.graph.try_canonical_view(chain, chain_tip, params) - } - - /// Returns a [`CanonicalView`]. + /// Creates a `[CanonicalizationTask]` to determine the `[CanonicalView]` of transactions. /// - /// This is the infallible version of [`try_canonical_view`](Self::try_canonical_view). - pub fn canonical_view<'a, C: ChainOracle>( - &'a self, - chain: &'a C, - chain_tip: BlockId, + /// 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, params: CanonicalizationParams, - ) -> CanonicalView { - self.graph.canonical_view(chain, chain_tip, params) - } -} - -impl AsRef> for IndexedTxGraph { - fn as_ref(&self) -> &TxGraph { - &self.graph + ) -> CanonicalizationTask<'_, A> { + self.graph.canonicalization_task(params) } } diff --git a/crates/chain/src/local_chain.rs b/crates/chain/src/local_chain.rs index 5bfff3aa9..bf932d068 100644 --- a/crates/chain/src/local_chain.rs +++ b/crates/chain/src/local_chain.rs @@ -4,8 +4,9 @@ use core::convert::Infallible; use core::fmt; use core::ops::RangeBounds; +use crate::canonical_task::CanonicalizationTask; use crate::collections::BTreeMap; -use crate::{BlockId, ChainOracle, Merge}; +use crate::{Anchor, BlockId, CanonicalView, ChainOracle, Merge}; use bdk_core::ToBlockHash; pub use bdk_core::{CheckPoint, CheckPointIter}; use bitcoin::block::Header; @@ -96,6 +97,83 @@ impl ChainOracle for LocalChain { // Methods for `LocalChain` impl LocalChain { + // /// Check if a block is in the chain. + // /// + // /// # Arguments + // /// * `block` - The block to check + // /// * `chain_tip` - The chain tip to check against + // /// + // /// # Returns + // /// * `Some(true)` if the block is in the chain + // /// * `Some(false)` if the block is not in the chain + // /// * `None` if it cannot be determined + // pub fn is_block_in_chain(&self, block: BlockId, chain_tip: BlockId) -> Option { + // let chain_tip_cp = match self.tip.get(chain_tip.height) { + // // we can only determine whether `block` is in chain of `chain_tip` if `chain_tip` + // can // be identified in chain + // Some(cp) if cp.hash() == chain_tip.hash => cp, + // _ => return None, + // }; + // chain_tip_cp + // .get(block.height) + // .map(|cp| cp.hash() == block.hash) + // } + + // /// Get the chain tip. + // /// + // /// # Returns + // /// The [`BlockId`] of the chain tip. + // pub fn chain_tip(&self) -> BlockId { + // self.tip.block_id() + // } + + /// Canonicalize a transaction graph using this chain. + /// + /// This method processes a [`CanonicalizationTask`], handling all its requests + /// to determine which transactions are canonical, and returns a [`CanonicalView`]. + /// + /// # Example + /// + /// ``` + /// # use bdk_chain::{CanonicalizationTask, CanonicalizationParams, TxGraph, local_chain::LocalChain}; + /// # use bdk_core::BlockId; + /// # use bitcoin::hashes::Hash; + /// # let tx_graph: TxGraph = TxGraph::default(); + /// # let chain = LocalChain::from_blocks([(0, bitcoin::BlockHash::all_zeros())].into_iter().collect()).unwrap(); + /// let task = CanonicalizationTask::new(&tx_graph, CanonicalizationParams::default()); + /// let view = chain.canonicalize(task, Some(chain.tip().block_id())); + /// ``` + pub fn canonicalize( + &self, + mut task: CanonicalizationTask<'_, A>, + chain_tip: Option, + ) -> CanonicalView { + let chain_tip = match chain_tip { + Some(chain_tip) => chain_tip, + None => self.get_chain_tip().expect("infallible"), + }; + + // Process all requests from the task + while let Some(request) = task.next_query() { + // Check each anchor and return the first confirmed one + let mut best_anchor = None; + for anchor in &request.anchors { + if self + .is_block_in_chain(anchor.anchor_block(), chain_tip) + .expect("infallible") + == Some(true) + { + best_anchor = Some(anchor.clone()); + break; + } + } + task.resolve_query(best_anchor); + } + + // Return the finished canonical view + task.finish(chain_tip) + } + /// Update the chain with a given [`Header`] at `height` which you claim is connected to a /// existing block in the chain. /// diff --git a/crates/chain/src/tx_graph.rs b/crates/chain/src/tx_graph.rs index 44c34c2d7..74016688b 100644 --- a/crates/chain/src/tx_graph.rs +++ b/crates/chain/src/tx_graph.rs @@ -21,18 +21,26 @@ //! Conflicting transactions are allowed to coexist within a [`TxGraph`]. A process called //! canonicalization is required to get a conflict-free view of transactions. //! -//! * [`canonical_iter`](TxGraph::canonical_iter) returns a [`CanonicalIter`] which performs -//! incremental canonicalization. This is useful when you only need to check specific transactions -//! (e.g., verifying whether a few unconfirmed transactions are canonical) without computing the -//! entire canonical view. -//! * [`canonical_view`](TxGraph::canonical_view) returns a [`CanonicalView`] which provides a -//! complete canonical view of the graph. This is required for typical wallet operations like -//! querying balances, listing outputs, transactions, and UTXOs. You must construct this first -//! before performing these operations. +//! The canonicalization process uses a two-step, sans-IO approach: //! -//! All these methods require a `chain` and `chain_tip` argument. The `chain` must be a -//! [`ChainOracle`] implementation (such as [`LocalChain`](crate::local_chain::LocalChain)) which -//! identifies which blocks exist under a given `chain_tip`. +//! 1. **Create a canonicalization task** using +//! [`canonicalization_task`](TxGraph::canonicalization_task): ```ignore let task = +//! tx_graph.canonicalization_task(params); ``` This creates a [`CanonicalizationTask`] that +//! encapsulates the canonicalization logic without performing any I/O operations. +//! +//! 2. **Execute the task** with a chain oracle to obtain a [`CanonicalView`]: ```ignore let view = +//! chain.canonicalize(task, Some(chain_tip)); ``` 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: @@ -119,11 +127,9 @@ //! [`insert_txout`]: TxGraph::insert_txout use crate::collections::*; -use crate::BlockId; -use crate::CanonicalIter; -use crate::CanonicalView; use crate::CanonicalizationParams; -use crate::{Anchor, ChainOracle, Merge}; +use crate::CanonicalizationTask; +use crate::{Anchor, Merge}; use alloc::collections::vec_deque::VecDeque; use alloc::sync::Arc; use alloc::vec::Vec; @@ -131,10 +137,7 @@ use bdk_core::ConfirmationBlockTime; pub use bdk_core::TxUpdate; use bitcoin::{Amount, OutPoint, SignedAmount, Transaction, TxOut, Txid}; use core::fmt::{self, Formatter}; -use core::{ - convert::Infallible, - ops::{Deref, RangeInclusive}, -}; +use core::ops::{Deref, RangeInclusive}; impl From> for TxUpdate { fn from(graph: TxGraph) -> Self { @@ -969,6 +972,19 @@ impl TxGraph { let _ = self.insert_evicted_at(txid, evicted_at); } } + + /// Creates a [`CanonicalizationTask`] to determine the [`CanonicalView`] of transactions. + /// + /// This method delegates to the underlying [`TxGraph`] to create a [`CanonicalizationTask`] + /// that can be used to determine which transactions are canonical based on the provided + /// parameters. The task handles the stateless canonicalization logic and can be polled + /// for anchor verification requests. + pub fn canonicalization_task( + &'_ self, + params: CanonicalizationParams, + ) -> CanonicalizationTask<'_, A> { + CanonicalizationTask::new(self, params) + } } impl TxGraph { @@ -997,36 +1013,6 @@ impl TxGraph { }) } - /// Returns a [`CanonicalIter`]. - pub fn canonical_iter<'a, C: ChainOracle>( - &'a self, - chain: &'a C, - chain_tip: BlockId, - params: CanonicalizationParams, - ) -> CanonicalIter<'a, A, C> { - CanonicalIter::new(self, chain, chain_tip, params) - } - - /// Returns a [`CanonicalView`]. - pub fn try_canonical_view<'a, C: ChainOracle>( - &'a self, - chain: &'a C, - chain_tip: BlockId, - params: CanonicalizationParams, - ) -> Result, C::Error> { - CanonicalView::new(self, chain, chain_tip, params) - } - - /// Returns a [`CanonicalView`]. - pub fn canonical_view<'a, C: ChainOracle>( - &'a self, - chain: &'a C, - chain_tip: BlockId, - params: CanonicalizationParams, - ) -> CanonicalView { - CanonicalView::new(self, chain, chain_tip, params).expect("infallible") - } - /// Construct a `TxGraph` from a `changeset`. pub fn from_changeset(changeset: ChangeSet) -> Self { let mut graph = Self::default(); diff --git a/crates/chain/tests/test_canonical_view.rs b/crates/chain/tests/test_canonical_view.rs index 3c0d54381..51e4e1463 100644 --- a/crates/chain/tests/test_canonical_view.rs +++ b/crates/chain/tests/test_canonical_view.rs @@ -54,8 +54,8 @@ fn test_min_confirmations_parameter() { let _ = tx_graph.insert_anchor(txid, anchor_height_5); let chain_tip = chain.tip().block_id(); - let canonical_view = - tx_graph.canonical_view(&chain, chain_tip, CanonicalizationParams::default()); + let task = tx_graph.canonicalization_task(CanonicalizationParams::default()); + let canonical_view = chain.canonicalize(task, Some(chain_tip)); // Test min_confirmations = 1: Should be confirmed (has 6 confirmations) let balance_1_conf = canonical_view.balance( @@ -142,11 +142,8 @@ fn test_min_confirmations_with_untrusted_tx() { }; let _ = tx_graph.insert_anchor(txid, anchor); - let canonical_view = tx_graph.canonical_view( - &chain, - chain.tip().block_id(), - CanonicalizationParams::default(), - ); + let task = tx_graph.canonicalization_task(CanonicalizationParams::default()); + let canonical_view = chain.canonicalize(task, Some(chain.tip().block_id())); // Test with min_confirmations = 5 and untrusted predicate let balance = canonical_view.balance( @@ -263,11 +260,8 @@ fn test_min_confirmations_multiple_transactions() { ); outpoints.push(((), outpoint2)); - let canonical_view = tx_graph.canonical_view( - &chain, - chain.tip().block_id(), - CanonicalizationParams::default(), - ); + let task = tx_graph.canonicalization_task(CanonicalizationParams::default()); + let canonical_view = chain.canonicalize(task, Some(chain.tip().block_id())); // Test with min_confirmations = 5 // tx0: 11 confirmations -> confirmed diff --git a/crates/chain/tests/test_indexed_tx_graph.rs b/crates/chain/tests/test_indexed_tx_graph.rs index 18d1ff1bf..80ecb3934 100644 --- a/crates/chain/tests/test_indexed_tx_graph.rs +++ b/crates/chain/tests/test_indexed_tx_graph.rs @@ -470,23 +470,30 @@ fn test_list_owned_txouts() { .get(height) .map(|cp| cp.block_id()) .unwrap_or_else(|| panic!("block must exist at {height}")); - let txouts = graph - .canonical_view(&local_chain, chain_tip, CanonicalizationParams::default()) + let task = graph + .graph() + .canonicalization_task(CanonicalizationParams::default()); + let txouts = local_chain + .canonicalize(task, Some(chain_tip)) .filter_outpoints(graph.index.outpoints().iter().cloned()) .collect::>(); - let utxos = graph - .canonical_view(&local_chain, chain_tip, CanonicalizationParams::default()) + let task = graph + .graph() + .canonicalization_task(CanonicalizationParams::default()); + let utxos = local_chain + .canonicalize(task, Some(chain_tip)) .filter_unspent_outpoints(graph.index.outpoints().iter().cloned()) .collect::>(); - let balance = graph - .canonical_view(&local_chain, chain_tip, CanonicalizationParams::default()) - .balance( - graph.index.outpoints().iter().cloned(), - |_, txout| trusted_spks.contains(&txout.txout.script_pubkey), - 1, - ); + let task = graph + .graph() + .canonicalization_task(CanonicalizationParams::default()); + let balance = local_chain.canonicalize(task, Some(chain_tip)).balance( + graph.index.outpoints().iter().cloned(), + |_, txout| trusted_spks.contains(&txout.txout.script_pubkey), + 0, + ); let confirmed_txouts_txid = txouts .iter() @@ -789,12 +796,11 @@ fn test_get_chain_position() { } // check chain position - let chain_pos = graph - .canonical_view( - chain, - chain.tip().block_id(), - CanonicalizationParams::default(), - ) + let task = graph + .graph() + .canonicalization_task(CanonicalizationParams::default()); + let chain_pos = chain + .canonicalize(task, Some(chain.tip().block_id())) .txs() .find_map(|canon_tx| { if canon_tx.txid == txid { diff --git a/crates/chain/tests/test_tx_graph.rs b/crates/chain/tests/test_tx_graph.rs index b2a359608..3bc14c126 100644 --- a/crates/chain/tests/test_tx_graph.rs +++ b/crates/chain/tests/test_tx_graph.rs @@ -1014,8 +1014,9 @@ fn test_chain_spends() { let build_canonical_spends = |chain: &LocalChain, tx_graph: &TxGraph| -> HashMap { - tx_graph - .canonical_view(chain, tip.block_id(), CanonicalizationParams::default()) + let task = tx_graph.canonicalization_task(CanonicalizationParams::default()); + chain + .canonicalize(task, Some(tip.block_id())) .filter_outpoints(tx_graph.all_txouts().map(|(op, _)| ((), op))) .filter_map(|(_, full_txo)| Some((full_txo.outpoint, full_txo.spent_by?))) .collect() @@ -1023,8 +1024,9 @@ fn test_chain_spends() { let build_canonical_positions = |chain: &LocalChain, tx_graph: &TxGraph| -> HashMap> { - tx_graph - .canonical_view(chain, tip.block_id(), CanonicalizationParams::default()) + let task = tx_graph.canonicalization_task(CanonicalizationParams::default()); + chain + .canonicalize(task, Some(tip.block_id())) .txs() .map(|canon_tx| (canon_tx.txid, canon_tx.pos)) .collect() @@ -1197,35 +1199,26 @@ fn transactions_inserted_into_tx_graph_are_not_canonical_until_they_have_an_anch .into_iter() .collect(); let chain = LocalChain::from_blocks(blocks).unwrap(); - let canonical_txs: Vec<_> = graph - .canonical_view( - &chain, - chain.tip().block_id(), - CanonicalizationParams::default(), - ) + let task = graph.canonicalization_task(CanonicalizationParams::default()); + let canonical_txs: Vec<_> = chain + .canonicalize(task, Some(chain.tip().block_id())) .txs() .collect(); assert!(canonical_txs.is_empty()); // tx0 with seen_at should be returned by canonical txs let _ = graph.insert_seen_at(txids[0], 2); - let canonical_view = graph.canonical_view( - &chain, - chain.tip().block_id(), - CanonicalizationParams::default(), - ); + let task = graph.canonicalization_task(CanonicalizationParams::default()); + let canonical_view = chain.canonicalize(task, Some(chain.tip().block_id())); let mut canonical_txs = canonical_view.txs(); assert_eq!(canonical_txs.next().map(|tx| tx.txid).unwrap(), txids[0]); drop(canonical_txs); // tx1 with anchor is also canonical let _ = graph.insert_anchor(txids[1], block_id!(2, "B")); - let canonical_txids: Vec<_> = graph - .canonical_view( - &chain, - chain.tip().block_id(), - CanonicalizationParams::default(), - ) + let task = graph.canonicalization_task(CanonicalizationParams::default()); + let canonical_txids: Vec<_> = chain + .canonicalize(task, Some(chain.tip().block_id())) .txs() .map(|tx| tx.txid) .collect(); diff --git a/crates/chain/tests/test_tx_graph_conflicts.rs b/crates/chain/tests/test_tx_graph_conflicts.rs index 38f21365c..9fb5aad58 100644 --- a/crates/chain/tests/test_tx_graph_conflicts.rs +++ b/crates/chain/tests/test_tx_graph_conflicts.rs @@ -970,9 +970,11 @@ fn test_tx_conflict_handling() { for scenario in scenarios { let env = init_graph(scenario.tx_templates.iter()); - let txs = env + let task = env .tx_graph - .canonical_view(&local_chain, chain_tip, env.canonicalization_params.clone()) + .canonicalization_task(env.canonicalization_params.clone()); + let txs = local_chain + .canonicalize(task, Some(chain_tip)) .txs() .map(|tx| tx.txid) .collect::>(); @@ -987,9 +989,11 @@ fn test_tx_conflict_handling() { scenario.name ); - let txouts = env + let task = env .tx_graph - .canonical_view(&local_chain, chain_tip, env.canonicalization_params.clone()) + .canonicalization_task(env.canonicalization_params.clone()); + let txouts = local_chain + .canonicalize(task, Some(chain_tip)) .filter_outpoints(env.indexer.outpoints().iter().cloned()) .map(|(_, full_txout)| full_txout.outpoint) .collect::>(); @@ -1007,9 +1011,11 @@ fn test_tx_conflict_handling() { scenario.name ); - let utxos = env + let task = env .tx_graph - .canonical_view(&local_chain, chain_tip, env.canonicalization_params.clone()) + .canonicalization_task(env.canonicalization_params.clone()); + let utxos = local_chain + .canonicalize(task, Some(chain_tip)) .filter_unspent_outpoints(env.indexer.outpoints().iter().cloned()) .map(|(_, full_txout)| full_txout.outpoint) .collect::>(); @@ -1027,18 +1033,18 @@ fn test_tx_conflict_handling() { scenario.name ); - let balance = env + let task = env .tx_graph - .canonical_view(&local_chain, chain_tip, env.canonicalization_params.clone()) - .balance( - env.indexer.outpoints().iter().cloned(), - |_, txout| { - env.indexer - .index_of_spk(txout.txout.script_pubkey.as_script()) - .is_some() - }, - 0, - ); + .canonicalization_task(env.canonicalization_params.clone()); + let balance = local_chain.canonicalize(task, Some(chain_tip)).balance( + env.indexer.outpoints().iter().cloned(), + |_, txout| { + env.indexer + .index_of_spk(txout.txout.script_pubkey.as_script()) + .is_some() + }, + 0, + ); assert_eq!( balance, scenario.exp_balance, "\n[{}] 'balance' failed", diff --git a/crates/electrum/tests/test_electrum.rs b/crates/electrum/tests/test_electrum.rs index 318708a19..71cc88d88 100644 --- a/crates/electrum/tests/test_electrum.rs +++ b/crates/electrum/tests/test_electrum.rs @@ -40,9 +40,12 @@ fn get_balance( ) -> anyhow::Result { let chain_tip = recv_chain.tip().block_id(); let outpoints = recv_graph.index.outpoints().clone(); - let balance = recv_graph - .canonical_view(recv_chain, chain_tip, CanonicalizationParams::default()) - .balance(outpoints, |_, _| true, 1); + let task = recv_graph + .graph() + .canonicalization_task(CanonicalizationParams::default()); + let balance = recv_chain + .canonicalize(task, Some(chain_tip)) + .balance(outpoints, |_, _| true, 0); Ok(balance) } @@ -156,9 +159,11 @@ pub fn detect_receive_tx_cancel() -> anyhow::Result<()> { .chain_tip(chain.tip()) .spks_with_indexes(graph.index.all_spks().clone()) .expected_spk_txids( - graph - .canonical_view(&chain, chain.tip().block_id(), Default::default()) - .list_expected_spk_txids(&graph.index, ..), + { + let task = graph.canonicalization_task(Default::default()); + chain.canonicalize(task, Some(chain.tip().block_id())) + } + .list_expected_spk_txids(&graph.index, ..), ); let sync_response = client.sync(sync_request, BATCH_SIZE, true)?; assert!( @@ -186,9 +191,11 @@ pub fn detect_receive_tx_cancel() -> anyhow::Result<()> { .chain_tip(chain.tip()) .spks_with_indexes(graph.index.all_spks().clone()) .expected_spk_txids( - graph - .canonical_view(&chain, chain.tip().block_id(), Default::default()) - .list_expected_spk_txids(&graph.index, ..), + { + let task = graph.canonicalization_task(Default::default()); + chain.canonicalize(task, Some(chain.tip().block_id())) + } + .list_expected_spk_txids(&graph.index, ..), ); let sync_response = client.sync(sync_request, BATCH_SIZE, true)?; assert!( diff --git a/crates/esplora/tests/async_ext.rs b/crates/esplora/tests/async_ext.rs index 209e5b788..5554006e3 100644 --- a/crates/esplora/tests/async_ext.rs +++ b/crates/esplora/tests/async_ext.rs @@ -100,9 +100,11 @@ pub async fn detect_receive_tx_cancel() -> anyhow::Result<()> { .chain_tip(chain.tip()) .spks_with_indexes(graph.index.all_spks().clone()) .expected_spk_txids( - graph - .canonical_view(&chain, chain.tip().block_id(), Default::default()) - .list_expected_spk_txids(&graph.index, ..), + { + let task = graph.canonicalization_task(Default::default()); + chain.canonicalize(task, Some(chain.tip().block_id())) + } + .list_expected_spk_txids(&graph.index, ..), ); let sync_response = client.sync(sync_request, 1).await?; assert!( @@ -130,9 +132,11 @@ pub async fn detect_receive_tx_cancel() -> anyhow::Result<()> { .chain_tip(chain.tip()) .spks_with_indexes(graph.index.all_spks().clone()) .expected_spk_txids( - graph - .canonical_view(&chain, chain.tip().block_id(), Default::default()) - .list_expected_spk_txids(&graph.index, ..), + { + let task = graph.canonicalization_task(Default::default()); + chain.canonicalize(task, Some(chain.tip().block_id())) + } + .list_expected_spk_txids(&graph.index, ..), ); let sync_response = client.sync(sync_request, 1).await?; assert!( diff --git a/crates/esplora/tests/blocking_ext.rs b/crates/esplora/tests/blocking_ext.rs index 76ed28fbb..99e716a50 100644 --- a/crates/esplora/tests/blocking_ext.rs +++ b/crates/esplora/tests/blocking_ext.rs @@ -97,9 +97,11 @@ pub fn detect_receive_tx_cancel() -> anyhow::Result<()> { .chain_tip(chain.tip()) .spks_with_indexes(graph.index.all_spks().clone()) .expected_spk_txids( - graph - .canonical_view(&chain, chain.tip().block_id(), Default::default()) - .list_expected_spk_txids(&graph.index, ..), + { + let task = graph.canonicalization_task(Default::default()); + chain.canonicalize(task, Some(chain.tip().block_id())) + } + .list_expected_spk_txids(&graph.index, ..), ); let sync_response = client.sync(sync_request, 1)?; assert!( @@ -127,9 +129,11 @@ pub fn detect_receive_tx_cancel() -> anyhow::Result<()> { .chain_tip(chain.tip()) .spks_with_indexes(graph.index.all_spks().clone()) .expected_spk_txids( - graph - .canonical_view(&chain, chain.tip().block_id(), Default::default()) - .list_expected_spk_txids(&graph.index, ..), + { + let task = graph.canonicalization_task(Default::default()); + chain.canonicalize(task, Some(chain.tip().block_id())) + } + .list_expected_spk_txids(&graph.index, ..), ); let sync_response = client.sync(sync_request, 1)?; assert!( diff --git a/examples/example_bitcoind_rpc_polling/src/main.rs b/examples/example_bitcoind_rpc_polling/src/main.rs index 0263c5b0b..b9cff2be5 100644 --- a/examples/example_bitcoind_rpc_polling/src/main.rs +++ b/examples/example_bitcoind_rpc_polling/src/main.rs @@ -144,15 +144,15 @@ fn main() -> anyhow::Result<()> { &rpc_client, chain.tip(), fallback_height, - graph - .canonical_view( - &*chain, - chain.tip().block_id(), - CanonicalizationParams::default(), - ) - .txs() - .filter(|tx| tx.pos.is_unconfirmed()) - .map(|tx| tx.tx), + { + let task = graph + .graph() + .canonicalization_task(CanonicalizationParams::default()); + chain.canonicalize(task, Some(chain.tip().block_id())) + } + .txs() + .filter(|tx| tx.pos.is_unconfirmed()) + .map(|tx| tx.tx), ) }; let mut db_stage = ChangeSet::default(); @@ -196,17 +196,17 @@ fn main() -> anyhow::Result<()> { last_print = Instant::now(); let synced_to = chain.tip(); let balance = { - graph - .canonical_view( - &*chain, - synced_to.block_id(), - CanonicalizationParams::default(), - ) - .balance( - graph.index.outpoints().iter().cloned(), - |(k, _), _| k == &Keychain::Internal, - 1, - ) + { + let task = graph + .graph() + .canonicalization_task(CanonicalizationParams::default()); + chain.canonicalize(task, Some(synced_to.block_id())) + } + .balance( + graph.index.outpoints().iter().cloned(), + |(k, _), _| k == &Keychain::Internal, + 0, + ) }; println!( "[{:>10}s] synced to {} @ {} | total: {}", @@ -249,15 +249,15 @@ fn main() -> anyhow::Result<()> { rpc_client.clone(), chain.tip(), fallback_height, - graph - .canonical_view( - &*chain, - chain.tip().block_id(), - CanonicalizationParams::default(), - ) - .txs() - .filter(|tx| tx.pos.is_unconfirmed()) - .map(|tx| tx.tx), + { + let task = graph + .graph() + .canonicalization_task(CanonicalizationParams::default()); + chain.canonicalize(task, Some(chain.tip().block_id())) + } + .txs() + .filter(|tx| tx.pos.is_unconfirmed()) + .map(|tx| tx.tx), ) }; @@ -356,17 +356,17 @@ fn main() -> anyhow::Result<()> { last_print = Some(Instant::now()); let synced_to = chain.tip(); let balance = { - graph - .canonical_view( - &*chain, - synced_to.block_id(), - CanonicalizationParams::default(), - ) - .balance( - graph.index.outpoints().iter().cloned(), - |(k, _), _| k == &Keychain::Internal, - 1, - ) + { + let task = graph + .graph() + .canonicalization_task(CanonicalizationParams::default()); + chain.canonicalize(task, Some(synced_to.block_id())) + } + .balance( + graph.index.outpoints().iter().cloned(), + |(k, _), _| k == &Keychain::Internal, + 0, + ) }; println!( "[{:>10}s] synced to {} @ {} / {} | total: {}", diff --git a/examples/example_cli/src/lib.rs b/examples/example_cli/src/lib.rs index 6745ae6c1..293a93093 100644 --- a/examples/example_cli/src/lib.rs +++ b/examples/example_cli/src/lib.rs @@ -1,6 +1,7 @@ use bdk_chain::keychain_txout::DEFAULT_LOOKAHEAD; use serde_json::json; use std::cmp; +use std::convert::Infallible; use std::env; use std::fmt; use std::str::FromStr; @@ -258,18 +259,15 @@ pub struct ChangeInfo { pub index: u32, } -pub fn create_tx( +pub fn create_tx( graph: &mut KeychainTxGraph, - chain: &O, + chain: &LocalChain, assets: &Assets, cs_algorithm: CoinSelectionAlgo, address: Address, value: u64, feerate: f32, -) -> anyhow::Result<(Psbt, Option)> -where - O::Error: core::error::Error + Send + Sync + 'static, -{ +) -> anyhow::Result<(Psbt, Option)> { let mut changeset = keychain_txout::ChangeSet::default(); // get planned utxos @@ -422,15 +420,18 @@ where // Alias the elements of `planned_utxos` pub type PlanUtxo = (Plan, FullTxOut); -pub fn planned_utxos( +pub fn planned_utxos( graph: &KeychainTxGraph, - chain: &O, + chain: &LocalChain, assets: &Assets, -) -> Result, O::Error> { - let chain_tip = chain.get_chain_tip()?; +) -> Result, Infallible> { + let chain_tip = chain.tip().block_id(); let outpoints = graph.index.outpoints(); - graph - .try_canonical_view(chain, chain_tip, CanonicalizationParams::default())? + let task = graph + .graph() + .canonicalization_task(CanonicalizationParams::default()); + chain + .canonicalize(task, Some(chain_tip)) .filter_unspent_outpoints(outpoints.iter().cloned()) .filter_map(|((k, i), full_txo)| -> Option> { let desc = graph @@ -522,12 +523,11 @@ pub fn handle_commands( } } - let balance = graph - .try_canonical_view( - chain, - chain.get_chain_tip()?, - CanonicalizationParams::default(), - )? + let task = graph + .graph() + .canonicalization_task(CanonicalizationParams::default()); + let balance = chain + .canonicalize(task, Some(chain.tip().block_id())) .balance( graph.index.outpoints().iter().cloned(), |(k, _), _| k == &Keychain::Internal, @@ -569,8 +569,11 @@ pub fn handle_commands( confirmed, unconfirmed, } => { - let txouts = graph - .try_canonical_view(chain, chain_tip, CanonicalizationParams::default())? + let task = graph + .graph() + .canonicalization_task(CanonicalizationParams::default()); + let txouts = chain + .canonicalize(task, Some(chain_tip)) .filter_outpoints(outpoints.iter().cloned()) .filter(|(_, full_txo)| match (spent, unspent) { (true, false) => full_txo.spent_by.is_some(), @@ -629,7 +632,7 @@ pub fn handle_commands( create_tx( &mut graph, - &*chain, + &chain, &assets, coin_select, address, diff --git a/examples/example_electrum/src/main.rs b/examples/example_electrum/src/main.rs index c92666303..5213adfab 100644 --- a/examples/example_electrum/src/main.rs +++ b/examples/example_electrum/src/main.rs @@ -226,11 +226,11 @@ fn main() -> anyhow::Result<()> { } let _ = io::stderr().flush(); }); - let canonical_view = graph.canonical_view( - &*chain, - chain_tip.block_id(), - CanonicalizationParams::default(), - ); + + let task = graph + .graph() + .canonicalization_task(CanonicalizationParams::default()); + let canonical_view = chain.canonicalize(task, Some(chain_tip.block_id())); request = request .expected_spk_txids(canonical_view.list_expected_spk_txids(&graph.index, ..)); diff --git a/examples/example_esplora/src/main.rs b/examples/example_esplora/src/main.rs index 9bc3231e6..3f780924b 100644 --- a/examples/example_esplora/src/main.rs +++ b/examples/example_esplora/src/main.rs @@ -237,11 +237,10 @@ fn main() -> anyhow::Result<()> { { let graph = graph.lock().unwrap(); let chain = chain.lock().unwrap(); - let canonical_view = graph.canonical_view( - &*chain, - local_tip.block_id(), - CanonicalizationParams::default(), - ); + let task = graph + .graph() + .canonicalization_task(CanonicalizationParams::default()); + let canonical_view = chain.canonicalize(task, Some(local_tip.block_id())); request = request .expected_spk_txids(canonical_view.list_expected_spk_txids(&graph.index, ..)); From 276da3b97b90f213b3ae2bc24a681143bb4b3682 Mon Sep 17 00:00:00 2001 From: Leonardo Lima Date: Mon, 22 Sep 2025 22:04:33 -0300 Subject: [PATCH 03/15] refactor(chain)!: remove `canonical_iter` module - Adds `CanonicalReason`, `ObservedIn`, and `CanonicalizationParams` to `canonical_task.rs` module, instead of using the ones from `canonical_iter.rs`. - Removes the `canonical_iter.rs` file and its module declaration. BREAKING CHANGE: `CanonicalIter` and all its exports are removed --- crates/chain/src/canonical_iter.rs | 344 ----------------------------- crates/chain/src/canonical_task.rs | 106 ++++++++- crates/chain/src/lib.rs | 2 - 3 files changed, 102 insertions(+), 350 deletions(-) delete mode 100644 crates/chain/src/canonical_iter.rs diff --git a/crates/chain/src/canonical_iter.rs b/crates/chain/src/canonical_iter.rs deleted file mode 100644 index 204ead451..000000000 --- a/crates/chain/src/canonical_iter.rs +++ /dev/null @@ -1,344 +0,0 @@ -use crate::collections::{HashMap, HashSet, VecDeque}; -use crate::tx_graph::{TxAncestors, TxDescendants}; -use crate::{Anchor, ChainOracle, TxGraph}; -use alloc::boxed::Box; -use alloc::collections::BTreeSet; -use alloc::sync::Arc; -use alloc::vec::Vec; -use bdk_core::BlockId; -use bitcoin::{Transaction, Txid}; - -type CanonicalMap = HashMap, CanonicalReason)>; -type NotCanonicalSet = HashSet; - -/// Modifies the canonicalization algorithm. -#[derive(Debug, Default, Clone)] -pub struct CanonicalizationParams { - /// Transactions that will supercede all other transactions. - /// - /// In case of conflicting transactions within `assume_canonical`, transactions that appear - /// later in the list (have higher index) have precedence. - pub assume_canonical: Vec, -} - -/// Iterates over canonical txs. -pub struct CanonicalIter<'g, A, C> { - tx_graph: &'g TxGraph, - chain: &'g C, - chain_tip: BlockId, - - unprocessed_assumed_txs: Box)> + 'g>, - unprocessed_anchored_txs: - Box, &'g BTreeSet)> + 'g>, - unprocessed_seen_txs: Box, u64)> + 'g>, - unprocessed_leftover_txs: VecDeque<(Txid, Arc, u32)>, - - canonical: CanonicalMap, - not_canonical: NotCanonicalSet, - - queue: VecDeque, -} - -impl<'g, A: Anchor, C: ChainOracle> CanonicalIter<'g, A, C> { - /// Constructs [`CanonicalIter`]. - pub fn new( - tx_graph: &'g TxGraph, - chain: &'g C, - chain_tip: BlockId, - params: CanonicalizationParams, - ) -> Self { - let anchors = tx_graph.all_anchors(); - let unprocessed_assumed_txs = Box::new( - params - .assume_canonical - .into_iter() - .rev() - .filter_map(|txid| Some((txid, tx_graph.get_tx(txid)?))), - ); - let unprocessed_anchored_txs = Box::new( - tx_graph - .txids_by_descending_anchor_height() - .filter_map(|(_, txid)| Some((txid, tx_graph.get_tx(txid)?, anchors.get(&txid)?))), - ); - let unprocessed_seen_txs = Box::new( - tx_graph - .txids_by_descending_last_seen() - .filter_map(|(last_seen, txid)| Some((txid, tx_graph.get_tx(txid)?, last_seen))), - ); - Self { - tx_graph, - chain, - chain_tip, - unprocessed_assumed_txs, - unprocessed_anchored_txs, - unprocessed_seen_txs, - unprocessed_leftover_txs: VecDeque::new(), - canonical: HashMap::new(), - not_canonical: HashSet::new(), - queue: VecDeque::new(), - } - } - - /// Whether this transaction is already canonicalized. - fn is_canonicalized(&self, txid: Txid) -> bool { - self.canonical.contains_key(&txid) || self.not_canonical.contains(&txid) - } - - /// Mark transaction as canonical if it is anchored in the best chain. - fn scan_anchors( - &mut self, - txid: Txid, - tx: Arc, - anchors: &BTreeSet, - ) -> Result<(), C::Error> { - for anchor in anchors { - let in_chain_opt = self - .chain - .is_block_in_chain(anchor.anchor_block(), self.chain_tip)?; - if in_chain_opt == Some(true) { - self.mark_canonical(txid, tx, CanonicalReason::from_anchor(anchor.clone())); - return Ok(()); - } - } - // cannot determine - self.unprocessed_leftover_txs.push_back(( - txid, - tx, - anchors - .iter() - .last() - .expect( - "tx taken from `unprocessed_txs_with_anchors` so it must atleast have an anchor", - ) - .confirmation_height_upper_bound(), - )); - Ok(()) - } - - /// Marks `tx` and it's ancestors as canonical and mark all conflicts of these as - /// `not_canonical`. - /// - /// The exception is when it is discovered that `tx` double spends itself (i.e. two of it's - /// inputs conflict with each other), then no changes will be made. - /// - /// The logic works by having two loops where one is nested in another. - /// * The outer loop iterates through ancestors of `tx` (including `tx`). We can transitively - /// assume that all ancestors of `tx` are also canonical. - /// * The inner loop loops through conflicts of ancestors of `tx`. Any descendants of conflicts - /// are also conflicts and are transitively considered non-canonical. - /// - /// If the inner loop ends up marking `tx` as non-canonical, then we know that it double spends - /// itself. - fn mark_canonical(&mut self, txid: Txid, tx: Arc, reason: CanonicalReason) { - let starting_txid = txid; - let mut is_starting_tx = true; - - // We keep track of changes made so far so that we can undo it later in case we detect that - // `tx` double spends itself. - let mut detected_self_double_spend = false; - let mut undo_not_canonical = Vec::::new(); - - // `staged_queue` doubles as the `undo_canonical` data. - let staged_queue = TxAncestors::new_include_root( - self.tx_graph, - tx, - |_: usize, tx: Arc| -> Option { - let this_txid = tx.compute_txid(); - let this_reason = if is_starting_tx { - is_starting_tx = false; - reason.clone() - } else { - reason.to_transitive(starting_txid) - }; - - use crate::collections::hash_map::Entry; - let canonical_entry = match self.canonical.entry(this_txid) { - // Already visited tx before, exit early. - Entry::Occupied(_) => return None, - Entry::Vacant(entry) => entry, - }; - - // Any conflicts with a canonical tx can be added to `not_canonical`. Descendants - // of `not_canonical` txs can also be added to `not_canonical`. - for (_, conflict_txid) in self.tx_graph.direct_conflicts(&tx) { - TxDescendants::new_include_root( - self.tx_graph, - conflict_txid, - |_: usize, txid: Txid| -> Option<()> { - if self.not_canonical.insert(txid) { - undo_not_canonical.push(txid); - Some(()) - } else { - None - } - }, - ) - .run_until_finished() - } - - if self.not_canonical.contains(&this_txid) { - // Early exit if self-double-spend is detected. - detected_self_double_spend = true; - return None; - } - canonical_entry.insert((tx, this_reason)); - Some(this_txid) - }, - ) - .collect::>(); - - if detected_self_double_spend { - for txid in staged_queue { - self.canonical.remove(&txid); - } - for txid in undo_not_canonical { - self.not_canonical.remove(&txid); - } - } else { - self.queue.extend(staged_queue); - } - } -} - -impl Iterator for CanonicalIter<'_, A, C> { - type Item = Result<(Txid, Arc, CanonicalReason), C::Error>; - - fn next(&mut self) -> Option { - loop { - if let Some(txid) = self.queue.pop_front() { - let (tx, reason) = self - .canonical - .get(&txid) - .cloned() - .expect("reason must exist"); - return Some(Ok((txid, tx, reason))); - } - - if let Some((txid, tx)) = self.unprocessed_assumed_txs.next() { - if !self.is_canonicalized(txid) { - self.mark_canonical(txid, tx, CanonicalReason::assumed()); - } - } - - if let Some((txid, tx, anchors)) = self.unprocessed_anchored_txs.next() { - if !self.is_canonicalized(txid) { - if let Err(err) = self.scan_anchors(txid, tx, anchors) { - return Some(Err(err)); - } - } - continue; - } - - if let Some((txid, tx, last_seen)) = self.unprocessed_seen_txs.next() { - debug_assert!( - !tx.is_coinbase(), - "Coinbase txs must not have `last_seen` (in mempool) value" - ); - if !self.is_canonicalized(txid) { - let observed_in = ObservedIn::Mempool(last_seen); - self.mark_canonical(txid, tx, CanonicalReason::from_observed_in(observed_in)); - } - continue; - } - - if let Some((txid, tx, height)) = self.unprocessed_leftover_txs.pop_front() { - if !self.is_canonicalized(txid) && !tx.is_coinbase() { - let observed_in = ObservedIn::Block(height); - self.mark_canonical(txid, tx, CanonicalReason::from_observed_in(observed_in)); - } - continue; - } - - return None; - } - } -} - -/// Represents when and where a transaction was last observed in. -#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] -pub enum ObservedIn { - /// The transaction was last observed in a block of height. - Block(u32), - /// The transaction was last observed in the mempool at the given unix timestamp. - Mempool(u64), -} - -/// The reason why a transaction is canonical. -#[derive(Debug, Clone, PartialEq, Eq)] -pub enum CanonicalReason { - /// This transaction is explicitly assumed to be canonical by the caller, superceding all other - /// canonicalization rules. - Assumed { - /// Whether it is a descendant that is assumed to be canonical. - descendant: Option, - }, - /// This transaction is anchored in the best chain by `A`, and therefore canonical. - Anchor { - /// The anchor that anchored the transaction in the chain. - anchor: A, - /// Whether the anchor is of the transaction's descendant. - descendant: Option, - }, - /// This transaction does not conflict with any other transaction with a more recent - /// [`ObservedIn`] value or one that is anchored in the best chain. - ObservedIn { - /// The [`ObservedIn`] value of the transaction. - observed_in: ObservedIn, - /// Whether the [`ObservedIn`] value is of the transaction's descendant. - descendant: Option, - }, -} - -impl CanonicalReason { - /// Constructs a [`CanonicalReason`] for a transaction that is assumed to supercede all other - /// transactions. - pub fn assumed() -> Self { - Self::Assumed { descendant: None } - } - - /// Constructs a [`CanonicalReason`] from an `anchor`. - pub fn from_anchor(anchor: A) -> Self { - Self::Anchor { - anchor, - descendant: None, - } - } - - /// Constructs a [`CanonicalReason`] from an `observed_in` value. - pub fn from_observed_in(observed_in: ObservedIn) -> Self { - Self::ObservedIn { - observed_in, - descendant: None, - } - } - - /// Contruct a new [`CanonicalReason`] from the original which is transitive to `descendant`. - /// - /// This signals that either the [`ObservedIn`] or [`Anchor`] value belongs to the transaction's - /// descendant, but is transitively relevant. - pub fn to_transitive(&self, descendant: Txid) -> Self { - match self { - CanonicalReason::Assumed { .. } => Self::Assumed { - descendant: Some(descendant), - }, - CanonicalReason::Anchor { anchor, .. } => Self::Anchor { - anchor: anchor.clone(), - descendant: Some(descendant), - }, - CanonicalReason::ObservedIn { observed_in, .. } => Self::ObservedIn { - observed_in: *observed_in, - descendant: Some(descendant), - }, - } - } - - /// This signals that either the [`ObservedIn`] or [`Anchor`] value belongs to the transaction's - /// descendant. - pub fn descendant(&self) -> &Option { - match self { - CanonicalReason::Assumed { descendant, .. } => descendant, - CanonicalReason::Anchor { descendant, .. } => descendant, - CanonicalReason::ObservedIn { descendant, .. } => descendant, - } - } -} diff --git a/crates/chain/src/canonical_task.rs b/crates/chain/src/canonical_task.rs index b6ab3e4f3..2686bc81c 100644 --- a/crates/chain/src/canonical_task.rs +++ b/crates/chain/src/canonical_task.rs @@ -1,4 +1,3 @@ -use crate::canonical_iter::{CanonicalReason, CanonicalizationParams, ObservedIn}; use crate::collections::{HashMap, HashSet, VecDeque}; use crate::tx_graph::{TxAncestors, TxDescendants}; use crate::{Anchor, CanonicalView, ChainPosition, TxGraph}; @@ -9,6 +8,19 @@ use alloc::vec::Vec; use bdk_core::BlockId; use bitcoin::{Transaction, Txid}; +type CanonicalMap = HashMap, CanonicalReason)>; +type NotCanonicalSet = HashSet; + +/// Modifies the canonicalization algorithm. +#[derive(Debug, Default, Clone)] +pub struct CanonicalizationParams { + /// Transactions that will 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, +} + /// A request to check which anchors are confirmed in the chain. #[derive(Debug, Clone, PartialEq, Eq)] pub struct CanonicalizationRequest { @@ -19,9 +31,6 @@ pub struct CanonicalizationRequest { /// Response containing the best confirmed anchor, if any. pub type CanonicalizationResponse = Option; -type CanonicalMap = HashMap, CanonicalReason)>; -type NotCanonicalSet = HashSet; - /// Manages the canonicalization process without direct I/O operations. pub struct CanonicalizationTask<'g, A> { tx_graph: &'g TxGraph, @@ -369,6 +378,95 @@ impl<'g, A: Anchor> CanonicalizationTask<'g, A> { } } +/// Represents when and where a transaction was last observed in. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] +pub enum ObservedIn { + /// The transaction was last observed in a block of height. + Block(u32), + /// The transaction was last observed in the mempool at the given unix timestamp. + Mempool(u64), +} + +/// The reason why a transaction is canonical. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum CanonicalReason { + /// This transaction is explicitly assumed to be canonical by the caller, superceding all other + /// canonicalization rules. + Assumed { + /// Whether it is a descendant that is assumed to be canonical. + descendant: Option, + }, + /// This transaction is anchored in the best chain by `A`, and therefore canonical. + Anchor { + /// The anchor that anchored the transaction in the chain. + anchor: A, + /// Whether the anchor is of the transaction's descendant. + descendant: Option, + }, + /// This transaction does not conflict with any other transaction with a more recent + /// [`ObservedIn`] value or one that is anchored in the best chain. + ObservedIn { + /// The [`ObservedIn`] value of the transaction. + observed_in: ObservedIn, + /// Whether the [`ObservedIn`] value is of the transaction's descendant. + descendant: Option, + }, +} + +impl CanonicalReason { + /// Constructs a [`CanonicalReason`] for a transaction that is assumed to supercede all other + /// transactions. + pub fn assumed() -> Self { + Self::Assumed { descendant: None } + } + + /// Constructs a [`CanonicalReason`] from an `anchor`. + pub fn from_anchor(anchor: A) -> Self { + Self::Anchor { + anchor, + descendant: None, + } + } + + /// Constructs a [`CanonicalReason`] from an `observed_in` value. + pub fn from_observed_in(observed_in: ObservedIn) -> Self { + Self::ObservedIn { + observed_in, + descendant: None, + } + } + + /// Contruct a new [`CanonicalReason`] from the original which is transitive to `descendant`. + /// + /// This signals that either the [`ObservedIn`] or [`Anchor`] value belongs to the transaction's + /// descendant, but is transitively relevant. + pub fn to_transitive(&self, descendant: Txid) -> Self { + match self { + CanonicalReason::Assumed { .. } => Self::Assumed { + descendant: Some(descendant), + }, + CanonicalReason::Anchor { anchor, .. } => Self::Anchor { + anchor: anchor.clone(), + descendant: Some(descendant), + }, + CanonicalReason::ObservedIn { observed_in, .. } => Self::ObservedIn { + observed_in: *observed_in, + descendant: Some(descendant), + }, + } + } + + /// This signals that either the [`ObservedIn`] or [`Anchor`] value belongs to the transaction's + /// descendant. + pub fn descendant(&self) -> &Option { + match self { + CanonicalReason::Assumed { descendant, .. } => descendant, + CanonicalReason::Anchor { descendant, .. } => descendant, + CanonicalReason::ObservedIn { descendant, .. } => descendant, + } + } +} + #[cfg(test)] mod tests { use super::*; diff --git a/crates/chain/src/lib.rs b/crates/chain/src/lib.rs index 3d9b5a7cf..2e0a83c27 100644 --- a/crates/chain/src/lib.rs +++ b/crates/chain/src/lib.rs @@ -44,8 +44,6 @@ pub mod tx_graph; pub use tx_graph::TxGraph; mod chain_oracle; pub use chain_oracle::*; -mod canonical_iter; -pub use canonical_iter::*; mod canonical_task; pub use canonical_task::*; mod canonical_view; From e034e90ec18cf60dfd41cba4a82d7a0846abfd17 Mon Sep 17 00:00:00 2001 From: Leonardo Lima Date: Thu, 25 Sep 2025 02:48:26 -0300 Subject: [PATCH 04/15] refactor(core,chain)!: extract generic `ChainQuery` trait from `CanonicalizationTask` Introduce a new `ChainQuery` trait in `bdk_core` that provides an interface for query-based operations against blockchain data. This trait enables sans-IO patterns for algorithms that need to interact with blockchain oracles without directly performing I/O. The `CanonicalizationTask` now implements this trait, making it more composable and allowing the query pattern to be reused for other blockchain query operations. - Add `ChainQuery` trait with associated types for Request, Response, Context, and Result - Implement `ChainQuery` for `CanonicalizationTask` with `BlockId` as context BREAKING CHANGE: `CanonicalizationTask::finish()` now requires a `BlockId` parameter Co-Authored-By: Claude --- crates/chain/src/canonical_task.rs | 111 +++++++++++++++-------------- crates/chain/src/local_chain.rs | 2 +- crates/core/src/chain_query.rs | 65 +++++++++++++++++ crates/core/src/lib.rs | 3 + 4 files changed, 126 insertions(+), 55 deletions(-) create mode 100644 crates/core/src/chain_query.rs diff --git a/crates/chain/src/canonical_task.rs b/crates/chain/src/canonical_task.rs index 2686bc81c..ca4f3d543 100644 --- a/crates/chain/src/canonical_task.rs +++ b/crates/chain/src/canonical_task.rs @@ -5,7 +5,7 @@ use alloc::boxed::Box; use alloc::collections::BTreeSet; use alloc::sync::Arc; use alloc::vec::Vec; -use bdk_core::BlockId; +use bdk_core::{BlockId, ChainQuery}; use bitcoin::{Transaction, Txid}; type CanonicalMap = HashMap, CanonicalReason)>; @@ -53,53 +53,13 @@ pub struct CanonicalizationTask<'g, A> { confirmed_anchors: HashMap, } -impl<'g, A: Anchor> CanonicalizationTask<'g, A> { - /// Creates a new canonicalization task. - pub fn new(tx_graph: &'g TxGraph, params: CanonicalizationParams) -> Self { - let anchors = tx_graph.all_anchors(); - let unprocessed_assumed_txs = Box::new( - params - .assume_canonical - .into_iter() - .rev() - .filter_map(|txid| Some((txid, tx_graph.get_tx(txid)?))), - ); - let unprocessed_anchored_txs = Box::new( - tx_graph - .txids_by_descending_anchor_height() - .filter_map(|(_, txid)| Some((txid, tx_graph.get_tx(txid)?, anchors.get(&txid)?))), - ); - let unprocessed_seen_txs = Box::new( - tx_graph - .txids_by_descending_last_seen() - .filter_map(|(last_seen, txid)| Some((txid, tx_graph.get_tx(txid)?, last_seen))), - ); +impl<'g, A: Anchor> ChainQuery for CanonicalizationTask<'g, A> { + type Request = CanonicalizationRequest; + type Response = CanonicalizationResponse; + type Context = BlockId; + type Result = CanonicalView; - let mut task = Self { - tx_graph, - - unprocessed_assumed_txs, - unprocessed_anchored_txs, - unprocessed_seen_txs, - unprocessed_leftover_txs: VecDeque::new(), - - canonical: HashMap::new(), - not_canonical: HashSet::new(), - - pending_anchor_checks: VecDeque::new(), - - canonical_order: Vec::new(), - confirmed_anchors: HashMap::new(), - }; - - // process assumed transactions first (they don't need queries) - task.process_assumed_txs(); - - task - } - - /// Returns the next query needed, if any. - pub fn next_query(&mut self) -> Option> { + fn next_query(&mut self) -> Option { // Check if we have pending anchor checks if let Some((_, _, anchors)) = self.pending_anchor_checks.front() { return Some(CanonicalizationRequest { @@ -111,8 +71,7 @@ impl<'g, A: Anchor> CanonicalizationTask<'g, A> { self.process_anchored_txs() } - /// Resolves a query with the given response. - pub fn resolve_query(&mut self, response: CanonicalizationResponse) { + fn resolve_query(&mut self, response: Self::Response) { if let Some((txid, tx, anchors)) = self.pending_anchor_checks.pop_front() { match response { Some(best_anchor) => { @@ -138,13 +97,11 @@ impl<'g, A: Anchor> CanonicalizationTask<'g, A> { } } - /// Returns true if the canonicalization process is complete. - pub fn is_finished(&self) -> bool { + fn is_finished(&mut self) -> bool { self.pending_anchor_checks.is_empty() && self.unprocessed_anchored_txs.size_hint().0 == 0 } - /// Completes the canonicalization and returns a CanonicalView. - pub fn finish(mut self, chain_tip: BlockId) -> CanonicalView { + fn finish(mut self, context: Self::Context) -> Self::Result { // Process remaining transactions (seen and leftover) self.process_seen_txs(); self.process_leftover_txs(); @@ -224,7 +181,53 @@ impl<'g, A: Anchor> CanonicalizationTask<'g, A> { } } - CanonicalView::new(chain_tip, view_order, view_txs, view_spends) + CanonicalView::new(context, 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, params: CanonicalizationParams) -> Self { + let anchors = tx_graph.all_anchors(); + let unprocessed_assumed_txs = Box::new( + params + .assume_canonical + .into_iter() + .rev() + .filter_map(|txid| Some((txid, tx_graph.get_tx(txid)?))), + ); + let unprocessed_anchored_txs = Box::new( + tx_graph + .txids_by_descending_anchor_height() + .filter_map(|(_, txid)| Some((txid, tx_graph.get_tx(txid)?, anchors.get(&txid)?))), + ); + let unprocessed_seen_txs = Box::new( + tx_graph + .txids_by_descending_last_seen() + .filter_map(|(last_seen, txid)| Some((txid, tx_graph.get_tx(txid)?, last_seen))), + ); + + let mut task = Self { + tx_graph, + + unprocessed_assumed_txs, + unprocessed_anchored_txs, + unprocessed_seen_txs, + unprocessed_leftover_txs: VecDeque::new(), + + canonical: HashMap::new(), + not_canonical: HashSet::new(), + + pending_anchor_checks: VecDeque::new(), + + canonical_order: Vec::new(), + confirmed_anchors: HashMap::new(), + }; + + // process assumed transactions first (they don't need queries) + task.process_assumed_txs(); + + task } fn is_canonicalized(&self, txid: Txid) -> bool { diff --git a/crates/chain/src/local_chain.rs b/crates/chain/src/local_chain.rs index bf932d068..7a095dea0 100644 --- a/crates/chain/src/local_chain.rs +++ b/crates/chain/src/local_chain.rs @@ -7,7 +7,7 @@ use core::ops::RangeBounds; use crate::canonical_task::CanonicalizationTask; use crate::collections::BTreeMap; use crate::{Anchor, BlockId, CanonicalView, ChainOracle, Merge}; -use bdk_core::ToBlockHash; +use bdk_core::{ChainQuery, ToBlockHash}; pub use bdk_core::{CheckPoint, CheckPointIter}; use bitcoin::block::Header; use bitcoin::BlockHash; diff --git a/crates/core/src/chain_query.rs b/crates/core/src/chain_query.rs new file mode 100644 index 000000000..478b9b3e4 --- /dev/null +++ b/crates/core/src/chain_query.rs @@ -0,0 +1,65 @@ +//! Generic trait for query-based operations that require external blockchain data. +//! +//! The [`ChainQuery`] trait provides a standardized interface for implementing +//! algorithms that need to make queries to blockchain sources and process responses +//! in a sans-IO manner. + +/// A trait for types that perform query-based operations against blockchain data. +/// +/// This trait enables types to request blockchain information via queries and process +/// responses in a decoupled, sans-IO manner. It's particularly useful for algorithms +/// that need to interact with blockchain oracles, chain sources, or other blockchain +/// data providers without directly performing I/O. +/// +/// # Type Parameters +/// +/// * `Request` - The type of query request that can be made +/// * `Response` - The type of response expected for queries +/// * `Context` - The type of context needed for finalization (e.g., `BlockId` for chain tip) +/// * `Result` - The final result type produced when the query process is complete +pub trait ChainQuery { + /// The type of query request that can be made. + type Request; + + /// The type of response expected for queries. + type Response; + + /// The type of context needed for finalization. + /// + /// This could be `BlockId` for algorithms needing chain tip information, + /// `()` for algorithms that don't need additional context, or any other + /// type specific to the implementation's needs. + type Context; + + /// The final result type produced when the query process is complete. + type Result; + + /// Returns the next query needed, if any. + /// + /// This method should return `Some(request)` if more information is needed, + /// or `None` if no more queries are required. + fn next_query(&mut self) -> Option; + + /// Resolves a query with the given response. + /// + /// This method processes the response to a previous query request and updates + /// the internal state accordingly. + fn resolve_query(&mut self, response: Self::Response); + + /// Returns true if the query process is complete and ready to finish. + /// + /// The default implementation returns `true` when there are no more queries needed. + /// Implementors can override this for more specific behavior if needed. + fn is_finished(&mut self) -> bool { + self.next_query().is_none() + } + + /// Completes the query process and returns the final result. + /// + /// This method should be called when `is_finished` returns `true`. + /// It consumes `self` and produces the final result. + /// + /// The `context` parameter provides implementation-specific context + /// needed for finalization. + fn finish(self, context: Self::Context) -> Self::Result; +} diff --git a/crates/core/src/lib.rs b/crates/core/src/lib.rs index 95bebe907..33e921687 100644 --- a/crates/core/src/lib.rs +++ b/crates/core/src/lib.rs @@ -72,3 +72,6 @@ mod merge; pub use merge::*; pub mod spk_client; + +mod chain_query; +pub use chain_query::*; From 0d4a64a6299ca612bf570725835fe49949017b0d Mon Sep 17 00:00:00 2001 From: Leonardo Lima Date: Thu, 25 Sep 2025 04:28:43 -0300 Subject: [PATCH 05/15] refactor(chain)!: generalize `ChainQuery` trait with generic type Make `ChainRequest`/`ChainResponse` generic over block identifier types to enable reuse beyond BlockId. Move `chain_tip` into `ChainRequest` for better encapsulation and simpler API. - Make `ChainRequest` and `ChainResponse` generic types with `BlockId` as default - Add `chain_tip` field to `ChainRequest` to make it self-contained - Change `ChainQuery` trait to use generic parameter `B` for block identifier type - Remove `chain_tip` parameter from `LocalChain::canonicalize()` method - Rename `ChainQuery::Result` to `ChainQuery::Output` for clarity BREAKING CHANGE: - `ChainRequest` now has a `chain_tip` field and is generic over block identifier type - `ChainResponse` is now generic with default type parameter `BlockId` - `ChainQuery` trait now takes a generic parameter `B = BlockId` - `LocalChain::canonicalize()` no longer takes a `chain_tip` parameter Co-authored-by: Claude refactor(chain): make `LocalChain::canonicalize()` generic over `ChainQuery` Allow any type implementing `ChainQuery` trait instead of requiring `CanonicalizationTask` specifically. Signed-off-by: Leonardo Lima --- crates/bitcoind_rpc/examples/filter_iter.rs | 5 +- crates/bitcoind_rpc/tests/test_emitter.rs | 12 ++-- crates/chain/benches/canonicalization.rs | 15 +++-- crates/chain/benches/indexer.rs | 6 +- crates/chain/src/canonical_task.rs | 56 ++++++++++-------- crates/chain/src/canonical_view.rs | 25 ++++---- crates/chain/src/indexed_tx_graph.rs | 3 +- crates/chain/src/local_chain.rs | 42 ++++++------- crates/chain/src/tx_graph.rs | 7 ++- crates/chain/tests/test_canonical_view.rs | 14 +++-- crates/chain/tests/test_indexed_tx_graph.rs | 32 +++++----- crates/chain/tests/test_tx_graph.rs | 32 +++++----- crates/chain/tests/test_tx_graph_conflicts.rs | 16 ++--- crates/core/src/chain_query.rs | 59 ++++++++++--------- crates/electrum/tests/test_electrum.rs | 14 +++-- crates/esplora/tests/async_ext.rs | 10 ++-- crates/esplora/tests/blocking_ext.rs | 10 ++-- .../example_bitcoind_rpc_polling/src/main.rs | 30 ++++++---- examples/example_cli/src/lib.rs | 23 ++++---- examples/example_electrum/src/main.rs | 5 +- examples/example_esplora/src/main.rs | 5 +- 21 files changed, 222 insertions(+), 199 deletions(-) diff --git a/crates/bitcoind_rpc/examples/filter_iter.rs b/crates/bitcoind_rpc/examples/filter_iter.rs index 0cafb2599..b7df2a02d 100644 --- a/crates/bitcoind_rpc/examples/filter_iter.rs +++ b/crates/bitcoind_rpc/examples/filter_iter.rs @@ -69,8 +69,9 @@ fn main() -> anyhow::Result<()> { println!("\ntook: {}s", start.elapsed().as_secs()); println!("Local tip: {}", chain.tip().height()); - let task = graph.canonicalization_task(Default::default()); - let canonical_view = chain.canonicalize(task, Some(chain.tip().block_id())); + let chain_tip = chain.tip().block_id(); + let task = graph.canonicalization_task(chain_tip, Default::default()); + let canonical_view = chain.canonicalize(task); let unspent: Vec<_> = canonical_view .filter_unspent_outpoints(graph.index.outpoints().clone()) diff --git a/crates/bitcoind_rpc/tests/test_emitter.rs b/crates/bitcoind_rpc/tests/test_emitter.rs index 84899455e..1077dd1dc 100644 --- a/crates/bitcoind_rpc/tests/test_emitter.rs +++ b/crates/bitcoind_rpc/tests/test_emitter.rs @@ -322,9 +322,9 @@ fn get_balance( let outpoints = recv_graph.index.outpoints().clone(); let task = recv_graph .graph() - .canonicalization_task(CanonicalizationParams::default()); + .canonicalization_task(chain_tip, CanonicalizationParams::default()); let balance = recv_chain - .canonicalize(task, Some(chain_tip)) + .canonicalize(task) .balance(outpoints, |_, _| true, 0); Ok(balance) } @@ -637,9 +637,9 @@ fn test_expect_tx_evicted() -> anyhow::Result<()> { let _txid_2 = core.send_raw_transaction(&tx1b)?; // Retrieve the expected unconfirmed txids and spks from the graph. - let task = graph.canonicalization_task(Default::default()); + let task = graph.canonicalization_task(chain_tip, Default::default()); let exp_spk_txids = chain - .canonicalize(task, Some(chain_tip)) + .canonicalize(task) .list_expected_spk_txids(&graph.index, ..) .collect::>(); assert_eq!(exp_spk_txids, vec![(spk, txid_1)]); @@ -656,9 +656,9 @@ fn test_expect_tx_evicted() -> anyhow::Result<()> { let task = graph .graph() - .canonicalization_task(CanonicalizationParams::default()); + .canonicalization_task(chain_tip, CanonicalizationParams::default()); let canonical_txids = chain - .canonicalize(task, Some(chain_tip)) + .canonicalize(task) .txs() .map(|tx| tx.txid) .collect::>(); diff --git a/crates/chain/benches/canonicalization.rs b/crates/chain/benches/canonicalization.rs index 78cf69b93..8a5d068ae 100644 --- a/crates/chain/benches/canonicalization.rs +++ b/crates/chain/benches/canonicalization.rs @@ -95,28 +95,31 @@ fn setup(f: F) -> (KeychainTxGraph, Lo } fn run_list_canonical_txs(tx_graph: &KeychainTxGraph, chain: &LocalChain, exp_txs: usize) { + let chain_tip = chain.tip().block_id(); let task = tx_graph .graph() - .canonicalization_task(CanonicalizationParams::default()); - let view = chain.canonicalize(task, Some(chain.tip().block_id())); + .canonicalization_task(chain_tip, CanonicalizationParams::default()); + let view = chain.canonicalize(task); let txs = view.txs(); assert_eq!(txs.count(), exp_txs); } fn run_filter_chain_txouts(tx_graph: &KeychainTxGraph, chain: &LocalChain, exp_txos: usize) { + let chain_tip = chain.tip().block_id(); let task = tx_graph .graph() - .canonicalization_task(CanonicalizationParams::default()); - let view = chain.canonicalize(task, Some(chain.tip().block_id())); + .canonicalization_task(chain_tip, CanonicalizationParams::default()); + let view = chain.canonicalize(task); let utxos = view.filter_outpoints(tx_graph.index.outpoints().clone()); assert_eq!(utxos.count(), exp_txos); } fn run_filter_chain_unspents(tx_graph: &KeychainTxGraph, chain: &LocalChain, exp_utxos: usize) { + let chain_tip = chain.tip().block_id(); let task = tx_graph .graph() - .canonicalization_task(CanonicalizationParams::default()); - let view = chain.canonicalize(task, Some(chain.tip().block_id())); + .canonicalization_task(chain_tip, CanonicalizationParams::default()); + let view = chain.canonicalize(task); let utxos = view.filter_unspent_outpoints(tx_graph.index.outpoints().clone()); assert_eq!(utxos.count(), exp_utxos); } diff --git a/crates/chain/benches/indexer.rs b/crates/chain/benches/indexer.rs index a1144e3f9..b05200b20 100644 --- a/crates/chain/benches/indexer.rs +++ b/crates/chain/benches/indexer.rs @@ -86,10 +86,8 @@ fn do_bench(indexed_tx_graph: &KeychainTxGraph, chain: &LocalChain) { let op = graph.index.outpoints().clone(); let task = graph .graph() - .canonicalization_task(CanonicalizationParams::default()); - let bal = chain - .canonicalize(task, Some(chain_tip)) - .balance(op, |_, _| false, 1); + .canonicalization_task(chain_tip, CanonicalizationParams::default()); + let bal = chain.canonicalize(task).balance(op, |_, _| false, 1); assert_eq!(bal.total(), AMOUNT * TX_CT as u64); } diff --git a/crates/chain/src/canonical_task.rs b/crates/chain/src/canonical_task.rs index ca4f3d543..516a2e11f 100644 --- a/crates/chain/src/canonical_task.rs +++ b/crates/chain/src/canonical_task.rs @@ -5,7 +5,7 @@ use alloc::boxed::Box; use alloc::collections::BTreeSet; use alloc::sync::Arc; use alloc::vec::Vec; -use bdk_core::{BlockId, ChainQuery}; +use bdk_core::{BlockId, ChainQuery, ChainRequest, ChainResponse}; use bitcoin::{Transaction, Txid}; type CanonicalMap = HashMap, CanonicalReason)>; @@ -21,19 +21,10 @@ pub struct CanonicalizationParams { pub assume_canonical: Vec, } -/// A request to check which anchors are confirmed in the chain. -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct CanonicalizationRequest { - /// The anchors to check. - pub anchors: Vec, -} - -/// Response containing the best confirmed anchor, if any. -pub type CanonicalizationResponse = Option; - /// Manages the canonicalization process without direct I/O operations. pub struct CanonicalizationTask<'g, A> { tx_graph: &'g TxGraph, + chain_tip: BlockId, unprocessed_assumed_txs: Box)> + 'g>, unprocessed_anchored_txs: @@ -54,16 +45,16 @@ pub struct CanonicalizationTask<'g, A> { } impl<'g, A: Anchor> ChainQuery for CanonicalizationTask<'g, A> { - type Request = CanonicalizationRequest; - type Response = CanonicalizationResponse; - type Context = BlockId; - type Result = CanonicalView; + type Output = CanonicalView; - fn next_query(&mut self) -> Option { + fn next_query(&mut self) -> Option { // Check if we have pending anchor checks if let Some((_, _, anchors)) = self.pending_anchor_checks.front() { - return Some(CanonicalizationRequest { - anchors: anchors.clone(), + // Convert anchors to BlockIds for the ChainRequest + let block_ids = anchors.iter().map(|anchor| anchor.anchor_block()).collect(); + return Some(ChainRequest { + chain_tip: self.chain_tip, + block_ids, }); } @@ -71,9 +62,17 @@ impl<'g, A: Anchor> ChainQuery for CanonicalizationTask<'g, A> { self.process_anchored_txs() } - fn resolve_query(&mut self, response: Self::Response) { + fn resolve_query(&mut self, response: ChainResponse) { if let Some((txid, tx, anchors)) = self.pending_anchor_checks.pop_front() { - match response { + // 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) => { self.confirmed_anchors.insert(txid, best_anchor.clone()); if !self.is_canonicalized(txid) { @@ -101,7 +100,7 @@ impl<'g, A: Anchor> ChainQuery for CanonicalizationTask<'g, A> { self.pending_anchor_checks.is_empty() && self.unprocessed_anchored_txs.size_hint().0 == 0 } - fn finish(mut self, context: Self::Context) -> Self::Result { + fn finish(mut self) -> Self::Output { // Process remaining transactions (seen and leftover) self.process_seen_txs(); self.process_leftover_txs(); @@ -181,13 +180,17 @@ impl<'g, A: Anchor> ChainQuery for CanonicalizationTask<'g, A> { } } - CanonicalView::new(context, view_order, view_txs, view_spends) + CanonicalView::new(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, params: CanonicalizationParams) -> Self { + pub fn new( + tx_graph: &'g TxGraph, + chain_tip: BlockId, + params: CanonicalizationParams, + ) -> Self { let anchors = tx_graph.all_anchors(); let unprocessed_assumed_txs = Box::new( params @@ -209,6 +212,7 @@ impl<'g, A: Anchor> CanonicalizationTask<'g, A> { let mut task = Self { tx_graph, + chain_tip, unprocessed_assumed_txs, unprocessed_anchored_txs, @@ -242,7 +246,7 @@ impl<'g, A: Anchor> CanonicalizationTask<'g, A> { } } - fn process_anchored_txs(&mut self) -> Option> { + fn process_anchored_txs(&mut self) -> Option { while let Some((txid, tx, anchors)) = self.unprocessed_anchored_txs.next() { if !self.is_canonicalized(txid) { self.pending_anchor_checks @@ -512,8 +516,8 @@ mod tests { // Create canonicalization task and canonicalize using the chain let params = CanonicalizationParams::default(); - let task = CanonicalizationTask::new(&tx_graph, params); - let canonical_view = chain.canonicalize(task, Some(chain_tip)); + 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); diff --git a/crates/chain/src/canonical_view.rs b/crates/chain/src/canonical_view.rs index 228b15454..b89e9efe1 100644 --- a/crates/chain/src/canonical_view.rs +++ b/crates/chain/src/canonical_view.rs @@ -11,9 +11,10 @@ //! # use bitcoin::hashes::Hash; //! # let tx_graph = TxGraph::::default(); //! # let chain = LocalChain::from_blocks([(0, bitcoin::BlockHash::all_zeros())].into_iter().collect()).unwrap(); +//! let chain_tip = chain.tip().block_id(); //! let params = CanonicalizationParams::default(); -//! let task = CanonicalizationTask::new(&tx_graph, params); -//! let view = chain.canonicalize(task, Some(chain.tip().block_id())); +//! let task = CanonicalizationTask::new(&tx_graph, chain_tip, params); +//! let view = chain.canonicalize(task); //! //! // Iterate over canonical transactions //! for tx in view.txs() { @@ -158,8 +159,9 @@ impl CanonicalView { /// # use bitcoin::hashes::Hash; /// # let tx_graph = TxGraph::::default(); /// # let chain = LocalChain::from_blocks([(0, bitcoin::BlockHash::all_zeros())].into_iter().collect()).unwrap(); - /// # let task = CanonicalizationTask::new(&tx_graph, Default::default()); - /// # let view = chain.canonicalize(task, Some(chain.tip().block_id())); + /// # let chain_tip = chain.tip().block_id(); + /// # let task = CanonicalizationTask::new(&tx_graph, chain_tip, Default::default()); + /// # let view = chain.canonicalize(task); /// // Iterate over all canonical transactions /// for tx in view.txs() { /// println!("TX {}: {:?}", tx.txid, tx.pos); @@ -192,8 +194,9 @@ impl CanonicalView { /// # use bitcoin::hashes::Hash; /// # let tx_graph = TxGraph::::default(); /// # let chain = LocalChain::from_blocks([(0, bitcoin::BlockHash::all_zeros())].into_iter().collect()).unwrap(); - /// # let task = CanonicalizationTask::new(&tx_graph, Default::default()); - /// # let view = chain.canonicalize(task, Some(chain.tip().block_id())); + /// # let chain_tip = chain.tip().block_id(); + /// # let task = CanonicalizationTask::new(&tx_graph, chain_tip, Default::default()); + /// # let view = chain.canonicalize(task); /// # let indexer = KeychainTxOutIndex::<&str>::default(); /// // Get all outputs from an indexer /// for (keychain, txout) in view.filter_outpoints(indexer.outpoints().clone()) { @@ -222,8 +225,9 @@ impl CanonicalView { /// # use bitcoin::hashes::Hash; /// # let tx_graph = TxGraph::::default(); /// # let chain = LocalChain::from_blocks([(0, bitcoin::BlockHash::all_zeros())].into_iter().collect()).unwrap(); - /// # let task = CanonicalizationTask::new(&tx_graph, Default::default()); - /// # let view = chain.canonicalize(task, Some(chain.tip().block_id())); + /// # let chain_tip = chain.tip().block_id(); + /// # let task = CanonicalizationTask::new(&tx_graph, chain_tip, Default::default()); + /// # let view = chain.canonicalize(task); /// # let indexer = KeychainTxOutIndex::<&str>::default(); /// // Get unspent outputs (UTXOs) from an indexer /// for (keychain, utxo) in view.filter_unspent_outpoints(indexer.outpoints().clone()) { @@ -269,8 +273,9 @@ impl CanonicalView { /// # use bitcoin::hashes::Hash; /// # let tx_graph = TxGraph::::default(); /// # let chain = LocalChain::from_blocks([(0, bitcoin::BlockHash::all_zeros())].into_iter().collect()).unwrap(); - /// # let task = CanonicalizationTask::new(&tx_graph, Default::default()); - /// # let view = chain.canonicalize(task, Some(chain.tip().block_id())); + /// # let chain_tip = chain.tip().block_id(); + /// # let task = CanonicalizationTask::new(&tx_graph, chain_tip, Default::default()); + /// # let view = chain.canonicalize(task); /// # let indexer = KeychainTxOutIndex::<&str>::default(); /// // Calculate balance with 6 confirmations, trusting all outputs /// let balance = view.balance( diff --git a/crates/chain/src/indexed_tx_graph.rs b/crates/chain/src/indexed_tx_graph.rs index bcb60c4a9..0745d6132 100644 --- a/crates/chain/src/indexed_tx_graph.rs +++ b/crates/chain/src/indexed_tx_graph.rs @@ -440,9 +440,10 @@ where /// for anchor verification requests. pub fn canonicalization_task( &'_ self, + chain_tip: BlockId, params: CanonicalizationParams, ) -> CanonicalizationTask<'_, A> { - self.graph.canonicalization_task(params) + self.graph.canonicalization_task(chain_tip, params) } } diff --git a/crates/chain/src/local_chain.rs b/crates/chain/src/local_chain.rs index 7a095dea0..c3dc81101 100644 --- a/crates/chain/src/local_chain.rs +++ b/crates/chain/src/local_chain.rs @@ -4,9 +4,8 @@ use core::convert::Infallible; use core::fmt; use core::ops::RangeBounds; -use crate::canonical_task::CanonicalizationTask; use crate::collections::BTreeMap; -use crate::{Anchor, BlockId, CanonicalView, ChainOracle, Merge}; +use crate::{BlockId, ChainOracle, Merge}; use bdk_core::{ChainQuery, ToBlockHash}; pub use bdk_core::{CheckPoint, CheckPointIter}; use bitcoin::block::Header; @@ -129,8 +128,8 @@ impl LocalChain { /// Canonicalize a transaction graph using this chain. /// - /// This method processes a [`CanonicalizationTask`], handling all its requests - /// to determine which transactions are canonical, and returns a [`CanonicalView`]. + /// This method processes any type implementing [`ChainQuery`], handling all its requests + /// to determine which transactions are canonical, and returns the query's output. /// /// # Example /// @@ -140,38 +139,35 @@ impl LocalChain { /// # use bitcoin::hashes::Hash; /// # let tx_graph: TxGraph = TxGraph::default(); /// # let chain = LocalChain::from_blocks([(0, bitcoin::BlockHash::all_zeros())].into_iter().collect()).unwrap(); - /// let task = CanonicalizationTask::new(&tx_graph, CanonicalizationParams::default()); - /// let view = chain.canonicalize(task, Some(chain.tip().block_id())); + /// let chain_tip = chain.tip().block_id(); + /// let task = CanonicalizationTask::new(&tx_graph, chain_tip, CanonicalizationParams::default()); + /// let view = chain.canonicalize(task); /// ``` - pub fn canonicalize( - &self, - mut task: CanonicalizationTask<'_, A>, - chain_tip: Option, - ) -> CanonicalView { - let chain_tip = match chain_tip { - Some(chain_tip) => chain_tip, - None => self.get_chain_tip().expect("infallible"), - }; - + pub fn canonicalize(&self, mut task: Q) -> Q::Output + where + Q: ChainQuery, + { // Process all requests from the task while let Some(request) = task.next_query() { - // Check each anchor and return the first confirmed one - let mut best_anchor = None; - for anchor in &request.anchors { + let chain_tip = request.chain_tip; + + // Check each block ID and return the first confirmed one + let mut best_block_id = None; + for block_id in &request.block_ids { if self - .is_block_in_chain(anchor.anchor_block(), chain_tip) + .is_block_in_chain(*block_id, chain_tip) .expect("infallible") == Some(true) { - best_anchor = Some(anchor.clone()); + best_block_id = Some(*block_id); break; } } - task.resolve_query(best_anchor); + task.resolve_query(best_block_id); } // Return the finished canonical view - task.finish(chain_tip) + task.finish() } /// Update the chain with a given [`Header`] at `height` which you claim is connected to a diff --git a/crates/chain/src/tx_graph.rs b/crates/chain/src/tx_graph.rs index 74016688b..6de24e3cf 100644 --- a/crates/chain/src/tx_graph.rs +++ b/crates/chain/src/tx_graph.rs @@ -29,7 +29,7 @@ //! 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, Some(chain_tip)); ``` The chain oracle (such as +//! chain.canonicalize(task); ``` The chain oracle (such as //! [`LocalChain`](crate::local_chain::LocalChain)) handles all anchor verification queries from //! the task. //! @@ -129,7 +129,7 @@ use crate::collections::*; use crate::CanonicalizationParams; use crate::CanonicalizationTask; -use crate::{Anchor, Merge}; +use crate::{Anchor, BlockId, Merge}; use alloc::collections::vec_deque::VecDeque; use alloc::sync::Arc; use alloc::vec::Vec; @@ -981,9 +981,10 @@ impl TxGraph { /// for anchor verification requests. pub fn canonicalization_task( &'_ self, + chain_tip: BlockId, params: CanonicalizationParams, ) -> CanonicalizationTask<'_, A> { - CanonicalizationTask::new(self, params) + CanonicalizationTask::new(self, chain_tip, params) } } diff --git a/crates/chain/tests/test_canonical_view.rs b/crates/chain/tests/test_canonical_view.rs index 51e4e1463..47bab2758 100644 --- a/crates/chain/tests/test_canonical_view.rs +++ b/crates/chain/tests/test_canonical_view.rs @@ -54,8 +54,8 @@ fn test_min_confirmations_parameter() { let _ = tx_graph.insert_anchor(txid, anchor_height_5); let chain_tip = chain.tip().block_id(); - let task = tx_graph.canonicalization_task(CanonicalizationParams::default()); - let canonical_view = chain.canonicalize(task, Some(chain_tip)); + let task = tx_graph.canonicalization_task(chain_tip, CanonicalizationParams::default()); + let canonical_view = chain.canonicalize(task); // Test min_confirmations = 1: Should be confirmed (has 6 confirmations) let balance_1_conf = canonical_view.balance( @@ -142,8 +142,9 @@ fn test_min_confirmations_with_untrusted_tx() { }; let _ = tx_graph.insert_anchor(txid, anchor); - let task = tx_graph.canonicalization_task(CanonicalizationParams::default()); - let canonical_view = chain.canonicalize(task, Some(chain.tip().block_id())); + let chain_tip = chain.tip().block_id(); + let task = tx_graph.canonicalization_task(chain_tip, CanonicalizationParams::default()); + let canonical_view = chain.canonicalize(task); // Test with min_confirmations = 5 and untrusted predicate let balance = canonical_view.balance( @@ -260,8 +261,9 @@ fn test_min_confirmations_multiple_transactions() { ); outpoints.push(((), outpoint2)); - let task = tx_graph.canonicalization_task(CanonicalizationParams::default()); - let canonical_view = chain.canonicalize(task, Some(chain.tip().block_id())); + let chain_tip = chain.tip().block_id(); + let task = tx_graph.canonicalization_task(chain_tip, CanonicalizationParams::default()); + let canonical_view = chain.canonicalize(task); // Test with min_confirmations = 5 // tx0: 11 confirmations -> confirmed diff --git a/crates/chain/tests/test_indexed_tx_graph.rs b/crates/chain/tests/test_indexed_tx_graph.rs index 80ecb3934..6855a58f2 100644 --- a/crates/chain/tests/test_indexed_tx_graph.rs +++ b/crates/chain/tests/test_indexed_tx_graph.rs @@ -472,24 +472,24 @@ fn test_list_owned_txouts() { .unwrap_or_else(|| panic!("block must exist at {height}")); let task = graph .graph() - .canonicalization_task(CanonicalizationParams::default()); + .canonicalization_task(chain_tip, CanonicalizationParams::default()); let txouts = local_chain - .canonicalize(task, Some(chain_tip)) + .canonicalize(task) .filter_outpoints(graph.index.outpoints().iter().cloned()) .collect::>(); let task = graph .graph() - .canonicalization_task(CanonicalizationParams::default()); + .canonicalization_task(chain_tip, CanonicalizationParams::default()); let utxos = local_chain - .canonicalize(task, Some(chain_tip)) + .canonicalize(task) .filter_unspent_outpoints(graph.index.outpoints().iter().cloned()) .collect::>(); let task = graph .graph() - .canonicalization_task(CanonicalizationParams::default()); - let balance = local_chain.canonicalize(task, Some(chain_tip)).balance( + .canonicalization_task(chain_tip, CanonicalizationParams::default()); + let balance = local_chain.canonicalize(task).balance( graph.index.outpoints().iter().cloned(), |_, txout| trusted_spks.contains(&txout.txout.script_pubkey), 0, @@ -796,19 +796,17 @@ fn test_get_chain_position() { } // check chain position + let chain_tip = chain.tip().block_id(); let task = graph .graph() - .canonicalization_task(CanonicalizationParams::default()); - let chain_pos = chain - .canonicalize(task, Some(chain.tip().block_id())) - .txs() - .find_map(|canon_tx| { - if canon_tx.txid == txid { - Some(canon_tx.pos) - } else { - None - } - }); + .canonicalization_task(chain_tip, CanonicalizationParams::default()); + let chain_pos = chain.canonicalize(task).txs().find_map(|canon_tx| { + if canon_tx.txid == txid { + Some(canon_tx.pos) + } else { + None + } + }); assert_eq!(chain_pos, exp_pos, "failed test case: {name}"); } diff --git a/crates/chain/tests/test_tx_graph.rs b/crates/chain/tests/test_tx_graph.rs index 3bc14c126..2b7ebf847 100644 --- a/crates/chain/tests/test_tx_graph.rs +++ b/crates/chain/tests/test_tx_graph.rs @@ -1014,9 +1014,10 @@ fn test_chain_spends() { let build_canonical_spends = |chain: &LocalChain, tx_graph: &TxGraph| -> HashMap { - let task = tx_graph.canonicalization_task(CanonicalizationParams::default()); + let task = + tx_graph.canonicalization_task(tip.block_id(), CanonicalizationParams::default()); chain - .canonicalize(task, Some(tip.block_id())) + .canonicalize(task) .filter_outpoints(tx_graph.all_txouts().map(|(op, _)| ((), op))) .filter_map(|(_, full_txo)| Some((full_txo.outpoint, full_txo.spent_by?))) .collect() @@ -1024,9 +1025,10 @@ fn test_chain_spends() { let build_canonical_positions = |chain: &LocalChain, tx_graph: &TxGraph| -> HashMap> { - let task = tx_graph.canonicalization_task(CanonicalizationParams::default()); + let task = + tx_graph.canonicalization_task(tip.block_id(), CanonicalizationParams::default()); chain - .canonicalize(task, Some(tip.block_id())) + .canonicalize(task) .txs() .map(|canon_tx| (canon_tx.txid, canon_tx.pos)) .collect() @@ -1199,29 +1201,25 @@ fn transactions_inserted_into_tx_graph_are_not_canonical_until_they_have_an_anch .into_iter() .collect(); let chain = LocalChain::from_blocks(blocks).unwrap(); - let task = graph.canonicalization_task(CanonicalizationParams::default()); - let canonical_txs: Vec<_> = chain - .canonicalize(task, Some(chain.tip().block_id())) - .txs() - .collect(); + let chain_tip = chain.tip().block_id(); + let task = graph.canonicalization_task(chain_tip, CanonicalizationParams::default()); + let canonical_txs: Vec<_> = chain.canonicalize(task).txs().collect(); assert!(canonical_txs.is_empty()); // tx0 with seen_at should be returned by canonical txs let _ = graph.insert_seen_at(txids[0], 2); - let task = graph.canonicalization_task(CanonicalizationParams::default()); - let canonical_view = chain.canonicalize(task, Some(chain.tip().block_id())); + let chain_tip = chain.tip().block_id(); + let task = graph.canonicalization_task(chain_tip, CanonicalizationParams::default()); + let canonical_view = chain.canonicalize(task); let mut canonical_txs = canonical_view.txs(); assert_eq!(canonical_txs.next().map(|tx| tx.txid).unwrap(), txids[0]); drop(canonical_txs); // tx1 with anchor is also canonical let _ = graph.insert_anchor(txids[1], block_id!(2, "B")); - let task = graph.canonicalization_task(CanonicalizationParams::default()); - let canonical_txids: Vec<_> = chain - .canonicalize(task, Some(chain.tip().block_id())) - .txs() - .map(|tx| tx.txid) - .collect(); + let chain_tip = chain.tip().block_id(); + let task = graph.canonicalization_task(chain_tip, CanonicalizationParams::default()); + let canonical_txids: Vec<_> = chain.canonicalize(task).txs().map(|tx| tx.txid).collect(); assert!(canonical_txids.contains(&txids[1])); assert!(graph.txs_with_no_anchor_or_last_seen().next().is_none()); } diff --git a/crates/chain/tests/test_tx_graph_conflicts.rs b/crates/chain/tests/test_tx_graph_conflicts.rs index 9fb5aad58..de6325914 100644 --- a/crates/chain/tests/test_tx_graph_conflicts.rs +++ b/crates/chain/tests/test_tx_graph_conflicts.rs @@ -972,9 +972,9 @@ fn test_tx_conflict_handling() { let task = env .tx_graph - .canonicalization_task(env.canonicalization_params.clone()); + .canonicalization_task(chain_tip, env.canonicalization_params.clone()); let txs = local_chain - .canonicalize(task, Some(chain_tip)) + .canonicalize(task) .txs() .map(|tx| tx.txid) .collect::>(); @@ -991,9 +991,9 @@ fn test_tx_conflict_handling() { let task = env .tx_graph - .canonicalization_task(env.canonicalization_params.clone()); + .canonicalization_task(chain_tip, env.canonicalization_params.clone()); let txouts = local_chain - .canonicalize(task, Some(chain_tip)) + .canonicalize(task) .filter_outpoints(env.indexer.outpoints().iter().cloned()) .map(|(_, full_txout)| full_txout.outpoint) .collect::>(); @@ -1013,9 +1013,9 @@ fn test_tx_conflict_handling() { let task = env .tx_graph - .canonicalization_task(env.canonicalization_params.clone()); + .canonicalization_task(chain_tip, env.canonicalization_params.clone()); let utxos = local_chain - .canonicalize(task, Some(chain_tip)) + .canonicalize(task) .filter_unspent_outpoints(env.indexer.outpoints().iter().cloned()) .map(|(_, full_txout)| full_txout.outpoint) .collect::>(); @@ -1035,8 +1035,8 @@ fn test_tx_conflict_handling() { let task = env .tx_graph - .canonicalization_task(env.canonicalization_params.clone()); - let balance = local_chain.canonicalize(task, Some(chain_tip)).balance( + .canonicalization_task(chain_tip, env.canonicalization_params.clone()); + let balance = local_chain.canonicalize(task).balance( env.indexer.outpoints().iter().cloned(), |_, txout| { env.indexer diff --git a/crates/core/src/chain_query.rs b/crates/core/src/chain_query.rs index 478b9b3e4..9a99d5ea8 100644 --- a/crates/core/src/chain_query.rs +++ b/crates/core/src/chain_query.rs @@ -4,6 +4,28 @@ //! algorithms that need to make queries to blockchain sources and process responses //! in a sans-IO manner. +use crate::BlockId; +use alloc::vec::Vec; + +/// A request to check which block identifiers are confirmed in the chain. +/// +/// This is used to verify if specific blocks are part of the canonical chain. +/// The generic parameter `B` represents the block identifier type, which defaults to `BlockId`. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ChainRequest { + /// The chain tip to use as reference for the query. + pub chain_tip: B, + /// The block identifiers to check for confirmation in the chain. + pub block_ids: Vec, +} + +/// Response containing the best confirmed block identifier, if any. +/// +/// Returns `Some(B)` if at least one of the requested blocks +/// is confirmed in the chain, or `None` if none are confirmed. +/// The generic parameter `B` represents the block identifier type, which defaults to `BlockId`. +pub type ChainResponse = Option; + /// A trait for types that perform query-based operations against blockchain data. /// /// This trait enables types to request blockchain information via queries and process @@ -13,38 +35,22 @@ /// /// # Type Parameters /// -/// * `Request` - The type of query request that can be made -/// * `Response` - The type of response expected for queries -/// * `Context` - The type of context needed for finalization (e.g., `BlockId` for chain tip) -/// * `Result` - The final result type produced when the query process is complete -pub trait ChainQuery { - /// The type of query request that can be made. - type Request; - - /// The type of response expected for queries. - type Response; - - /// The type of context needed for finalization. - /// - /// This could be `BlockId` for algorithms needing chain tip information, - /// `()` for algorithms that don't need additional context, or any other - /// type specific to the implementation's needs. - type Context; - - /// The final result type produced when the query process is complete. - type Result; +/// * `B` - The type of block identifier used in queries (defaults to `BlockId`) +pub trait ChainQuery { + /// The final output type produced when the query process is complete. + type Output; /// Returns the next query needed, if any. /// /// This method should return `Some(request)` if more information is needed, /// or `None` if no more queries are required. - fn next_query(&mut self) -> Option; + fn next_query(&mut self) -> Option>; /// Resolves a query with the given response. /// /// This method processes the response to a previous query request and updates /// the internal state accordingly. - fn resolve_query(&mut self, response: Self::Response); + fn resolve_query(&mut self, response: ChainResponse); /// Returns true if the query process is complete and ready to finish. /// @@ -54,12 +60,9 @@ pub trait ChainQuery { self.next_query().is_none() } - /// Completes the query process and returns the final result. + /// Completes the query process and returns the final output. /// /// This method should be called when `is_finished` returns `true`. - /// It consumes `self` and produces the final result. - /// - /// The `context` parameter provides implementation-specific context - /// needed for finalization. - fn finish(self, context: Self::Context) -> Self::Result; + /// It consumes `self` and produces the final output. + fn finish(self) -> Self::Output; } diff --git a/crates/electrum/tests/test_electrum.rs b/crates/electrum/tests/test_electrum.rs index 71cc88d88..fada6e19f 100644 --- a/crates/electrum/tests/test_electrum.rs +++ b/crates/electrum/tests/test_electrum.rs @@ -42,9 +42,9 @@ fn get_balance( let outpoints = recv_graph.index.outpoints().clone(); let task = recv_graph .graph() - .canonicalization_task(CanonicalizationParams::default()); + .canonicalization_task(chain_tip, CanonicalizationParams::default()); let balance = recv_chain - .canonicalize(task, Some(chain_tip)) + .canonicalize(task) .balance(outpoints, |_, _| true, 0); Ok(balance) } @@ -160,8 +160,9 @@ pub fn detect_receive_tx_cancel() -> anyhow::Result<()> { .spks_with_indexes(graph.index.all_spks().clone()) .expected_spk_txids( { - let task = graph.canonicalization_task(Default::default()); - chain.canonicalize(task, Some(chain.tip().block_id())) + let chain_tip = chain.tip().block_id(); + let task = graph.canonicalization_task(chain_tip, Default::default()); + chain.canonicalize(task) } .list_expected_spk_txids(&graph.index, ..), ); @@ -192,8 +193,9 @@ pub fn detect_receive_tx_cancel() -> anyhow::Result<()> { .spks_with_indexes(graph.index.all_spks().clone()) .expected_spk_txids( { - let task = graph.canonicalization_task(Default::default()); - chain.canonicalize(task, Some(chain.tip().block_id())) + let chain_tip = chain.tip().block_id(); + let task = graph.canonicalization_task(chain_tip, Default::default()); + chain.canonicalize(task) } .list_expected_spk_txids(&graph.index, ..), ); diff --git a/crates/esplora/tests/async_ext.rs b/crates/esplora/tests/async_ext.rs index 5554006e3..c18b94071 100644 --- a/crates/esplora/tests/async_ext.rs +++ b/crates/esplora/tests/async_ext.rs @@ -101,8 +101,9 @@ pub async fn detect_receive_tx_cancel() -> anyhow::Result<()> { .spks_with_indexes(graph.index.all_spks().clone()) .expected_spk_txids( { - let task = graph.canonicalization_task(Default::default()); - chain.canonicalize(task, Some(chain.tip().block_id())) + let chain_tip = chain.tip().block_id(); + let task = graph.canonicalization_task(chain_tip, Default::default()); + chain.canonicalize(task) } .list_expected_spk_txids(&graph.index, ..), ); @@ -133,8 +134,9 @@ pub async fn detect_receive_tx_cancel() -> anyhow::Result<()> { .spks_with_indexes(graph.index.all_spks().clone()) .expected_spk_txids( { - let task = graph.canonicalization_task(Default::default()); - chain.canonicalize(task, Some(chain.tip().block_id())) + let chain_tip = chain.tip().block_id(); + let task = graph.canonicalization_task(chain_tip, Default::default()); + chain.canonicalize(task) } .list_expected_spk_txids(&graph.index, ..), ); diff --git a/crates/esplora/tests/blocking_ext.rs b/crates/esplora/tests/blocking_ext.rs index 99e716a50..f46617725 100644 --- a/crates/esplora/tests/blocking_ext.rs +++ b/crates/esplora/tests/blocking_ext.rs @@ -98,8 +98,9 @@ pub fn detect_receive_tx_cancel() -> anyhow::Result<()> { .spks_with_indexes(graph.index.all_spks().clone()) .expected_spk_txids( { - let task = graph.canonicalization_task(Default::default()); - chain.canonicalize(task, Some(chain.tip().block_id())) + let chain_tip = chain.tip().block_id(); + let task = graph.canonicalization_task(chain_tip, Default::default()); + chain.canonicalize(task) } .list_expected_spk_txids(&graph.index, ..), ); @@ -130,8 +131,9 @@ pub fn detect_receive_tx_cancel() -> anyhow::Result<()> { .spks_with_indexes(graph.index.all_spks().clone()) .expected_spk_txids( { - let task = graph.canonicalization_task(Default::default()); - chain.canonicalize(task, Some(chain.tip().block_id())) + let chain_tip = chain.tip().block_id(); + let task = graph.canonicalization_task(chain_tip, Default::default()); + chain.canonicalize(task) } .list_expected_spk_txids(&graph.index, ..), ); diff --git a/examples/example_bitcoind_rpc_polling/src/main.rs b/examples/example_bitcoind_rpc_polling/src/main.rs index b9cff2be5..61e8b05ea 100644 --- a/examples/example_bitcoind_rpc_polling/src/main.rs +++ b/examples/example_bitcoind_rpc_polling/src/main.rs @@ -145,10 +145,11 @@ fn main() -> anyhow::Result<()> { chain.tip(), fallback_height, { + let chain_tip = chain.tip().block_id(); let task = graph .graph() - .canonicalization_task(CanonicalizationParams::default()); - chain.canonicalize(task, Some(chain.tip().block_id())) + .canonicalization_task(chain_tip, CanonicalizationParams::default()); + chain.canonicalize(task) } .txs() .filter(|tx| tx.pos.is_unconfirmed()) @@ -197,10 +198,12 @@ fn main() -> anyhow::Result<()> { let synced_to = chain.tip(); let balance = { { - let task = graph - .graph() - .canonicalization_task(CanonicalizationParams::default()); - chain.canonicalize(task, Some(synced_to.block_id())) + let synced_to_block = synced_to.block_id(); + let task = graph.graph().canonicalization_task( + synced_to_block, + CanonicalizationParams::default(), + ); + chain.canonicalize(task) } .balance( graph.index.outpoints().iter().cloned(), @@ -250,10 +253,11 @@ fn main() -> anyhow::Result<()> { chain.tip(), fallback_height, { + let chain_tip = chain.tip().block_id(); let task = graph .graph() - .canonicalization_task(CanonicalizationParams::default()); - chain.canonicalize(task, Some(chain.tip().block_id())) + .canonicalization_task(chain_tip, CanonicalizationParams::default()); + chain.canonicalize(task) } .txs() .filter(|tx| tx.pos.is_unconfirmed()) @@ -357,10 +361,12 @@ fn main() -> anyhow::Result<()> { let synced_to = chain.tip(); let balance = { { - let task = graph - .graph() - .canonicalization_task(CanonicalizationParams::default()); - chain.canonicalize(task, Some(synced_to.block_id())) + let synced_to_block = synced_to.block_id(); + let task = graph.graph().canonicalization_task( + synced_to_block, + CanonicalizationParams::default(), + ); + chain.canonicalize(task) } .balance( graph.index.outpoints().iter().cloned(), diff --git a/examples/example_cli/src/lib.rs b/examples/example_cli/src/lib.rs index 293a93093..53d373481 100644 --- a/examples/example_cli/src/lib.rs +++ b/examples/example_cli/src/lib.rs @@ -429,9 +429,9 @@ pub fn planned_utxos( let outpoints = graph.index.outpoints(); let task = graph .graph() - .canonicalization_task(CanonicalizationParams::default()); + .canonicalization_task(chain_tip, CanonicalizationParams::default()); chain - .canonicalize(task, Some(chain_tip)) + .canonicalize(task) .filter_unspent_outpoints(outpoints.iter().cloned()) .filter_map(|((k, i), full_txo)| -> Option> { let desc = graph @@ -523,16 +523,15 @@ pub fn handle_commands( } } + let chain_tip = chain.tip().block_id(); let task = graph .graph() - .canonicalization_task(CanonicalizationParams::default()); - let balance = chain - .canonicalize(task, Some(chain.tip().block_id())) - .balance( - graph.index.outpoints().iter().cloned(), - |(k, _), _| k == &Keychain::Internal, - 1, - ); + .canonicalization_task(chain_tip, CanonicalizationParams::default()); + let balance = chain.canonicalize(task).balance( + graph.index.outpoints().iter().cloned(), + |(k, _), _| k == &Keychain::Internal, + 1, + ); let confirmed_total = balance.confirmed + balance.immature; let unconfirmed_total = balance.untrusted_pending + balance.trusted_pending; @@ -571,9 +570,9 @@ pub fn handle_commands( } => { let task = graph .graph() - .canonicalization_task(CanonicalizationParams::default()); + .canonicalization_task(chain_tip, CanonicalizationParams::default()); let txouts = chain - .canonicalize(task, Some(chain_tip)) + .canonicalize(task) .filter_outpoints(outpoints.iter().cloned()) .filter(|(_, full_txo)| match (spent, unspent) { (true, false) => full_txo.spent_by.is_some(), diff --git a/examples/example_electrum/src/main.rs b/examples/example_electrum/src/main.rs index 5213adfab..b216f3245 100644 --- a/examples/example_electrum/src/main.rs +++ b/examples/example_electrum/src/main.rs @@ -227,10 +227,11 @@ fn main() -> anyhow::Result<()> { let _ = io::stderr().flush(); }); + let chain_tip_block = chain_tip.block_id(); let task = graph .graph() - .canonicalization_task(CanonicalizationParams::default()); - let canonical_view = chain.canonicalize(task, Some(chain_tip.block_id())); + .canonicalization_task(chain_tip_block, CanonicalizationParams::default()); + let canonical_view = chain.canonicalize(task); request = request .expected_spk_txids(canonical_view.list_expected_spk_txids(&graph.index, ..)); diff --git a/examples/example_esplora/src/main.rs b/examples/example_esplora/src/main.rs index 3f780924b..f291722ef 100644 --- a/examples/example_esplora/src/main.rs +++ b/examples/example_esplora/src/main.rs @@ -237,10 +237,11 @@ fn main() -> anyhow::Result<()> { { let graph = graph.lock().unwrap(); let chain = chain.lock().unwrap(); + let local_tip_block = local_tip.block_id(); let task = graph .graph() - .canonicalization_task(CanonicalizationParams::default()); - let canonical_view = chain.canonicalize(task, Some(local_tip.block_id())); + .canonicalization_task(local_tip_block, CanonicalizationParams::default()); + let canonical_view = chain.canonicalize(task); request = request .expected_spk_txids(canonical_view.list_expected_spk_txids(&graph.index, ..)); From 77fcf91991f273f729f86d79922d7fe9c49758c0 Mon Sep 17 00:00:00 2001 From: Leonardo Lima Date: Fri, 3 Oct 2025 02:07:57 -0300 Subject: [PATCH 06/15] refactor(chain): use single queue for `anchored_txs` in canonicalization - Unify both `unprocessed_anchored_txs` and `pending_anchored_txs` in a single `unprocessed_anchored_txs` queue. - Changes the `unprocessed_anchored_txs from `Iterator` to `VecDeque`. - Removes the `pending_anchored_txs` field and it's usage. - Collects all `anchored_txs` upfront instead of lazy iteration. --- crates/chain/src/canonical_task.rs | 47 ++++++++---------------------- 1 file changed, 12 insertions(+), 35 deletions(-) diff --git a/crates/chain/src/canonical_task.rs b/crates/chain/src/canonical_task.rs index 516a2e11f..87868a923 100644 --- a/crates/chain/src/canonical_task.rs +++ b/crates/chain/src/canonical_task.rs @@ -27,16 +27,13 @@ pub struct CanonicalizationTask<'g, A> { chain_tip: BlockId, unprocessed_assumed_txs: Box)> + 'g>, - unprocessed_anchored_txs: - Box, &'g BTreeSet)> + 'g>, + unprocessed_anchored_txs: VecDeque<(Txid, Arc, &'g BTreeSet)>, unprocessed_seen_txs: Box, u64)> + 'g>, unprocessed_leftover_txs: VecDeque<(Txid, Arc, u32)>, canonical: CanonicalMap, not_canonical: NotCanonicalSet, - pending_anchor_checks: VecDeque<(Txid, Arc, Vec)>, - // Store canonical transactions in order canonical_order: Vec, @@ -48,22 +45,19 @@ impl<'g, A: Anchor> ChainQuery for CanonicalizationTask<'g, A> { type Output = CanonicalView; fn next_query(&mut self) -> Option { - // Check if we have pending anchor checks - if let Some((_, _, anchors)) = self.pending_anchor_checks.front() { - // Convert anchors to BlockIds for the ChainRequest + // Get the next unprocessed anchored tx that needs to query a chain oracle. + if let Some((_txid, _tx, anchors)) = self.unprocessed_anchored_txs.front() { let block_ids = anchors.iter().map(|anchor| anchor.anchor_block()).collect(); return Some(ChainRequest { chain_tip: self.chain_tip, block_ids, }); } - - // Process more anchored transactions if available - self.process_anchored_txs() + None } fn resolve_query(&mut self, response: ChainResponse) { - if let Some((txid, tx, anchors)) = self.pending_anchor_checks.pop_front() { + 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 @@ -97,7 +91,7 @@ impl<'g, A: Anchor> ChainQuery for CanonicalizationTask<'g, A> { } fn is_finished(&mut self) -> bool { - self.pending_anchor_checks.is_empty() && self.unprocessed_anchored_txs.size_hint().0 == 0 + self.unprocessed_anchored_txs.is_empty() } fn finish(mut self) -> Self::Output { @@ -199,11 +193,10 @@ impl<'g, A: Anchor> CanonicalizationTask<'g, A> { .rev() .filter_map(|txid| Some((txid, tx_graph.get_tx(txid)?))), ); - let unprocessed_anchored_txs = Box::new( - tx_graph - .txids_by_descending_anchor_height() - .filter_map(|(_, txid)| Some((txid, tx_graph.get_tx(txid)?, anchors.get(&txid)?))), - ); + let unprocessed_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() @@ -222,8 +215,6 @@ impl<'g, A: Anchor> CanonicalizationTask<'g, A> { canonical: HashMap::new(), not_canonical: HashSet::new(), - pending_anchor_checks: VecDeque::new(), - canonical_order: Vec::new(), confirmed_anchors: HashMap::new(), }; @@ -246,17 +237,6 @@ impl<'g, A: Anchor> CanonicalizationTask<'g, A> { } } - fn process_anchored_txs(&mut self) -> Option { - while let Some((txid, tx, anchors)) = self.unprocessed_anchored_txs.next() { - if !self.is_canonicalized(txid) { - self.pending_anchor_checks - .push_back((txid, tx, anchors.iter().cloned().collect())); - return self.next_query(); - } - } - None - } - fn process_seen_txs(&mut self) { while let Some((txid, tx, last_seen)) = self.unprocessed_seen_txs.next() { debug_assert!( @@ -372,11 +352,8 @@ impl<'g, A: Anchor> CanonicalizationTask<'g, A> { if let Some(anchors) = self.tx_graph.all_anchors().get(txid) { // only check anchors we haven't already confirmed if !self.confirmed_anchors.contains_key(txid) { - self.pending_anchor_checks.push_back(( - *txid, - tx.clone(), - anchors.iter().cloned().collect(), - )); + self.unprocessed_anchored_txs + .push_back((*txid, tx.clone(), anchors)); } } } From f6b2565883ea8fd0363a6a265081828840c09b6a Mon Sep 17 00:00:00 2001 From: Leonardo Lima Date: Fri, 3 Oct 2025 03:23:41 -0300 Subject: [PATCH 07/15] refactor(chain)!: introduce stage based canonicalization processing - Add new `CanonicalStage` enum for tracking the different canonicalization phases/stages. - Add new `try_advance()` method for stage progression. - Add new `is_transitive()` helper to `CanonicalReason`. - Change internal `confirmed_anchors` to `direct_anchors` for better clarity. - Update the `resolve_query()` to handle staged-based processing. Co-authored-by: Claude --- crates/chain/src/canonical_task.rs | 268 ++++++++++++++++++++++------- 1 file changed, 202 insertions(+), 66 deletions(-) diff --git a/crates/chain/src/canonical_task.rs b/crates/chain/src/canonical_task.rs index 87868a923..9d8713213 100644 --- a/crates/chain/src/canonical_task.rs +++ b/crates/chain/src/canonical_task.rs @@ -11,6 +11,21 @@ use bitcoin::{Transaction, Txid}; type CanonicalMap = HashMap, CanonicalReason)>; type NotCanonicalSet = HashSet; +/// Represents the current stage of canonicalization processing. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum CanonicalStage { + /// 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, +} + /// Modifies the canonicalization algorithm. #[derive(Debug, Default, Clone)] pub struct CanonicalizationParams { @@ -30,6 +45,7 @@ pub struct CanonicalizationTask<'g, A> { unprocessed_anchored_txs: VecDeque<(Txid, Arc, &'g BTreeSet)>, unprocessed_seen_txs: Box, u64)> + 'g>, unprocessed_leftover_txs: VecDeque<(Txid, Arc, u32)>, + unprocessed_transitively_anchored_txs: VecDeque<(Txid, Arc, &'g BTreeSet)>, canonical: CanonicalMap, not_canonical: NotCanonicalSet, @@ -37,68 +53,139 @@ pub struct CanonicalizationTask<'g, A> { // Store canonical transactions in order canonical_order: Vec, - // Track which transactions have confirmed anchors - confirmed_anchors: HashMap, + // Track which transactions have direct anchors (not transitive) + direct_anchors: HashMap, + + // Track the current stage of processing + current_stage: CanonicalStage, } impl<'g, A: Anchor> ChainQuery for CanonicalizationTask<'g, A> { type Output = CanonicalView; fn next_query(&mut self) -> Option { - // Get the next unprocessed anchored tx that needs to query a chain oracle. - if let Some((_txid, _tx, anchors)) = self.unprocessed_anchored_txs.front() { - let block_ids = anchors.iter().map(|anchor| anchor.anchor_block()).collect(); - return Some(ChainRequest { - chain_tip: self.chain_tip, - block_ids, - }); + // Try to advance to the next stage if needed + self.try_advance(); + + match self.current_stage { + CanonicalStage::AnchoredTxs => { + // Process directly anchored transactions first + if let Some((_txid, _, anchors)) = self.unprocessed_anchored_txs.front() { + let block_ids = anchors.iter().map(|anchor| anchor.anchor_block()).collect(); + return Some(ChainRequest { + chain_tip: self.chain_tip, + block_ids, + }); + } + None + } + CanonicalStage::TransitivelyAnchoredTxs => { + // Process transitively anchored transactions last + if let Some((_txid, _, anchors)) = + self.unprocessed_transitively_anchored_txs.front() + { + let block_ids = anchors.iter().map(|anchor| anchor.anchor_block()).collect(); + return Some(ChainRequest { + chain_tip: self.chain_tip, + block_ids, + }); + } + None + } + CanonicalStage::SeenTxs | CanonicalStage::LeftOverTxs | CanonicalStage::Finished => { + // These stages don't need queries + None + } } - None } fn resolve_query(&mut self, response: ChainResponse) { - 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) => { - self.confirmed_anchors.insert(txid, best_anchor.clone()); - if !self.is_canonicalized(txid) { - self.mark_canonical(txid, tx, CanonicalReason::from_anchor(best_anchor)); + // 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(), + )) + } } } - None => { - self.unprocessed_leftover_txs.push_back(( - txid, - tx, + } + 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() - .last() - .expect( - "tx taken from `unprocessed_txs_with_anchors` so it must at least have an anchor", - ) - .confirmation_height_upper_bound(), - )) + .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::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 is_finished(&mut self) -> bool { - self.unprocessed_anchored_txs.is_empty() + // Try to advance stages first + self.try_advance(); + // Check if we've reached the Finished stage + self.current_stage == CanonicalStage::Finished } - fn finish(mut self) -> Self::Output { - // Process remaining transactions (seen and leftover) - self.process_seen_txs(); - self.process_leftover_txs(); - + fn finish(self) -> Self::Output { // Build the canonical view let mut view_order = Vec::new(); let mut view_txs = HashMap::new(); @@ -127,7 +214,7 @@ impl<'g, A: Anchor> ChainQuery for CanonicalizationTask<'g, A> { // Determine chain position based on reason let chain_position = match reason { CanonicalReason::Assumed { descendant } => match descendant { - Some(_) => match self.confirmed_anchors.get(txid) { + Some(_) => match self.direct_anchors.get(txid) { Some(anchor) => ChainPosition::Confirmed { anchor, transitively: None, @@ -143,7 +230,7 @@ impl<'g, A: Anchor> ChainQuery for CanonicalizationTask<'g, A> { }, }, CanonicalReason::Anchor { anchor, descendant } => match descendant { - Some(_) => match self.confirmed_anchors.get(txid) { + Some(_) => match self.direct_anchors.get(txid) { Some(anchor) => ChainPosition::Confirmed { anchor, transitively: None, @@ -179,6 +266,49 @@ impl<'g, A: Anchor> ChainQuery for CanonicalizationTask<'g, A> { } impl<'g, A: Anchor> CanonicalizationTask<'g, A> { + /// Try to advance to the next stage if the current stage is complete. + /// The loop continues through stages that process all their transactions at once + /// (SeenTxs and LeftOverTxs) to avoid needing multiple calls. + fn try_advance(&mut self) { + loop { + let advanced = match self.current_stage { + CanonicalStage::AnchoredTxs => { + if self.unprocessed_anchored_txs.is_empty() { + self.current_stage = CanonicalStage::SeenTxs; + true // Continue to process SeenTxs immediately + } else { + false // Still have work, stop advancing + } + } + CanonicalStage::SeenTxs => { + // Process all seen transactions at once + self.process_seen_txs(); + self.current_stage = CanonicalStage::LeftOverTxs; + true // Continue to process LeftOverTxs immediately + } + CanonicalStage::LeftOverTxs => { + // Process all leftover transactions at once + self.process_leftover_txs(); + self.current_stage = CanonicalStage::TransitivelyAnchoredTxs; + false // Stop here - TransitivelyAnchoredTxs need queries + } + CanonicalStage::TransitivelyAnchoredTxs => { + if self.unprocessed_transitively_anchored_txs.is_empty() { + self.current_stage = CanonicalStage::Finished; + } + false // Stop advancing + } + CanonicalStage::Finished => { + false // Already finished, nothing to do + } + }; + + if !advanced { + break; + } + } + } + /// Creates a new canonicalization task. pub fn new( tx_graph: &'g TxGraph, @@ -211,12 +341,14 @@ impl<'g, A: Anchor> CanonicalizationTask<'g, A> { unprocessed_anchored_txs, unprocessed_seen_txs, unprocessed_leftover_txs: VecDeque::new(), + unprocessed_transitively_anchored_txs: VecDeque::new(), canonical: HashMap::new(), not_canonical: HashSet::new(), canonical_order: Vec::new(), - confirmed_anchors: HashMap::new(), + direct_anchors: HashMap::new(), + current_stage: CanonicalStage::AnchoredTxs, }; // process assumed transactions first (they don't need queries) @@ -331,30 +463,28 @@ impl<'g, A: Anchor> CanonicalizationTask<'g, A> { for txid in undo_not_canonical { self.not_canonical.remove(&txid); } - } else { - // Add to canonical order - for (txid, tx, reason) in &staged_canonical { - self.canonical_order.push(*txid); - - // If this was marked transitively, check if it has anchors to verify - let is_transitive = matches!( - reason, - CanonicalReason::Anchor { - descendant: Some(_), - .. - } | CanonicalReason::Assumed { - descendant: Some(_), - .. - } - ); + return; + } - if is_transitive { - if let Some(anchors) = self.tx_graph.all_anchors().get(txid) { - // only check anchors we haven't already confirmed - if !self.confirmed_anchors.contains_key(txid) { - self.unprocessed_anchored_txs - .push_back((*txid, tx.clone(), anchors)); - } + // 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, + )); } } } @@ -449,6 +579,12 @@ impl CanonicalReason { 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)] From afa097c69f69e7966ce80e1628e51883dff7ea93 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=BF=97=E5=AE=87?= Date: Fri, 13 Feb 2026 08:41:34 +0000 Subject: [PATCH 08/15] refactor(core,chain)!: consolidate state machine into `next_query` loop Inline all stage-processing logic into `next_query()`, removing the separate `try_advance()` method, `process_*_txs()` helpers, and `is_finished()` from the `ChainQuery` trait. Add `AssumedTxs` as an explicit first stage and `CanonicalStage::advance()` for centralized stage transitions. Document the `ChainQuery` protocol contract. --- crates/chain/src/canonical_task.rs | 206 ++++++++++++----------------- crates/core/src/chain_query.rs | 18 +-- 2 files changed, 96 insertions(+), 128 deletions(-) diff --git a/crates/chain/src/canonical_task.rs b/crates/chain/src/canonical_task.rs index 9d8713213..e02445c30 100644 --- a/crates/chain/src/canonical_task.rs +++ b/crates/chain/src/canonical_task.rs @@ -12,8 +12,11 @@ type CanonicalMap = HashMap, CanonicalReason)>; type NotCanonicalSet = HashSet; /// Represents the current stage of canonicalization processing. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] enum CanonicalStage { + /// Processing transctions assumed to be canonical. + #[default] + AssumedTxs, /// Processing directly anchored transactions. AnchoredTxs, /// Processing transactions seen in mempool. @@ -26,6 +29,19 @@ enum CanonicalStage { 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 { @@ -64,38 +80,72 @@ impl<'g, A: Anchor> ChainQuery for CanonicalizationTask<'g, A> { type Output = CanonicalView; fn next_query(&mut self) -> Option { - // Try to advance to the next stage if needed - self.try_advance(); - - match self.current_stage { - CanonicalStage::AnchoredTxs => { - // Process directly anchored transactions first - if let Some((_txid, _, anchors)) = self.unprocessed_anchored_txs.front() { - let block_ids = anchors.iter().map(|anchor| anchor.anchor_block()).collect(); - return Some(ChainRequest { - chain_tip: self.chain_tip, - block_ids, - }); + 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; + } } - None - } - CanonicalStage::TransitivelyAnchoredTxs => { - // Process transitively anchored transactions last - if let Some((_txid, _, anchors)) = - self.unprocessed_transitively_anchored_txs.front() - { - let block_ids = anchors.iter().map(|anchor| anchor.anchor_block()).collect(); - return Some(ChainRequest { - chain_tip: self.chain_tip, - block_ids, - }); + 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(ChainRequest { + chain_tip: self.chain_tip, + block_ids, + }); + } } - None - } - CanonicalStage::SeenTxs | CanonicalStage::LeftOverTxs | CanonicalStage::Finished => { - // These stages don't need queries - None + 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(ChainRequest { + chain_tip: self.chain_tip, + block_ids, + }); + } + } + CanonicalStage::Finished => return None, } + + self.current_stage.advance(); } } @@ -167,7 +217,10 @@ impl<'g, A: Anchor> ChainQuery for CanonicalizationTask<'g, A> { // If no confirmed anchor, we keep the transitive canonicalization status } } - CanonicalStage::SeenTxs | CanonicalStage::LeftOverTxs | CanonicalStage::Finished => { + CanonicalStage::AssumedTxs + | CanonicalStage::SeenTxs + | CanonicalStage::LeftOverTxs + | CanonicalStage::Finished => { // These stages don't generate queries and shouldn't receive responses debug_assert!( false, @@ -178,13 +231,6 @@ impl<'g, A: Anchor> ChainQuery for CanonicalizationTask<'g, A> { } } - fn is_finished(&mut self) -> bool { - // Try to advance stages first - self.try_advance(); - // Check if we've reached the Finished stage - self.current_stage == CanonicalStage::Finished - } - fn finish(self) -> Self::Output { // Build the canonical view let mut view_order = Vec::new(); @@ -266,49 +312,6 @@ impl<'g, A: Anchor> ChainQuery for CanonicalizationTask<'g, A> { } impl<'g, A: Anchor> CanonicalizationTask<'g, A> { - /// Try to advance to the next stage if the current stage is complete. - /// The loop continues through stages that process all their transactions at once - /// (SeenTxs and LeftOverTxs) to avoid needing multiple calls. - fn try_advance(&mut self) { - loop { - let advanced = match self.current_stage { - CanonicalStage::AnchoredTxs => { - if self.unprocessed_anchored_txs.is_empty() { - self.current_stage = CanonicalStage::SeenTxs; - true // Continue to process SeenTxs immediately - } else { - false // Still have work, stop advancing - } - } - CanonicalStage::SeenTxs => { - // Process all seen transactions at once - self.process_seen_txs(); - self.current_stage = CanonicalStage::LeftOverTxs; - true // Continue to process LeftOverTxs immediately - } - CanonicalStage::LeftOverTxs => { - // Process all leftover transactions at once - self.process_leftover_txs(); - self.current_stage = CanonicalStage::TransitivelyAnchoredTxs; - false // Stop here - TransitivelyAnchoredTxs need queries - } - CanonicalStage::TransitivelyAnchoredTxs => { - if self.unprocessed_transitively_anchored_txs.is_empty() { - self.current_stage = CanonicalStage::Finished; - } - false // Stop advancing - } - CanonicalStage::Finished => { - false // Already finished, nothing to do - } - }; - - if !advanced { - break; - } - } - } - /// Creates a new canonicalization task. pub fn new( tx_graph: &'g TxGraph, @@ -333,7 +336,7 @@ impl<'g, A: Anchor> CanonicalizationTask<'g, A> { .filter_map(|(last_seen, txid)| Some((txid, tx_graph.get_tx(txid)?, last_seen))), ); - let mut task = Self { + Self { tx_graph, chain_tip, @@ -348,49 +351,14 @@ impl<'g, A: Anchor> CanonicalizationTask<'g, A> { canonical_order: Vec::new(), direct_anchors: HashMap::new(), - current_stage: CanonicalStage::AnchoredTxs, - }; - - // process assumed transactions first (they don't need queries) - task.process_assumed_txs(); - - task + current_stage: CanonicalStage::default(), + } } fn is_canonicalized(&self, txid: Txid) -> bool { self.canonical.contains_key(&txid) || self.not_canonical.contains(&txid) } - fn process_assumed_txs(&mut self) { - while let Some((txid, tx)) = self.unprocessed_assumed_txs.next() { - if !self.is_canonicalized(txid) { - self.mark_canonical(txid, tx, CanonicalReason::assumed()); - } - } - } - - fn process_seen_txs(&mut self) { - while 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)); - } - } - } - - fn process_leftover_txs(&mut self) { - while 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)); - } - } - } - fn mark_canonical(&mut self, txid: Txid, tx: Arc, reason: CanonicalReason) { let starting_txid = txid; let mut is_starting_tx = true; diff --git a/crates/core/src/chain_query.rs b/crates/core/src/chain_query.rs index 9a99d5ea8..fe621492e 100644 --- a/crates/core/src/chain_query.rs +++ b/crates/core/src/chain_query.rs @@ -33,6 +33,14 @@ pub type ChainResponse = Option; /// that need to interact with blockchain oracles, chain sources, or other blockchain /// data providers without directly performing I/O. /// +/// # Protocol +/// +/// Callers must drive the task by calling [`next_query`](Self::next_query) and +/// [`resolve_query`](Self::resolve_query) in a loop. `resolve_query` must only be called +/// after `next_query` returns `Some`. Once `next_query` returns `None`, call +/// [`finish`](Self::finish) to get the output. Calling `resolve_query` or `finish` out of +/// sequence is a programming error. +/// /// # Type Parameters /// /// * `B` - The type of block identifier used in queries (defaults to `BlockId`) @@ -52,17 +60,9 @@ pub trait ChainQuery { /// the internal state accordingly. fn resolve_query(&mut self, response: ChainResponse); - /// Returns true if the query process is complete and ready to finish. - /// - /// The default implementation returns `true` when there are no more queries needed. - /// Implementors can override this for more specific behavior if needed. - fn is_finished(&mut self) -> bool { - self.next_query().is_none() - } - /// Completes the query process and returns the final output. /// - /// This method should be called when `is_finished` returns `true`. + /// This method should be called once [`next_query`](Self::next_query) returns `None`. /// It consumes `self` and produces the final output. fn finish(self) -> Self::Output; } From 6d9a7d6f5702b3a55006c3f20eec049bd86cb15f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=BF=97=E5=AE=87?= Date: Fri, 13 Feb 2026 12:57:39 +0000 Subject: [PATCH 09/15] refactor(chain)!: split canonicalization into two tasks with generic `Canonical` Separate concerns by splitting `CanonicalizationTask` into two phases: 1. `CanonicalTask` determines which transactions are canonical and why (`CanonicalReason`), outputting `CanonicalTxs`. 2. `CanonicalViewTask` resolves reasons into `ChainPosition`s (confirmed vs unconfirmed), outputting `CanonicalView`. Make `Canonical`, `CanonicalTx

`, and `FullTxOut

` generic over the position type so the same structs serve both phases. Add `LocalChain::canonical_view()` convenience method for the common two-step pipeline. Renames: `CanonicalizationTask` -> `CanonicalTask`, `CanonicalizationParams` -> `CanonicalParams`, `canonicalization_task()` -> `canonical_task()`, `FullTxOut::chain_position` -> `FullTxOut::pos`. Co-Authored-By: Claude Opus 4.6 --- crates/bitcoind_rpc/examples/filter_iter.rs | 3 +- crates/bitcoind_rpc/tests/test_emitter.rs | 15 +- crates/chain/benches/canonicalization.rs | 17 +- crates/chain/benches/indexer.rs | 9 +- crates/chain/src/canonical_task.rs | 346 +++++++++++------- crates/chain/src/canonical_view.rs | 171 +++++---- crates/chain/src/chain_data.rs | 28 +- crates/chain/src/indexed_tx_graph.rs | 14 +- crates/chain/src/local_chain.rs | 23 +- crates/chain/src/tx_graph.rs | 23 +- crates/chain/tests/common/tx_template.rs | 6 +- crates/chain/tests/test_canonical_view.rs | 11 +- crates/chain/tests/test_indexed_tx_graph.rs | 55 ++- crates/chain/tests/test_tx_graph.rs | 26 +- crates/chain/tests/test_tx_graph_conflicts.rs | 54 +-- crates/electrum/tests/test_electrum.rs | 26 +- crates/esplora/tests/async_ext.rs | 18 +- crates/esplora/tests/blocking_ext.rs | 18 +- .../example_bitcoind_rpc_polling/src/main.rs | 88 +++-- examples/example_cli/src/lib.rs | 39 +- examples/example_electrum/src/main.rs | 8 +- examples/example_esplora/src/main.rs | 11 +- 22 files changed, 532 insertions(+), 477 deletions(-) diff --git a/crates/bitcoind_rpc/examples/filter_iter.rs b/crates/bitcoind_rpc/examples/filter_iter.rs index b7df2a02d..895120323 100644 --- a/crates/bitcoind_rpc/examples/filter_iter.rs +++ b/crates/bitcoind_rpc/examples/filter_iter.rs @@ -70,8 +70,7 @@ fn main() -> anyhow::Result<()> { println!("Local tip: {}", chain.tip().height()); let chain_tip = chain.tip().block_id(); - let task = graph.canonicalization_task(chain_tip, Default::default()); - let canonical_view = chain.canonicalize(task); + let canonical_view = chain.canonical_view(graph.graph(), chain_tip, Default::default()); let unspent: Vec<_> = canonical_view .filter_unspent_outpoints(graph.index.outpoints().clone()) diff --git a/crates/bitcoind_rpc/tests/test_emitter.rs b/crates/bitcoind_rpc/tests/test_emitter.rs index 1077dd1dc..b891e9d76 100644 --- a/crates/bitcoind_rpc/tests/test_emitter.rs +++ b/crates/bitcoind_rpc/tests/test_emitter.rs @@ -5,7 +5,7 @@ use bdk_chain::{ bitcoin::{Address, Amount, Txid}, local_chain::{CheckPoint, LocalChain}, spk_txout::SpkTxOutIndex, - Balance, BlockId, CanonicalizationParams, IndexedTxGraph, Merge, + Balance, BlockId, CanonicalParams, IndexedTxGraph, Merge, }; use bdk_testenv::{ anyhow, @@ -320,11 +320,8 @@ fn get_balance( ) -> anyhow::Result { let chain_tip = recv_chain.tip().block_id(); let outpoints = recv_graph.index.outpoints().clone(); - let task = recv_graph - .graph() - .canonicalization_task(chain_tip, CanonicalizationParams::default()); let balance = recv_chain - .canonicalize(task) + .canonical_view(recv_graph.graph(), chain_tip, CanonicalParams::default()) .balance(outpoints, |_, _| true, 0); Ok(balance) } @@ -637,9 +634,8 @@ fn test_expect_tx_evicted() -> anyhow::Result<()> { let _txid_2 = core.send_raw_transaction(&tx1b)?; // Retrieve the expected unconfirmed txids and spks from the graph. - let task = graph.canonicalization_task(chain_tip, Default::default()); let exp_spk_txids = chain - .canonicalize(task) + .canonical_view(graph.graph(), chain_tip, Default::default()) .list_expected_spk_txids(&graph.index, ..) .collect::>(); assert_eq!(exp_spk_txids, vec![(spk, txid_1)]); @@ -654,11 +650,8 @@ fn test_expect_tx_evicted() -> anyhow::Result<()> { // Update graph with evicted tx. let _ = graph.batch_insert_relevant_evicted_at(mempool_event.evicted); - let task = graph - .graph() - .canonicalization_task(chain_tip, CanonicalizationParams::default()); let canonical_txids = chain - .canonicalize(task) + .canonical_view(graph.graph(), chain_tip, CanonicalParams::default()) .txs() .map(|tx| tx.txid) .collect::>(); diff --git a/crates/chain/benches/canonicalization.rs b/crates/chain/benches/canonicalization.rs index 8a5d068ae..dd05100a5 100644 --- a/crates/chain/benches/canonicalization.rs +++ b/crates/chain/benches/canonicalization.rs @@ -1,4 +1,4 @@ -use bdk_chain::CanonicalizationParams; +use bdk_chain::CanonicalParams; use bdk_chain::{keychain_txout::KeychainTxOutIndex, local_chain::LocalChain, IndexedTxGraph}; use bdk_core::{BlockId, CheckPoint}; use bdk_core::{ConfirmationBlockTime, TxUpdate}; @@ -96,30 +96,21 @@ fn setup(f: F) -> (KeychainTxGraph, Lo fn run_list_canonical_txs(tx_graph: &KeychainTxGraph, chain: &LocalChain, exp_txs: usize) { let chain_tip = chain.tip().block_id(); - let task = tx_graph - .graph() - .canonicalization_task(chain_tip, CanonicalizationParams::default()); - let view = chain.canonicalize(task); + let view = chain.canonical_view(tx_graph.graph(), chain_tip, CanonicalParams::default()); let txs = view.txs(); assert_eq!(txs.count(), exp_txs); } fn run_filter_chain_txouts(tx_graph: &KeychainTxGraph, chain: &LocalChain, exp_txos: usize) { let chain_tip = chain.tip().block_id(); - let task = tx_graph - .graph() - .canonicalization_task(chain_tip, CanonicalizationParams::default()); - let view = chain.canonicalize(task); + let view = chain.canonical_view(tx_graph.graph(), chain_tip, CanonicalParams::default()); let utxos = view.filter_outpoints(tx_graph.index.outpoints().clone()); assert_eq!(utxos.count(), exp_txos); } fn run_filter_chain_unspents(tx_graph: &KeychainTxGraph, chain: &LocalChain, exp_utxos: usize) { let chain_tip = chain.tip().block_id(); - let task = tx_graph - .graph() - .canonicalization_task(chain_tip, CanonicalizationParams::default()); - let view = chain.canonicalize(task); + let view = chain.canonical_view(tx_graph.graph(), chain_tip, CanonicalParams::default()); let utxos = view.filter_unspent_outpoints(tx_graph.index.outpoints().clone()); assert_eq!(utxos.count(), exp_utxos); } diff --git a/crates/chain/benches/indexer.rs b/crates/chain/benches/indexer.rs index b05200b20..68e9a477e 100644 --- a/crates/chain/benches/indexer.rs +++ b/crates/chain/benches/indexer.rs @@ -1,7 +1,7 @@ use bdk_chain::{ keychain_txout::{InsertDescriptorError, KeychainTxOutIndex}, local_chain::LocalChain, - CanonicalizationParams, IndexedTxGraph, + CanonicalParams, IndexedTxGraph, }; use bdk_core::{BlockId, CheckPoint, ConfirmationBlockTime, TxUpdate}; use bitcoin::{ @@ -84,10 +84,9 @@ fn do_bench(indexed_tx_graph: &KeychainTxGraph, chain: &LocalChain) { // Check balance let chain_tip = chain.tip().block_id(); let op = graph.index.outpoints().clone(); - let task = graph - .graph() - .canonicalization_task(chain_tip, CanonicalizationParams::default()); - let bal = chain.canonicalize(task).balance(op, |_, _| false, 1); + let bal = chain + .canonical_view(graph.graph(), chain_tip, CanonicalParams::default()) + .balance(op, |_, _| false, 1); assert_eq!(bal.total(), AMOUNT * TX_CT as u64); } diff --git a/crates/chain/src/canonical_task.rs b/crates/chain/src/canonical_task.rs index e02445c30..3991bf7e9 100644 --- a/crates/chain/src/canonical_task.rs +++ b/crates/chain/src/canonical_task.rs @@ -1,6 +1,6 @@ use crate::collections::{HashMap, HashSet, VecDeque}; use crate::tx_graph::{TxAncestors, TxDescendants}; -use crate::{Anchor, CanonicalView, ChainPosition, TxGraph}; +use crate::{Anchor, CanonicalTxs, CanonicalView, ChainPosition, TxGraph}; use alloc::boxed::Box; use alloc::collections::BTreeSet; use alloc::sync::Arc; @@ -23,8 +23,6 @@ enum CanonicalStage { SeenTxs, /// Processing leftover transactions. LeftOverTxs, - /// Processing transitively anchored transactions. - TransitivelyAnchoredTxs, /// All processing is complete. Finished, } @@ -35,8 +33,7 @@ impl CanonicalStage { CanonicalStage::AssumedTxs => Self::AnchoredTxs, CanonicalStage::AnchoredTxs => Self::SeenTxs, CanonicalStage::SeenTxs => Self::LeftOverTxs, - CanonicalStage::LeftOverTxs => Self::TransitivelyAnchoredTxs, - CanonicalStage::TransitivelyAnchoredTxs => Self::Finished, + CanonicalStage::LeftOverTxs => Self::Finished, CanonicalStage::Finished => Self::Finished, }; } @@ -44,7 +41,7 @@ impl CanonicalStage { /// Modifies the canonicalization algorithm. #[derive(Debug, Default, Clone)] -pub struct CanonicalizationParams { +pub struct CanonicalParams { /// Transactions that will supersede all other transactions. /// /// In case of conflicting transactions within `assume_canonical`, transactions that appear @@ -52,8 +49,14 @@ pub struct CanonicalizationParams { pub assume_canonical: Vec, } -/// Manages the canonicalization process without direct I/O operations. -pub struct CanonicalizationTask<'g, A> { +/// Determines which transactions are canonical without resolving chain positions. +/// +/// This task implements the first phase of canonicalization: it walks the transaction +/// graph and determines which transactions are canonical (non-conflicting) and why +/// (via [`CanonicalReason`]). The output is a [`CanonicalTxs`] which can then be +/// further processed by [`CanonicalViewTask`] to resolve reasons into +/// [`ChainPosition`]s. +pub struct CanonicalTask<'g, A> { tx_graph: &'g TxGraph, chain_tip: BlockId, @@ -61,7 +64,6 @@ pub struct CanonicalizationTask<'g, A> { unprocessed_anchored_txs: VecDeque<(Txid, Arc, &'g BTreeSet)>, unprocessed_seen_txs: Box, u64)> + 'g>, unprocessed_leftover_txs: VecDeque<(Txid, Arc, u32)>, - unprocessed_transitively_anchored_txs: VecDeque<(Txid, Arc, &'g BTreeSet)>, canonical: CanonicalMap, not_canonical: NotCanonicalSet, @@ -69,15 +71,12 @@ pub struct CanonicalizationTask<'g, A> { // Store canonical transactions in order canonical_order: Vec, - // Track which transactions have direct anchors (not transitive) - direct_anchors: HashMap, - // Track the current stage of processing current_stage: CanonicalStage, } -impl<'g, A: Anchor> ChainQuery for CanonicalizationTask<'g, A> { - type Output = CanonicalView; +impl<'g, A: Anchor> ChainQuery for CanonicalTask<'g, A> { + type Output = CanonicalTxs; fn next_query(&mut self) -> Option { loop { @@ -130,18 +129,6 @@ impl<'g, A: Anchor> ChainQuery for CanonicalizationTask<'g, A> { continue; } } - CanonicalStage::TransitivelyAnchoredTxs => { - if let Some((_txid, _, anchors)) = - self.unprocessed_transitively_anchored_txs.front() - { - let block_ids = - anchors.iter().map(|anchor| anchor.anchor_block()).collect(); - return Some(ChainRequest { - chain_tip: self.chain_tip, - block_ids, - }); - } - } CanonicalStage::Finished => return None, } @@ -150,9 +137,6 @@ impl<'g, A: Anchor> ChainQuery for CanonicalizationTask<'g, A> { } fn resolve_query(&mut self, response: ChainResponse) { - // Only AnchoredTxs and TransitivelyAnchoredTxs stages should receive query - // responses Other stages don't generate queries and thus shouldn't call - // resolve_query match self.current_stage { CanonicalStage::AnchoredTxs => { // Process directly anchored transaction response @@ -168,7 +152,6 @@ impl<'g, A: Anchor> ChainQuery for CanonicalizationTask<'g, A> { match best_anchor { Some(best_anchor) => { // Transaction has a confirmed anchor - self.direct_anchors.insert(txid, best_anchor.clone()); if !self.is_canonicalized(txid) { self.mark_canonical( txid, @@ -195,28 +178,6 @@ impl<'g, A: Anchor> ChainQuery for CanonicalizationTask<'g, A> { } } } - CanonicalStage::TransitivelyAnchoredTxs => { - // Process transitively anchored transaction response - if let Some((txid, _tx, anchors)) = - self.unprocessed_transitively_anchored_txs.pop_front() - { - // Find the anchor that matches the confirmed BlockId - let best_anchor = response.and_then(|block_id| { - anchors - .iter() - .find(|anchor| anchor.anchor_block() == block_id) - .cloned() - }); - - if let Some(best_anchor) = best_anchor { - // Found a confirmed anchor for this transitively anchored transaction - self.direct_anchors.insert(txid, best_anchor.clone()); - // Note: We don't re-mark as canonical since it's already marked - // from being transitively anchored by its descendant - } - // If no confirmed anchor, we keep the transitive canonicalization status - } - } CanonicalStage::AssumedTxs | CanonicalStage::SeenTxs | CanonicalStage::LeftOverTxs @@ -232,7 +193,6 @@ impl<'g, A: Anchor> ChainQuery for CanonicalizationTask<'g, A> { } fn finish(self) -> Self::Output { - // Build the canonical view let mut view_order = Vec::new(); let mut view_txs = HashMap::new(); let mut view_spends = HashMap::new(); @@ -248,76 +208,17 @@ impl<'g, A: Anchor> ChainQuery for CanonicalizationTask<'g, A> { } } - // Get transaction node for first_seen/last_seen info - let tx_node = match self.tx_graph.get_tx_node(*txid) { - Some(tx_node) => tx_node, - None => { - debug_assert!(false, "tx node must exist!"); - continue; - } - }; - - // Determine chain position based on reason - let chain_position = match reason { - CanonicalReason::Assumed { descendant } => match descendant { - Some(_) => match self.direct_anchors.get(txid) { - Some(anchor) => ChainPosition::Confirmed { - anchor, - transitively: None, - }, - None => ChainPosition::Unconfirmed { - first_seen: tx_node.first_seen, - last_seen: tx_node.last_seen, - }, - }, - None => ChainPosition::Unconfirmed { - first_seen: tx_node.first_seen, - last_seen: tx_node.last_seen, - }, - }, - CanonicalReason::Anchor { anchor, descendant } => match descendant { - Some(_) => match self.direct_anchors.get(txid) { - Some(anchor) => ChainPosition::Confirmed { - anchor, - transitively: None, - }, - None => ChainPosition::Confirmed { - anchor, - transitively: *descendant, - }, - }, - None => ChainPosition::Confirmed { - anchor, - transitively: None, - }, - }, - CanonicalReason::ObservedIn { observed_in, .. } => match observed_in { - ObservedIn::Mempool(last_seen) => ChainPosition::Unconfirmed { - first_seen: tx_node.first_seen, - last_seen: Some(*last_seen), - }, - ObservedIn::Block(_) => ChainPosition::Unconfirmed { - first_seen: tx_node.first_seen, - last_seen: None, - }, - }, - }; - - view_txs.insert(*txid, (tx.clone(), chain_position.cloned())); + view_txs.insert(*txid, (tx.clone(), reason.clone())); } } - CanonicalView::new(self.chain_tip, view_order, view_txs, view_spends) + CanonicalTxs::new(self.chain_tip, view_order, view_txs, view_spends) } } -impl<'g, A: Anchor> CanonicalizationTask<'g, A> { +impl<'g, A: Anchor> CanonicalTask<'g, A> { /// Creates a new canonicalization task. - pub fn new( - tx_graph: &'g TxGraph, - chain_tip: BlockId, - params: CanonicalizationParams, - ) -> Self { + pub fn new(tx_graph: &'g TxGraph, chain_tip: BlockId, params: CanonicalParams) -> Self { let anchors = tx_graph.all_anchors(); let unprocessed_assumed_txs = Box::new( params @@ -344,13 +245,11 @@ impl<'g, A: Anchor> CanonicalizationTask<'g, A> { unprocessed_anchored_txs, unprocessed_seen_txs, unprocessed_leftover_txs: VecDeque::new(), - unprocessed_transitively_anchored_txs: VecDeque::new(), canonical: HashMap::new(), not_canonical: HashSet::new(), canonical_order: Vec::new(), - direct_anchors: HashMap::new(), current_stage: CanonicalStage::default(), } } @@ -380,8 +279,6 @@ impl<'g, A: Anchor> CanonicalizationTask<'g, A> { reason.clone() } else { // This is an ancestor being marked transitively - // Check if it has its own anchor that needs to be verified later - // We'll check anchors after marking it canonical reason.to_transitive(starting_txid) }; @@ -435,28 +332,199 @@ impl<'g, A: Anchor> CanonicalizationTask<'g, A> { } // Add to canonical order - for (txid, tx, reason) in &staged_canonical { + for (txid, _, _) in &staged_canonical { self.canonical_order.push(*txid); + } + } +} + +/// Represents the current stage of view task processing. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +enum ViewStage { + /// Processing transactions to resolve their chain positions. + #[default] + ResolvingPositions, + /// All processing is complete. + Finished, +} + +/// Resolves [`CanonicalReason`]s into [`ChainPosition`]s. +/// +/// This task implements the second phase of canonicalization: given a set of canonical +/// transactions with their reasons (from [`CanonicalTask`]), it resolves each reason +/// into a concrete [`ChainPosition`] (confirmed or unconfirmed). For transitively +/// anchored transactions, it queries the chain to check if they have their own direct +/// anchors. +pub struct CanonicalViewTask<'g, A> { + tx_graph: &'g TxGraph, + tip: BlockId, - // ObservedIn transactions don't need anchor verification - if matches!(reason, CanonicalReason::ObservedIn { .. }) { - continue; + /// Transactions in canonical order with their reasons. + canonical_order: Vec, + canonical_txs: HashMap, CanonicalReason)>, + spends: HashMap, + + /// Transactions that need anchor verification (transitively anchored). + unprocessed_anchor_checks: VecDeque<(Txid, &'g BTreeSet)>, + + /// Resolved direct anchors for transitively anchored transactions. + direct_anchors: HashMap, + + current_stage: ViewStage, +} + +impl<'g, A: Anchor> ChainQuery for CanonicalViewTask<'g, A> { + type Output = CanonicalView; + + fn next_query(&mut self) -> Option { + loop { + match self.current_stage { + ViewStage::ResolvingPositions => { + if let Some((_txid, anchors)) = self.unprocessed_anchor_checks.front() { + let block_ids = + anchors.iter().map(|anchor| anchor.anchor_block()).collect(); + return Some(ChainRequest { + chain_tip: self.tip, + 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.clone()) + } +} - // 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, - )); +impl CanonicalTxs { + /// Creates a [`CanonicalViewTask`] that resolves [`CanonicalReason`]s into [`ChainPosition`]s. + /// + /// This is the second phase of the canonicalization pipeline. The resulting task + /// queries the chain to verify anchors for transitively anchored transactions and + /// produces a [`CanonicalView`] with resolved chain positions. + pub fn view_task<'g>(self, tx_graph: &'g TxGraph) -> CanonicalViewTask<'g, A> { + let all_anchors = tx_graph.all_anchors(); + + // Find transactions that need anchor verification + let mut unprocessed_anchor_checks = VecDeque::new(); + for txid in &self.order { + if let Some((_, reason)) = self.txs.get(txid) { + // Skip ObservedIn transactions — they don't have anchors to verify + if matches!(reason, CanonicalReason::ObservedIn { .. }) { + continue; + } + // Transitively anchored transactions need their own anchor checked + if reason.is_transitive() { + if let Some(anchors) = all_anchors.get(txid) { + unprocessed_anchor_checks.push_back((*txid, anchors)); } } } } + + CanonicalViewTask { + tx_graph, + tip: self.tip, + canonical_order: self.order, + canonical_txs: self.txs, + spends: self.spends, + unprocessed_anchor_checks, + direct_anchors: HashMap::new(), + current_stage: ViewStage::default(), + } } } @@ -595,10 +663,12 @@ mod tests { }; let _ = tx_graph.insert_anchor(txid, anchor); - // Create canonicalization task and canonicalize using the chain - let params = CanonicalizationParams::default(); - let task = CanonicalizationTask::new(&tx_graph, chain_tip, params); - let canonical_view = chain.canonicalize(task); + // Create canonicalization task and canonicalize using the two-step pipeline + let params = CanonicalParams::default(); + let task = CanonicalTask::new(&tx_graph, chain_tip, params); + let canonical_txs = chain.canonicalize(task); + let view_task = canonical_txs.view_task(&tx_graph); + let canonical_view = chain.canonicalize(view_task); // Should have one canonical transaction assert_eq!(canonical_view.txs().len(), 1); diff --git a/crates/chain/src/canonical_view.rs b/crates/chain/src/canonical_view.rs index b89e9efe1..c4da9b2a0 100644 --- a/crates/chain/src/canonical_view.rs +++ b/crates/chain/src/canonical_view.rs @@ -6,14 +6,14 @@ //! ## Example //! //! ``` -//! # use bdk_chain::{TxGraph, CanonicalizationParams, CanonicalizationTask, local_chain::LocalChain}; +//! # use bdk_chain::{TxGraph, CanonicalParams, CanonicalTask, local_chain::LocalChain}; //! # use bdk_core::BlockId; //! # use bitcoin::hashes::Hash; //! # let tx_graph = TxGraph::::default(); //! # let chain = LocalChain::from_blocks([(0, bitcoin::BlockHash::all_zeros())].into_iter().collect()).unwrap(); //! let chain_tip = chain.tip().block_id(); -//! let params = CanonicalizationParams::default(); -//! let task = CanonicalizationTask::new(&tx_graph, chain_tip, params); +//! let params = CanonicalParams::default(); +//! let task = CanonicalTask::new(&tx_graph, chain_tip, params); //! let view = chain.canonicalize(task); //! //! // Iterate over canonical transactions @@ -33,25 +33,27 @@ use bitcoin::{Amount, OutPoint, ScriptBuf, Transaction, Txid}; use crate::{spk_txout::SpkTxOutIndex, Anchor, Balance, ChainPosition, FullTxOut}; -/// A single canonical transaction with its chain position. +/// A single canonical transaction with its position. /// /// This struct represents a transaction that has been determined to be canonical (not -/// conflicted). It includes the transaction itself along with its position in the chain (confirmed -/// or unconfirmed). +/// conflicted). It includes the transaction itself along with its position information. +/// The position type `P` is generic — it can be [`ChainPosition`] for resolved views, +/// or [`CanonicalReason`](crate::canonical_task::CanonicalReason) for unresolved canonicalization +/// results. #[derive(Clone, Debug, PartialEq, Eq)] -pub struct CanonicalTx { - /// The position of this transaction in the chain. +pub struct CanonicalTx

{ + /// The position of this transaction. /// - /// This indicates whether the transaction is confirmed (and at what height) or - /// unconfirmed (most likely pending in the mempool). - pub pos: ChainPosition, + /// When `P` is [`ChainPosition`], this indicates whether the transaction is confirmed + /// (and at what height) or unconfirmed (most likely pending in the mempool). + pub pos: P, /// The transaction ID (hash) of this transaction. pub txid: Txid, /// The full transaction. pub tx: Arc, } -impl Ord for CanonicalTx { +impl Ord for CanonicalTx

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

{ fn partial_cmp(&self, other: &Self) -> Option { Some(self.cmp(other)) } } -/// A view of canonical transactions from a [`TxGraph`]. +/// Canonical set of transactions from a [`TxGraph`]. /// -/// `CanonicalView` provides an ordered, conflict-resolved view of transactions. It determines +/// `Canonical` provides an ordered, conflict-resolved set of transactions. It determines /// which transactions are canonical (non-conflicted) based on the current chain state and /// provides methods to query transaction data, unspent outputs, and balances. /// +/// The position type `P` is generic: +/// - [`ChainPosition`] for resolved views (aka [`CanonicalView`]) +/// - [`CanonicalReason`](crate::canonical_task::CanonicalReason) for unresolved results (aka +/// [`CanonicalTxs`]) +/// /// The view maintains: /// - An ordered list of canonical transactions in topological-spending order /// - A mapping of outpoints to the transactions that spend them /// - The chain tip used for canonicalization #[derive(Debug)] -pub struct CanonicalView { - /// Ordered list of transaction IDs in in topological-spending order. - order: Vec, - /// Map of transaction IDs to their transaction data and chain position. - txs: HashMap, ChainPosition)>, +pub struct Canonical { + /// Ordered list of transaction IDs in topological-spending order. + pub(crate) order: Vec, + /// Map of transaction IDs to their transaction data and position. + pub(crate) txs: HashMap, P)>, /// Map of outpoints to the transaction ID that spends them. - spends: HashMap, + pub(crate) spends: HashMap, /// The chain tip at the time this view was created. - tip: BlockId, + pub(crate) tip: BlockId, + /// Marker for the anchor type. + pub(crate) _anchor: core::marker::PhantomData, } -impl CanonicalView { - /// Creates a [`CanonicalView`] from its constituent parts. +/// Type alias for canonical transactions with resolved [`ChainPosition`]s. +pub type CanonicalView = Canonical>; + +/// Type alias for canonical transactions with unresolved +/// [`CanonicalReason`](crate::canonical_task::CanonicalReason)s. +pub type CanonicalTxs = Canonical>; + +impl Canonical { + /// Creates a [`Canonical`] from its constituent parts. /// - /// This internal constructor is used by [`CanonicalizationTask`] to build the view + /// This internal constructor is used by [`CanonicalTask`] to build the canonical set /// after completing the canonicalization process. It takes the processed transaction - /// data including the canonical ordering, transaction map with chain positions, and + /// data including the canonical ordering, transaction map with positions, and /// spend information. pub(crate) fn new( tip: BlockId, order: Vec, - txs: HashMap, ChainPosition)>, + txs: HashMap, P)>, spends: HashMap, ) -> Self { Self { @@ -106,14 +122,20 @@ impl CanonicalView { order, txs, spends, + _anchor: core::marker::PhantomData, } } + /// Get the chain tip used to construct this canonical set. + pub fn tip(&self) -> BlockId { + self.tip + } + /// Get a single canonical transaction by its transaction ID. /// - /// Returns `Some(CanonicalViewTx)` if the transaction exists in the canonical view, + /// Returns `Some(CanonicalTx)` if the transaction exists in the canonical set, /// or `None` if the transaction doesn't exist or was excluded due to conflicts. - pub fn tx(&self, txid: Txid) -> Option> { + pub fn tx(&self, txid: Txid) -> Option> { self.txs .get(&txid) .cloned() @@ -126,10 +148,10 @@ impl CanonicalView { /// spent and by which transaction. /// /// Returns `None` if: - /// - The transaction doesn't exist in the canonical view + /// - The transaction doesn't exist in the canonical set /// - The output index is out of bounds /// - The transaction was excluded due to conflicts - pub fn txout(&self, op: OutPoint) -> Option> { + pub fn txout(&self, op: OutPoint) -> Option> { let (tx, pos) = self.txs.get(&op.txid)?; let vout: usize = op.vout.try_into().ok()?; let txout = tx.output.get(vout)?; @@ -138,7 +160,7 @@ impl CanonicalView { (spent_by_pos.clone(), *spent_by_txid) }); Some(FullTxOut { - chain_position: pos.clone(), + pos: pos.clone(), outpoint: op, txout: txout.clone(), spent_by, @@ -154,13 +176,13 @@ impl CanonicalView { /// # Example /// /// ``` - /// # use bdk_chain::{TxGraph, CanonicalizationTask, local_chain::LocalChain}; + /// # use bdk_chain::{TxGraph, CanonicalTask, local_chain::LocalChain}; /// # use bdk_core::BlockId; /// # use bitcoin::hashes::Hash; /// # let tx_graph = TxGraph::::default(); /// # let chain = LocalChain::from_blocks([(0, bitcoin::BlockHash::all_zeros())].into_iter().collect()).unwrap(); /// # let chain_tip = chain.tip().block_id(); - /// # let task = CanonicalizationTask::new(&tx_graph, chain_tip, Default::default()); + /// # let task = CanonicalTask::new(&tx_graph, chain_tip, Default::default()); /// # let view = chain.canonicalize(task); /// // Iterate over all canonical transactions /// for tx in view.txs() { @@ -170,7 +192,7 @@ impl CanonicalView { /// // Get the total number of canonical transactions /// println!("Total canonical transactions: {}", view.txs().len()); /// ``` - pub fn txs(&self) -> impl ExactSizeIterator> + DoubleEndedIterator + '_ { + pub fn txs(&self) -> impl ExactSizeIterator> + DoubleEndedIterator + '_ { self.order.iter().map(|&txid| { let (tx, pos) = self.txs[&txid].clone(); CanonicalTx { pos, txid, tx } @@ -180,7 +202,7 @@ impl CanonicalView { /// Get a filtered list of outputs from the given outpoints. /// /// This method takes an iterator of `(identifier, outpoint)` pairs and returns an iterator - /// of `(identifier, full_txout)` pairs for outpoints that exist in the canonical view. + /// of `(identifier, full_txout)` pairs for outpoints that exist in the canonical set. /// Non-existent outpoints are silently filtered out. /// /// The identifier type `O` is useful for tracking which outpoints correspond to which addresses @@ -189,13 +211,13 @@ impl CanonicalView { /// # Example /// /// ``` - /// # use bdk_chain::{TxGraph, CanonicalizationTask, local_chain::LocalChain, keychain_txout::KeychainTxOutIndex}; + /// # use bdk_chain::{TxGraph, CanonicalTask, local_chain::LocalChain, keychain_txout::KeychainTxOutIndex}; /// # use bdk_core::BlockId; /// # use bitcoin::hashes::Hash; /// # let tx_graph = TxGraph::::default(); /// # let chain = LocalChain::from_blocks([(0, bitcoin::BlockHash::all_zeros())].into_iter().collect()).unwrap(); /// # let chain_tip = chain.tip().block_id(); - /// # let task = CanonicalizationTask::new(&tx_graph, chain_tip, Default::default()); + /// # let task = CanonicalTask::new(&tx_graph, chain_tip, Default::default()); /// # let view = chain.canonicalize(task); /// # let indexer = KeychainTxOutIndex::<&str>::default(); /// // Get all outputs from an indexer @@ -206,7 +228,7 @@ impl CanonicalView { pub fn filter_outpoints<'v, O: Clone + 'v>( &'v self, outpoints: impl IntoIterator + 'v, - ) -> impl Iterator)> + 'v { + ) -> impl Iterator)> + 'v { outpoints .into_iter() .filter_map(|(op_i, op)| Some((op_i, self.txout(op)?))) @@ -220,13 +242,13 @@ impl CanonicalView { /// # Example /// /// ``` - /// # use bdk_chain::{TxGraph, CanonicalizationTask, local_chain::LocalChain, keychain_txout::KeychainTxOutIndex}; + /// # use bdk_chain::{TxGraph, CanonicalTask, local_chain::LocalChain, keychain_txout::KeychainTxOutIndex}; /// # use bdk_core::BlockId; /// # use bitcoin::hashes::Hash; /// # let tx_graph = TxGraph::::default(); /// # let chain = LocalChain::from_blocks([(0, bitcoin::BlockHash::all_zeros())].into_iter().collect()).unwrap(); /// # let chain_tip = chain.tip().block_id(); - /// # let task = CanonicalizationTask::new(&tx_graph, chain_tip, Default::default()); + /// # let task = CanonicalTask::new(&tx_graph, chain_tip, Default::default()); /// # let view = chain.canonicalize(task); /// # let indexer = KeychainTxOutIndex::<&str>::default(); /// // Get unspent outputs (UTXOs) from an indexer @@ -237,11 +259,40 @@ impl CanonicalView { pub fn filter_unspent_outpoints<'v, O: Clone + 'v>( &'v self, outpoints: impl IntoIterator + 'v, - ) -> impl Iterator)> + 'v { + ) -> impl Iterator)> + 'v { self.filter_outpoints(outpoints) .filter(|(_, txo)| txo.spent_by.is_none()) } + /// List transaction IDs that are expected to exist for the given script pubkeys. + /// + /// This method is primarily used for synchronization with external sources, helping to + /// identify which transactions are expected to exist for a set of script pubkeys. It's + /// commonly used with + /// [`SyncRequestBuilder::expected_spk_txids`](bdk_core::spk_client::SyncRequestBuilder::expected_spk_txids) + /// to inform sync operations about known transactions. + pub fn list_expected_spk_txids<'v, I>( + &'v self, + indexer: &'v impl AsRef>, + spk_index_range: impl RangeBounds + 'v, + ) -> impl Iterator + 'v + where + I: fmt::Debug + Clone + Ord + 'v, + { + let indexer = indexer.as_ref(); + self.txs().flat_map(move |c_tx| -> Vec<_> { + let range = &spk_index_range; + let relevant_spks = indexer.relevant_spks_of_tx(&c_tx.tx); + relevant_spks + .into_iter() + .filter(|(i, _)| range.contains(i)) + .map(|(_, spk)| (spk, c_tx.txid)) + .collect() + }) + } +} + +impl CanonicalView { /// Calculate the total balance of the given outpoints. /// /// This method computes a detailed balance breakdown for a set of outpoints, categorizing @@ -268,14 +319,13 @@ impl CanonicalView { /// # Example /// /// ``` - /// # use bdk_chain::{CanonicalizationTask, TxGraph, local_chain::LocalChain, keychain_txout::KeychainTxOutIndex}; + /// # use bdk_chain::{CanonicalParams, TxGraph, local_chain::LocalChain, keychain_txout::KeychainTxOutIndex}; /// # use bdk_core::BlockId; /// # use bitcoin::hashes::Hash; /// # let tx_graph = TxGraph::::default(); /// # let chain = LocalChain::from_blocks([(0, bitcoin::BlockHash::all_zeros())].into_iter().collect()).unwrap(); /// # let chain_tip = chain.tip().block_id(); - /// # let task = CanonicalizationTask::new(&tx_graph, chain_tip, Default::default()); - /// # let view = chain.canonicalize(task); + /// # let view = chain.canonical_view(&tx_graph, chain_tip, CanonicalParams::default()); /// # let indexer = KeychainTxOutIndex::<&str>::default(); /// // Calculate balance with 6 confirmations, trusting all outputs /// let balance = view.balance( @@ -287,7 +337,7 @@ impl CanonicalView { pub fn balance<'v, O: Clone + 'v>( &'v self, outpoints: impl IntoIterator + 'v, - mut trust_predicate: impl FnMut(&O, &FullTxOut) -> bool, + mut trust_predicate: impl FnMut(&O, &FullTxOut>) -> bool, min_confirmations: u32, ) -> Balance { let mut immature = Amount::ZERO; @@ -296,7 +346,7 @@ impl CanonicalView { let mut confirmed = Amount::ZERO; for (spk_i, txout) in self.filter_unspent_outpoints(outpoints) { - match &txout.chain_position { + match &txout.pos { ChainPosition::Confirmed { anchor, .. } => { let confirmation_height = anchor.confirmation_height_upper_bound(); let confirmations = self @@ -336,31 +386,4 @@ impl CanonicalView { confirmed, } } - - /// List transaction IDs that are expected to exist for the given script pubkeys. - /// - /// This method is primarily used for synchronization with external sources, helping to - /// identify which transactions are expected to exist for a set of script pubkeys. It's - /// commonly used with - /// [`SyncRequestBuilder::expected_spk_txids`](bdk_core::spk_client::SyncRequestBuilder::expected_spk_txids) - /// to inform sync operations about known transactions. - pub fn list_expected_spk_txids<'v, I>( - &'v self, - indexer: &'v impl AsRef>, - spk_index_range: impl RangeBounds + 'v, - ) -> impl Iterator + 'v - where - I: fmt::Debug + Clone + Ord + 'v, - { - let indexer = indexer.as_ref(); - self.txs().flat_map(move |c_tx| -> Vec<_> { - let range = &spk_index_range; - let relevant_spks = indexer.relevant_spks_of_tx(&c_tx.tx); - relevant_spks - .into_iter() - .filter(|(i, _)| range.contains(i)) - .map(|(_, spk)| (spk, c_tx.txid)) - .collect() - }) - } } diff --git a/crates/chain/src/chain_data.rs b/crates/chain/src/chain_data.rs index 43d41e2ed..9bbf3a85c 100644 --- a/crates/chain/src/chain_data.rs +++ b/crates/chain/src/chain_data.rs @@ -161,38 +161,42 @@ impl PartialOrd for ChainPosition { } } -/// A `TxOut` with as much data as we can retrieve about it +/// A `TxOut` with as much data as we can retrieve about it. +/// +/// The position type `P` is generic — it can be [`ChainPosition`] for resolved views, +/// or [`CanonicalReason`](crate::canonical_task::CanonicalReason) for unresolved canonicalization +/// results. #[derive(Debug, Clone, PartialEq, Eq)] -pub struct FullTxOut { +pub struct FullTxOut

{ /// The position of the transaction in `outpoint` in the overall chain. - pub chain_position: ChainPosition, + pub pos: P, /// The location of the `TxOut`. pub outpoint: OutPoint, /// The `TxOut`. pub txout: TxOut, - /// The txid and chain position of the transaction (if any) that has spent this output. - pub spent_by: Option<(ChainPosition, Txid)>, + /// The txid and position of the transaction (if any) that has spent this output. + pub spent_by: Option<(P, Txid)>, /// Whether this output is on a coinbase transaction. pub is_on_coinbase: bool, } -impl Ord for FullTxOut { +impl Ord for FullTxOut

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

{ fn partial_cmp(&self, other: &Self) -> Option { Some(self.cmp(other)) } } -impl FullTxOut { +impl FullTxOut> { /// Whether the `txout` is considered mature. /// /// Depending on the implementation of [`confirmation_height_upper_bound`] in [`Anchor`], this @@ -202,7 +206,7 @@ impl FullTxOut { /// [`confirmation_height_upper_bound`]: Anchor::confirmation_height_upper_bound pub fn is_mature(&self, tip: u32) -> bool { if self.is_on_coinbase { - let conf_height = match self.chain_position.confirmation_height_upper_bound() { + let conf_height = match self.pos.confirmation_height_upper_bound() { Some(height) => height, None => { debug_assert!(false, "coinbase tx can never be unconfirmed"); @@ -232,7 +236,7 @@ impl FullTxOut { return false; } - let conf_height = match self.chain_position.confirmation_height_upper_bound() { + let conf_height = match self.pos.confirmation_height_upper_bound() { Some(height) => height, None => return false, }; diff --git a/crates/chain/src/indexed_tx_graph.rs b/crates/chain/src/indexed_tx_graph.rs index 0745d6132..e486ad2e3 100644 --- a/crates/chain/src/indexed_tx_graph.rs +++ b/crates/chain/src/indexed_tx_graph.rs @@ -7,7 +7,7 @@ use bitcoin::{Block, OutPoint, Transaction, TxOut, Txid}; use crate::{ tx_graph::{self, TxGraph}, - Anchor, BlockId, CanonicalizationParams, CanonicalizationTask, Indexer, Merge, TxPosInBlock, + Anchor, BlockId, CanonicalParams, CanonicalTask, Indexer, Merge, TxPosInBlock, }; /// A [`TxGraph`] paired with an indexer `I`, enforcing that every insertion into the graph is @@ -432,18 +432,18 @@ impl IndexedTxGraph where A: Anchor, { - /// Creates a `[CanonicalizationTask]` to determine the `[CanonicalView]` of transactions. + /// Creates a [`CanonicalTask`] to determine the [`CanonicalView`] of transactions. /// - /// This method delegates to the underlying [`TxGraph`] to create a [`CanonicalizationTask`] + /// This method delegates to the underlying [`TxGraph`] to create a [`CanonicalTask`] /// that can be used to determine which transactions are canonical based on the provided /// parameters. The task handles the stateless canonicalization logic and can be polled /// for anchor verification requests. - pub fn canonicalization_task( + pub fn canonical_task( &'_ self, chain_tip: BlockId, - params: CanonicalizationParams, - ) -> CanonicalizationTask<'_, A> { - self.graph.canonicalization_task(chain_tip, params) + params: CanonicalParams, + ) -> CanonicalTask<'_, A> { + self.graph.canonical_task(chain_tip, params) } } diff --git a/crates/chain/src/local_chain.rs b/crates/chain/src/local_chain.rs index c3dc81101..4301fb72b 100644 --- a/crates/chain/src/local_chain.rs +++ b/crates/chain/src/local_chain.rs @@ -5,7 +5,7 @@ use core::fmt; use core::ops::RangeBounds; use crate::collections::BTreeMap; -use crate::{BlockId, ChainOracle, Merge}; +use crate::{Anchor, BlockId, CanonicalParams, CanonicalView, ChainOracle, Merge, TxGraph}; use bdk_core::{ChainQuery, ToBlockHash}; pub use bdk_core::{CheckPoint, CheckPointIter}; use bitcoin::block::Header; @@ -134,13 +134,13 @@ impl LocalChain { /// # Example /// /// ``` - /// # use bdk_chain::{CanonicalizationTask, CanonicalizationParams, TxGraph, local_chain::LocalChain}; + /// # use bdk_chain::{CanonicalTask, CanonicalParams, TxGraph, local_chain::LocalChain}; /// # use bdk_core::BlockId; /// # use bitcoin::hashes::Hash; /// # let tx_graph: TxGraph = TxGraph::default(); /// # let chain = LocalChain::from_blocks([(0, bitcoin::BlockHash::all_zeros())].into_iter().collect()).unwrap(); /// let chain_tip = chain.tip().block_id(); - /// let task = CanonicalizationTask::new(&tx_graph, chain_tip, CanonicalizationParams::default()); + /// let task = CanonicalTask::new(&tx_graph, chain_tip, CanonicalParams::default()); /// let view = chain.canonicalize(task); /// ``` pub fn canonicalize(&self, mut task: Q) -> Q::Output @@ -170,6 +170,23 @@ impl LocalChain { task.finish() } + /// Convenience method that runs both canonicalization phases and returns a [`CanonicalView`]. + /// + /// This is equivalent to: + /// ```ignore + /// let canonical_txs = chain.canonicalize(tx_graph.canonical_task(tip, params)); + /// let view = chain.canonicalize(canonical_txs.view_task(tx_graph)); + /// ``` + pub fn canonical_view( + &self, + tx_graph: &TxGraph, + tip: BlockId, + params: CanonicalParams, + ) -> CanonicalView { + let canonical_txs = self.canonicalize(tx_graph.canonical_task(tip, params)); + self.canonicalize(canonical_txs.view_task(tx_graph)) + } + /// Update the chain with a given [`Header`] at `height` which you claim is connected to a /// existing block in the chain. /// diff --git a/crates/chain/src/tx_graph.rs b/crates/chain/src/tx_graph.rs index 6de24e3cf..2fcf26bcd 100644 --- a/crates/chain/src/tx_graph.rs +++ b/crates/chain/src/tx_graph.rs @@ -23,10 +23,9 @@ //! //! The canonicalization process uses a two-step, sans-IO approach: //! -//! 1. **Create a canonicalization task** using -//! [`canonicalization_task`](TxGraph::canonicalization_task): ```ignore let task = -//! tx_graph.canonicalization_task(params); ``` This creates a [`CanonicalizationTask`] that -//! encapsulates the canonicalization logic without performing any I/O operations. +//! 1. **Create a canonicalization task** using [`canonical_task`](TxGraph::canonical_task): +//! ```ignore let task = tx_graph.canonical_task(params); ``` This creates a [`CanonicalTask`] +//! that encapsulates the canonicalization logic without performing any I/O operations. //! //! 2. **Execute the task** with a chain oracle to obtain a [`CanonicalView`]: ```ignore let view = //! chain.canonicalize(task); ``` The chain oracle (such as @@ -127,8 +126,8 @@ //! [`insert_txout`]: TxGraph::insert_txout use crate::collections::*; -use crate::CanonicalizationParams; -use crate::CanonicalizationTask; +use crate::CanonicalParams; +use crate::CanonicalTask; use crate::{Anchor, BlockId, Merge}; use alloc::collections::vec_deque::VecDeque; use alloc::sync::Arc; @@ -973,18 +972,18 @@ impl TxGraph { } } - /// Creates a [`CanonicalizationTask`] to determine the [`CanonicalView`] of transactions. + /// Creates a [`CanonicalTask`] to determine the [`CanonicalView`] of transactions. /// - /// This method delegates to the underlying [`TxGraph`] to create a [`CanonicalizationTask`] + /// This method delegates to the underlying [`TxGraph`] to create a [`CanonicalTask`] /// that can be used to determine which transactions are canonical based on the provided /// parameters. The task handles the stateless canonicalization logic and can be polled /// for anchor verification requests. - pub fn canonicalization_task( + pub fn canonical_task( &'_ self, chain_tip: BlockId, - params: CanonicalizationParams, - ) -> CanonicalizationTask<'_, A> { - CanonicalizationTask::new(self, chain_tip, params) + params: CanonicalParams, + ) -> CanonicalTask<'_, A> { + CanonicalTask::new(self, chain_tip, params) } } diff --git a/crates/chain/tests/common/tx_template.rs b/crates/chain/tests/common/tx_template.rs index 29f36169a..7bdac78a5 100644 --- a/crates/chain/tests/common/tx_template.rs +++ b/crates/chain/tests/common/tx_template.rs @@ -4,7 +4,7 @@ use bdk_testenv::utils::DESCRIPTORS; use rand::distributions::{Alphanumeric, DistString}; use std::collections::HashMap; -use bdk_chain::{spk_txout::SpkTxOutIndex, tx_graph::TxGraph, Anchor, CanonicalizationParams}; +use bdk_chain::{spk_txout::SpkTxOutIndex, tx_graph::TxGraph, Anchor, CanonicalParams}; use bitcoin::{ locktime::absolute::LockTime, secp256k1::Secp256k1, transaction, Amount, OutPoint, ScriptBuf, Sequence, Transaction, TxIn, TxOut, Txid, Witness, @@ -57,7 +57,7 @@ pub struct TxTemplateEnv<'a, A> { pub tx_graph: TxGraph, pub indexer: SpkTxOutIndex, pub txid_to_name: HashMap<&'a str, Txid>, - pub canonicalization_params: CanonicalizationParams, + pub canonicalization_params: CanonicalParams, } #[allow(dead_code)] @@ -79,7 +79,7 @@ pub fn init_graph<'a, A: Anchor + Clone + 'a>( }); let mut txid_to_name = HashMap::<&'a str, Txid>::new(); - let mut canonicalization_params = CanonicalizationParams::default(); + let mut canonicalization_params = CanonicalParams::default(); for (bogus_txin_vout, tx_tmp) in tx_templates.into_iter().enumerate() { let tx = Transaction { version: transaction::Version::non_standard(0), diff --git a/crates/chain/tests/test_canonical_view.rs b/crates/chain/tests/test_canonical_view.rs index 47bab2758..47547c16c 100644 --- a/crates/chain/tests/test_canonical_view.rs +++ b/crates/chain/tests/test_canonical_view.rs @@ -2,7 +2,7 @@ use std::collections::BTreeMap; -use bdk_chain::{local_chain::LocalChain, CanonicalizationParams, ConfirmationBlockTime, TxGraph}; +use bdk_chain::{local_chain::LocalChain, CanonicalParams, ConfirmationBlockTime, TxGraph}; use bdk_testenv::{hash, utils::new_tx}; use bitcoin::{Amount, BlockHash, OutPoint, ScriptBuf, Transaction, TxIn, TxOut}; @@ -54,8 +54,7 @@ fn test_min_confirmations_parameter() { let _ = tx_graph.insert_anchor(txid, anchor_height_5); let chain_tip = chain.tip().block_id(); - let task = tx_graph.canonicalization_task(chain_tip, CanonicalizationParams::default()); - let canonical_view = chain.canonicalize(task); + let canonical_view = chain.canonical_view(&tx_graph, chain_tip, CanonicalParams::default()); // Test min_confirmations = 1: Should be confirmed (has 6 confirmations) let balance_1_conf = canonical_view.balance( @@ -143,8 +142,7 @@ fn test_min_confirmations_with_untrusted_tx() { let _ = tx_graph.insert_anchor(txid, anchor); let chain_tip = chain.tip().block_id(); - let task = tx_graph.canonicalization_task(chain_tip, CanonicalizationParams::default()); - let canonical_view = chain.canonicalize(task); + let canonical_view = chain.canonical_view(&tx_graph, chain_tip, CanonicalParams::default()); // Test with min_confirmations = 5 and untrusted predicate let balance = canonical_view.balance( @@ -262,8 +260,7 @@ fn test_min_confirmations_multiple_transactions() { outpoints.push(((), outpoint2)); let chain_tip = chain.tip().block_id(); - let task = tx_graph.canonicalization_task(chain_tip, CanonicalizationParams::default()); - let canonical_view = chain.canonicalize(task); + let canonical_view = chain.canonical_view(&tx_graph, chain_tip, CanonicalParams::default()); // Test with min_confirmations = 5 // tx0: 11 confirmations -> confirmed diff --git a/crates/chain/tests/test_indexed_tx_graph.rs b/crates/chain/tests/test_indexed_tx_graph.rs index 6855a58f2..27e9b972e 100644 --- a/crates/chain/tests/test_indexed_tx_graph.rs +++ b/crates/chain/tests/test_indexed_tx_graph.rs @@ -10,7 +10,7 @@ use bdk_chain::{ indexer::keychain_txout::KeychainTxOutIndex, local_chain::LocalChain, spk_txout::SpkTxOutIndex, - tx_graph, Balance, CanonicalizationParams, ChainPosition, ConfirmationBlockTime, DescriptorExt, + tx_graph, Balance, CanonicalParams, ChainPosition, ConfirmationBlockTime, DescriptorExt, SpkIterator, }; use bdk_testenv::{ @@ -470,35 +470,28 @@ fn test_list_owned_txouts() { .get(height) .map(|cp| cp.block_id()) .unwrap_or_else(|| panic!("block must exist at {height}")); - let task = graph - .graph() - .canonicalization_task(chain_tip, CanonicalizationParams::default()); let txouts = local_chain - .canonicalize(task) + .canonical_view(graph.graph(), chain_tip, CanonicalParams::default()) .filter_outpoints(graph.index.outpoints().iter().cloned()) .collect::>(); - let task = graph - .graph() - .canonicalization_task(chain_tip, CanonicalizationParams::default()); let utxos = local_chain - .canonicalize(task) + .canonical_view(graph.graph(), chain_tip, CanonicalParams::default()) .filter_unspent_outpoints(graph.index.outpoints().iter().cloned()) .collect::>(); - let task = graph - .graph() - .canonicalization_task(chain_tip, CanonicalizationParams::default()); - let balance = local_chain.canonicalize(task).balance( - graph.index.outpoints().iter().cloned(), - |_, txout| trusted_spks.contains(&txout.txout.script_pubkey), - 0, - ); + let balance = local_chain + .canonical_view(graph.graph(), chain_tip, CanonicalParams::default()) + .balance( + graph.index.outpoints().iter().cloned(), + |_, txout| trusted_spks.contains(&txout.txout.script_pubkey), + 0, + ); let confirmed_txouts_txid = txouts .iter() .filter_map(|(_, full_txout)| { - if full_txout.chain_position.is_confirmed() { + if full_txout.pos.is_confirmed() { Some(full_txout.outpoint.txid) } else { None @@ -509,7 +502,7 @@ fn test_list_owned_txouts() { let unconfirmed_txouts_txid = txouts .iter() .filter_map(|(_, full_txout)| { - if !full_txout.chain_position.is_confirmed() { + if !full_txout.pos.is_confirmed() { Some(full_txout.outpoint.txid) } else { None @@ -520,7 +513,7 @@ fn test_list_owned_txouts() { let confirmed_utxos_txid = utxos .iter() .filter_map(|(_, full_txout)| { - if full_txout.chain_position.is_confirmed() { + if full_txout.pos.is_confirmed() { Some(full_txout.outpoint.txid) } else { None @@ -531,7 +524,7 @@ fn test_list_owned_txouts() { let unconfirmed_utxos_txid = utxos .iter() .filter_map(|(_, full_txout)| { - if !full_txout.chain_position.is_confirmed() { + if !full_txout.pos.is_confirmed() { Some(full_txout.outpoint.txid) } else { None @@ -797,16 +790,16 @@ fn test_get_chain_position() { // check chain position let chain_tip = chain.tip().block_id(); - let task = graph - .graph() - .canonicalization_task(chain_tip, CanonicalizationParams::default()); - let chain_pos = chain.canonicalize(task).txs().find_map(|canon_tx| { - if canon_tx.txid == txid { - Some(canon_tx.pos) - } else { - None - } - }); + let chain_pos = chain + .canonical_view(graph.graph(), chain_tip, CanonicalParams::default()) + .txs() + .find_map(|canon_tx| { + if canon_tx.txid == txid { + Some(canon_tx.pos) + } else { + None + } + }); assert_eq!(chain_pos, exp_pos, "failed test case: {name}"); } diff --git a/crates/chain/tests/test_tx_graph.rs b/crates/chain/tests/test_tx_graph.rs index 2b7ebf847..2e10395d1 100644 --- a/crates/chain/tests/test_tx_graph.rs +++ b/crates/chain/tests/test_tx_graph.rs @@ -2,7 +2,7 @@ #[macro_use] mod common; -use bdk_chain::{collections::*, BlockId, CanonicalizationParams, ConfirmationBlockTime}; +use bdk_chain::{collections::*, BlockId, CanonicalParams, ConfirmationBlockTime}; use bdk_chain::{ local_chain::LocalChain, tx_graph::{self, CalculateFeeError}, @@ -1014,10 +1014,8 @@ fn test_chain_spends() { let build_canonical_spends = |chain: &LocalChain, tx_graph: &TxGraph| -> HashMap { - let task = - tx_graph.canonicalization_task(tip.block_id(), CanonicalizationParams::default()); chain - .canonicalize(task) + .canonical_view(tx_graph, tip.block_id(), CanonicalParams::default()) .filter_outpoints(tx_graph.all_txouts().map(|(op, _)| ((), op))) .filter_map(|(_, full_txo)| Some((full_txo.outpoint, full_txo.spent_by?))) .collect() @@ -1025,10 +1023,8 @@ fn test_chain_spends() { let build_canonical_positions = |chain: &LocalChain, tx_graph: &TxGraph| -> HashMap> { - let task = - tx_graph.canonicalization_task(tip.block_id(), CanonicalizationParams::default()); chain - .canonicalize(task) + .canonical_view(tx_graph, tip.block_id(), CanonicalParams::default()) .txs() .map(|canon_tx| (canon_tx.txid, canon_tx.pos)) .collect() @@ -1202,15 +1198,16 @@ fn transactions_inserted_into_tx_graph_are_not_canonical_until_they_have_an_anch .collect(); let chain = LocalChain::from_blocks(blocks).unwrap(); let chain_tip = chain.tip().block_id(); - let task = graph.canonicalization_task(chain_tip, CanonicalizationParams::default()); - let canonical_txs: Vec<_> = chain.canonicalize(task).txs().collect(); + let canonical_txs: Vec<_> = chain + .canonical_view(&graph, chain_tip, CanonicalParams::default()) + .txs() + .collect(); assert!(canonical_txs.is_empty()); // tx0 with seen_at should be returned by canonical txs let _ = graph.insert_seen_at(txids[0], 2); let chain_tip = chain.tip().block_id(); - let task = graph.canonicalization_task(chain_tip, CanonicalizationParams::default()); - let canonical_view = chain.canonicalize(task); + let canonical_view = chain.canonical_view(&graph, chain_tip, CanonicalParams::default()); let mut canonical_txs = canonical_view.txs(); assert_eq!(canonical_txs.next().map(|tx| tx.txid).unwrap(), txids[0]); drop(canonical_txs); @@ -1218,8 +1215,11 @@ fn transactions_inserted_into_tx_graph_are_not_canonical_until_they_have_an_anch // tx1 with anchor is also canonical let _ = graph.insert_anchor(txids[1], block_id!(2, "B")); let chain_tip = chain.tip().block_id(); - let task = graph.canonicalization_task(chain_tip, CanonicalizationParams::default()); - let canonical_txids: Vec<_> = chain.canonicalize(task).txs().map(|tx| tx.txid).collect(); + let canonical_txids: Vec<_> = chain + .canonical_view(&graph, chain_tip, CanonicalParams::default()) + .txs() + .map(|tx| tx.txid) + .collect(); assert!(canonical_txids.contains(&txids[1])); assert!(graph.txs_with_no_anchor_or_last_seen().next().is_none()); } diff --git a/crates/chain/tests/test_tx_graph_conflicts.rs b/crates/chain/tests/test_tx_graph_conflicts.rs index de6325914..45ecc7762 100644 --- a/crates/chain/tests/test_tx_graph_conflicts.rs +++ b/crates/chain/tests/test_tx_graph_conflicts.rs @@ -970,11 +970,12 @@ fn test_tx_conflict_handling() { for scenario in scenarios { let env = init_graph(scenario.tx_templates.iter()); - let task = env - .tx_graph - .canonicalization_task(chain_tip, env.canonicalization_params.clone()); let txs = local_chain - .canonicalize(task) + .canonical_view( + &env.tx_graph, + chain_tip, + env.canonicalization_params.clone(), + ) .txs() .map(|tx| tx.txid) .collect::>(); @@ -989,11 +990,12 @@ fn test_tx_conflict_handling() { scenario.name ); - let task = env - .tx_graph - .canonicalization_task(chain_tip, env.canonicalization_params.clone()); let txouts = local_chain - .canonicalize(task) + .canonical_view( + &env.tx_graph, + chain_tip, + env.canonicalization_params.clone(), + ) .filter_outpoints(env.indexer.outpoints().iter().cloned()) .map(|(_, full_txout)| full_txout.outpoint) .collect::>(); @@ -1011,11 +1013,12 @@ fn test_tx_conflict_handling() { scenario.name ); - let task = env - .tx_graph - .canonicalization_task(chain_tip, env.canonicalization_params.clone()); let utxos = local_chain - .canonicalize(task) + .canonical_view( + &env.tx_graph, + chain_tip, + env.canonicalization_params.clone(), + ) .filter_unspent_outpoints(env.indexer.outpoints().iter().cloned()) .map(|(_, full_txout)| full_txout.outpoint) .collect::>(); @@ -1033,18 +1036,21 @@ fn test_tx_conflict_handling() { scenario.name ); - let task = env - .tx_graph - .canonicalization_task(chain_tip, env.canonicalization_params.clone()); - let balance = local_chain.canonicalize(task).balance( - env.indexer.outpoints().iter().cloned(), - |_, txout| { - env.indexer - .index_of_spk(txout.txout.script_pubkey.as_script()) - .is_some() - }, - 0, - ); + let balance = local_chain + .canonical_view( + &env.tx_graph, + chain_tip, + env.canonicalization_params.clone(), + ) + .balance( + env.indexer.outpoints().iter().cloned(), + |_, txout| { + env.indexer + .index_of_spk(txout.txout.script_pubkey.as_script()) + .is_some() + }, + 0, + ); assert_eq!( balance, scenario.exp_balance, "\n[{}] 'balance' failed", diff --git a/crates/electrum/tests/test_electrum.rs b/crates/electrum/tests/test_electrum.rs index fada6e19f..efe789465 100644 --- a/crates/electrum/tests/test_electrum.rs +++ b/crates/electrum/tests/test_electrum.rs @@ -3,8 +3,7 @@ use bdk_chain::{ local_chain::LocalChain, spk_client::{FullScanRequest, SyncRequest, SyncResponse}, spk_txout::SpkTxOutIndex, - Balance, CanonicalizationParams, ConfirmationBlockTime, IndexedTxGraph, Indexer, Merge, - TxGraph, + Balance, CanonicalParams, ConfirmationBlockTime, IndexedTxGraph, Indexer, Merge, TxGraph, }; use bdk_core::bitcoin::{ key::{Secp256k1, UntweakedPublicKey}, @@ -40,11 +39,8 @@ fn get_balance( ) -> anyhow::Result { let chain_tip = recv_chain.tip().block_id(); let outpoints = recv_graph.index.outpoints().clone(); - let task = recv_graph - .graph() - .canonicalization_task(chain_tip, CanonicalizationParams::default()); let balance = recv_chain - .canonicalize(task) + .canonical_view(recv_graph.graph(), chain_tip, CanonicalParams::default()) .balance(outpoints, |_, _| true, 0); Ok(balance) } @@ -159,12 +155,9 @@ pub fn detect_receive_tx_cancel() -> anyhow::Result<()> { .chain_tip(chain.tip()) .spks_with_indexes(graph.index.all_spks().clone()) .expected_spk_txids( - { - let chain_tip = chain.tip().block_id(); - let task = graph.canonicalization_task(chain_tip, Default::default()); - chain.canonicalize(task) - } - .list_expected_spk_txids(&graph.index, ..), + chain + .canonical_view(graph.graph(), chain.tip().block_id(), Default::default()) + .list_expected_spk_txids(&graph.index, ..), ); let sync_response = client.sync(sync_request, BATCH_SIZE, true)?; assert!( @@ -192,12 +185,9 @@ pub fn detect_receive_tx_cancel() -> anyhow::Result<()> { .chain_tip(chain.tip()) .spks_with_indexes(graph.index.all_spks().clone()) .expected_spk_txids( - { - let chain_tip = chain.tip().block_id(); - let task = graph.canonicalization_task(chain_tip, Default::default()); - chain.canonicalize(task) - } - .list_expected_spk_txids(&graph.index, ..), + chain + .canonical_view(graph.graph(), chain.tip().block_id(), Default::default()) + .list_expected_spk_txids(&graph.index, ..), ); let sync_response = client.sync(sync_request, BATCH_SIZE, true)?; assert!( diff --git a/crates/esplora/tests/async_ext.rs b/crates/esplora/tests/async_ext.rs index c18b94071..1958190a7 100644 --- a/crates/esplora/tests/async_ext.rs +++ b/crates/esplora/tests/async_ext.rs @@ -100,12 +100,9 @@ pub async fn detect_receive_tx_cancel() -> anyhow::Result<()> { .chain_tip(chain.tip()) .spks_with_indexes(graph.index.all_spks().clone()) .expected_spk_txids( - { - let chain_tip = chain.tip().block_id(); - let task = graph.canonicalization_task(chain_tip, Default::default()); - chain.canonicalize(task) - } - .list_expected_spk_txids(&graph.index, ..), + chain + .canonical_view(graph.graph(), chain.tip().block_id(), Default::default()) + .list_expected_spk_txids(&graph.index, ..), ); let sync_response = client.sync(sync_request, 1).await?; assert!( @@ -133,12 +130,9 @@ pub async fn detect_receive_tx_cancel() -> anyhow::Result<()> { .chain_tip(chain.tip()) .spks_with_indexes(graph.index.all_spks().clone()) .expected_spk_txids( - { - let chain_tip = chain.tip().block_id(); - let task = graph.canonicalization_task(chain_tip, Default::default()); - chain.canonicalize(task) - } - .list_expected_spk_txids(&graph.index, ..), + chain + .canonical_view(graph.graph(), chain.tip().block_id(), Default::default()) + .list_expected_spk_txids(&graph.index, ..), ); let sync_response = client.sync(sync_request, 1).await?; assert!( diff --git a/crates/esplora/tests/blocking_ext.rs b/crates/esplora/tests/blocking_ext.rs index f46617725..4855638fe 100644 --- a/crates/esplora/tests/blocking_ext.rs +++ b/crates/esplora/tests/blocking_ext.rs @@ -97,12 +97,9 @@ pub fn detect_receive_tx_cancel() -> anyhow::Result<()> { .chain_tip(chain.tip()) .spks_with_indexes(graph.index.all_spks().clone()) .expected_spk_txids( - { - let chain_tip = chain.tip().block_id(); - let task = graph.canonicalization_task(chain_tip, Default::default()); - chain.canonicalize(task) - } - .list_expected_spk_txids(&graph.index, ..), + chain + .canonical_view(graph.graph(), chain.tip().block_id(), Default::default()) + .list_expected_spk_txids(&graph.index, ..), ); let sync_response = client.sync(sync_request, 1)?; assert!( @@ -130,12 +127,9 @@ pub fn detect_receive_tx_cancel() -> anyhow::Result<()> { .chain_tip(chain.tip()) .spks_with_indexes(graph.index.all_spks().clone()) .expected_spk_txids( - { - let chain_tip = chain.tip().block_id(); - let task = graph.canonicalization_task(chain_tip, Default::default()); - chain.canonicalize(task) - } - .list_expected_spk_txids(&graph.index, ..), + chain + .canonical_view(graph.graph(), chain.tip().block_id(), Default::default()) + .list_expected_spk_txids(&graph.index, ..), ); let sync_response = client.sync(sync_request, 1)?; assert!( diff --git a/examples/example_bitcoind_rpc_polling/src/main.rs b/examples/example_bitcoind_rpc_polling/src/main.rs index 61e8b05ea..5a97a3c4c 100644 --- a/examples/example_bitcoind_rpc_polling/src/main.rs +++ b/examples/example_bitcoind_rpc_polling/src/main.rs @@ -11,7 +11,7 @@ use bdk_bitcoind_rpc::{ bitcoincore_rpc::{Auth, Client, RpcApi}, Emitter, }; -use bdk_chain::{bitcoin::Block, local_chain, CanonicalizationParams, Merge}; +use bdk_chain::{bitcoin::Block, local_chain, CanonicalParams, Merge}; use example_cli::{ anyhow, clap::{self, Args, Subcommand}, @@ -144,16 +144,15 @@ fn main() -> anyhow::Result<()> { &rpc_client, chain.tip(), fallback_height, - { - let chain_tip = chain.tip().block_id(); - let task = graph - .graph() - .canonicalization_task(chain_tip, CanonicalizationParams::default()); - chain.canonicalize(task) - } - .txs() - .filter(|tx| tx.pos.is_unconfirmed()) - .map(|tx| tx.tx), + chain + .canonical_view( + graph.graph(), + chain.tip().block_id(), + CanonicalParams::default(), + ) + .txs() + .filter(|tx| tx.pos.is_unconfirmed()) + .map(|tx| tx.tx), ) }; let mut db_stage = ChangeSet::default(); @@ -197,19 +196,17 @@ fn main() -> anyhow::Result<()> { last_print = Instant::now(); let synced_to = chain.tip(); let balance = { - { - let synced_to_block = synced_to.block_id(); - let task = graph.graph().canonicalization_task( - synced_to_block, - CanonicalizationParams::default(), - ); - chain.canonicalize(task) - } - .balance( - graph.index.outpoints().iter().cloned(), - |(k, _), _| k == &Keychain::Internal, - 0, - ) + chain + .canonical_view( + graph.graph(), + synced_to.block_id(), + CanonicalParams::default(), + ) + .balance( + graph.index.outpoints().iter().cloned(), + |(k, _), _| k == &Keychain::Internal, + 0, + ) }; println!( "[{:>10}s] synced to {} @ {} | total: {}", @@ -252,16 +249,15 @@ fn main() -> anyhow::Result<()> { rpc_client.clone(), chain.tip(), fallback_height, - { - let chain_tip = chain.tip().block_id(); - let task = graph - .graph() - .canonicalization_task(chain_tip, CanonicalizationParams::default()); - chain.canonicalize(task) - } - .txs() - .filter(|tx| tx.pos.is_unconfirmed()) - .map(|tx| tx.tx), + chain + .canonical_view( + graph.graph(), + chain.tip().block_id(), + CanonicalParams::default(), + ) + .txs() + .filter(|tx| tx.pos.is_unconfirmed()) + .map(|tx| tx.tx), ) }; @@ -360,19 +356,17 @@ fn main() -> anyhow::Result<()> { last_print = Some(Instant::now()); let synced_to = chain.tip(); let balance = { - { - let synced_to_block = synced_to.block_id(); - let task = graph.graph().canonicalization_task( - synced_to_block, - CanonicalizationParams::default(), - ); - chain.canonicalize(task) - } - .balance( - graph.index.outpoints().iter().cloned(), - |(k, _), _| k == &Keychain::Internal, - 0, - ) + chain + .canonical_view( + graph.graph(), + synced_to.block_id(), + CanonicalParams::default(), + ) + .balance( + graph.index.outpoints().iter().cloned(), + |(k, _), _| k == &Keychain::Internal, + 0, + ) }; println!( "[{:>10}s] synced to {} @ {} / {} | total: {}", diff --git a/examples/example_cli/src/lib.rs b/examples/example_cli/src/lib.rs index 53d373481..8975c0da4 100644 --- a/examples/example_cli/src/lib.rs +++ b/examples/example_cli/src/lib.rs @@ -19,12 +19,12 @@ use bdk_chain::miniscript::{ psbt::PsbtExt, Descriptor, DescriptorPublicKey, ForEachKey, }; -use bdk_chain::CanonicalizationParams; +use bdk_chain::CanonicalParams; use bdk_chain::ConfirmationBlockTime; use bdk_chain::{ indexer::keychain_txout::{self, KeychainTxOutIndex}, local_chain::{self, LocalChain}, - tx_graph, ChainOracle, DescriptorExt, FullTxOut, IndexedTxGraph, Merge, + tx_graph, ChainOracle, ChainPosition, DescriptorExt, FullTxOut, IndexedTxGraph, Merge, }; use bdk_coin_select::{ metrics::LowestFee, Candidate, ChangePolicy, CoinSelector, DrainWeights, FeeRate, Target, @@ -279,9 +279,9 @@ pub fn create_tx( plan_utxos.sort_by_key(|(_, utxo)| cmp::Reverse(utxo.txout.value)) } CoinSelectionAlgo::SmallestFirst => plan_utxos.sort_by_key(|(_, utxo)| utxo.txout.value), - CoinSelectionAlgo::OldestFirst => plan_utxos.sort_by_key(|(_, utxo)| utxo.chain_position), + CoinSelectionAlgo::OldestFirst => plan_utxos.sort_by_key(|(_, utxo)| utxo.pos), CoinSelectionAlgo::NewestFirst => { - plan_utxos.sort_by_key(|(_, utxo)| cmp::Reverse(utxo.chain_position)) + plan_utxos.sort_by_key(|(_, utxo)| cmp::Reverse(utxo.pos)) } CoinSelectionAlgo::BranchAndBound => plan_utxos.shuffle(&mut thread_rng()), } @@ -418,7 +418,7 @@ pub fn create_tx( } // Alias the elements of `planned_utxos` -pub type PlanUtxo = (Plan, FullTxOut); +pub type PlanUtxo = (Plan, FullTxOut>); pub fn planned_utxos( graph: &KeychainTxGraph, @@ -427,11 +427,8 @@ pub fn planned_utxos( ) -> Result, Infallible> { let chain_tip = chain.tip().block_id(); let outpoints = graph.index.outpoints(); - let task = graph - .graph() - .canonicalization_task(chain_tip, CanonicalizationParams::default()); chain - .canonicalize(task) + .canonical_view(graph.graph(), chain_tip, CanonicalParams::default()) .filter_unspent_outpoints(outpoints.iter().cloned()) .filter_map(|((k, i), full_txo)| -> Option> { let desc = graph @@ -524,14 +521,13 @@ pub fn handle_commands( } let chain_tip = chain.tip().block_id(); - let task = graph - .graph() - .canonicalization_task(chain_tip, CanonicalizationParams::default()); - let balance = chain.canonicalize(task).balance( - graph.index.outpoints().iter().cloned(), - |(k, _), _| k == &Keychain::Internal, - 1, - ); + let balance = chain + .canonical_view(graph.graph(), chain_tip, CanonicalParams::default()) + .balance( + graph.index.outpoints().iter().cloned(), + |(k, _), _| k == &Keychain::Internal, + 1, + ); let confirmed_total = balance.confirmed + balance.immature; let unconfirmed_total = balance.untrusted_pending + balance.trusted_pending; @@ -568,11 +564,8 @@ pub fn handle_commands( confirmed, unconfirmed, } => { - let task = graph - .graph() - .canonicalization_task(chain_tip, CanonicalizationParams::default()); let txouts = chain - .canonicalize(task) + .canonical_view(graph.graph(), chain_tip, CanonicalParams::default()) .filter_outpoints(outpoints.iter().cloned()) .filter(|(_, full_txo)| match (spent, unspent) { (true, false) => full_txo.spent_by.is_some(), @@ -580,8 +573,8 @@ pub fn handle_commands( _ => true, }) .filter(|(_, full_txo)| match (confirmed, unconfirmed) { - (true, false) => full_txo.chain_position.is_confirmed(), - (false, true) => !full_txo.chain_position.is_confirmed(), + (true, false) => full_txo.pos.is_confirmed(), + (false, true) => !full_txo.pos.is_confirmed(), _ => true, }) .collect::>(); diff --git a/examples/example_electrum/src/main.rs b/examples/example_electrum/src/main.rs index b216f3245..cc995a738 100644 --- a/examples/example_electrum/src/main.rs +++ b/examples/example_electrum/src/main.rs @@ -5,7 +5,7 @@ use bdk_chain::{ collections::BTreeSet, indexed_tx_graph, spk_client::{FullScanRequest, SyncRequest}, - CanonicalizationParams, ConfirmationBlockTime, Merge, + CanonicalParams, ConfirmationBlockTime, Merge, }; use bdk_electrum::{ electrum_client::{self, Client, ElectrumApi}, @@ -228,10 +228,8 @@ fn main() -> anyhow::Result<()> { }); let chain_tip_block = chain_tip.block_id(); - let task = graph - .graph() - .canonicalization_task(chain_tip_block, CanonicalizationParams::default()); - let canonical_view = chain.canonicalize(task); + let canonical_view = + chain.canonical_view(graph.graph(), chain_tip_block, CanonicalParams::default()); request = request .expected_spk_txids(canonical_view.list_expected_spk_txids(&graph.index, ..)); diff --git a/examples/example_esplora/src/main.rs b/examples/example_esplora/src/main.rs index f291722ef..288acfb67 100644 --- a/examples/example_esplora/src/main.rs +++ b/examples/example_esplora/src/main.rs @@ -8,7 +8,7 @@ use bdk_chain::{ bitcoin::Network, keychain_txout::FullScanRequestBuilderExt, spk_client::{FullScanRequest, SyncRequest}, - CanonicalizationParams, Merge, + CanonicalParams, Merge, }; use bdk_esplora::{esplora_client, EsploraExt}; use example_cli::{ @@ -238,10 +238,11 @@ fn main() -> anyhow::Result<()> { let graph = graph.lock().unwrap(); let chain = chain.lock().unwrap(); let local_tip_block = local_tip.block_id(); - let task = graph - .graph() - .canonicalization_task(local_tip_block, CanonicalizationParams::default()); - let canonical_view = chain.canonicalize(task); + let canonical_view = chain.canonical_view( + graph.graph(), + local_tip_block, + CanonicalParams::default(), + ); request = request .expected_spk_txids(canonical_view.list_expected_spk_txids(&graph.index, ..)); From 1e27917d456f1bb8d1b7723d0e51e4b79fdf4d34 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=BF=97=E5=AE=87?= Date: Fri, 13 Feb 2026 13:21:50 +0000 Subject: [PATCH 10/15] refactor(core,chain)!: move `chain_tip` from `ChainRequest` to `ChainQuery::tip()` The chain tip is constant for the lifetime of a query, so it belongs on the trait rather than being redundantly copied into every request. `ChainRequest` is now a type alias for `Vec`. Co-Authored-By: Claude Opus 4.6 --- crates/chain/src/canonical_task.rs | 18 ++++++++++-------- crates/chain/src/local_chain.rs | 9 ++------- crates/core/src/chain_query.rs | 14 +++++--------- 3 files changed, 17 insertions(+), 24 deletions(-) diff --git a/crates/chain/src/canonical_task.rs b/crates/chain/src/canonical_task.rs index 3991bf7e9..2ddf90816 100644 --- a/crates/chain/src/canonical_task.rs +++ b/crates/chain/src/canonical_task.rs @@ -78,6 +78,10 @@ pub struct CanonicalTask<'g, A> { impl<'g, A: Anchor> ChainQuery for CanonicalTask<'g, A> { type Output = CanonicalTxs; + fn tip(&self) -> BlockId { + self.chain_tip + } + fn next_query(&mut self) -> Option { loop { match self.current_stage { @@ -93,10 +97,7 @@ impl<'g, A: Anchor> ChainQuery for CanonicalTask<'g, A> { if let Some((_txid, _, anchors)) = self.unprocessed_anchored_txs.front() { let block_ids = anchors.iter().map(|anchor| anchor.anchor_block()).collect(); - return Some(ChainRequest { - chain_tip: self.chain_tip, - block_ids, - }); + return Some(block_ids); } } CanonicalStage::SeenTxs => { @@ -376,6 +377,10 @@ pub struct CanonicalViewTask<'g, A> { impl<'g, A: Anchor> ChainQuery for CanonicalViewTask<'g, A> { type Output = CanonicalView; + fn tip(&self) -> BlockId { + self.tip + } + fn next_query(&mut self) -> Option { loop { match self.current_stage { @@ -383,10 +388,7 @@ impl<'g, A: Anchor> ChainQuery for CanonicalViewTask<'g, A> { if let Some((_txid, anchors)) = self.unprocessed_anchor_checks.front() { let block_ids = anchors.iter().map(|anchor| anchor.anchor_block()).collect(); - return Some(ChainRequest { - chain_tip: self.tip, - block_ids, - }); + return Some(block_ids); } } ViewStage::Finished => return None, diff --git a/crates/chain/src/local_chain.rs b/crates/chain/src/local_chain.rs index 4301fb72b..31c72cb3a 100644 --- a/crates/chain/src/local_chain.rs +++ b/crates/chain/src/local_chain.rs @@ -147,13 +147,10 @@ impl LocalChain { where Q: ChainQuery, { - // Process all requests from the task + let chain_tip = task.tip(); while let Some(request) = task.next_query() { - let chain_tip = request.chain_tip; - - // Check each block ID and return the first confirmed one let mut best_block_id = None; - for block_id in &request.block_ids { + for block_id in &request { if self .is_block_in_chain(*block_id, chain_tip) .expect("infallible") @@ -165,8 +162,6 @@ impl LocalChain { } task.resolve_query(best_block_id); } - - // Return the finished canonical view task.finish() } diff --git a/crates/core/src/chain_query.rs b/crates/core/src/chain_query.rs index fe621492e..f225a50cb 100644 --- a/crates/core/src/chain_query.rs +++ b/crates/core/src/chain_query.rs @@ -7,17 +7,10 @@ use crate::BlockId; use alloc::vec::Vec; -/// A request to check which block identifiers are confirmed in the chain. +/// A request containing block identifiers to check for confirmation in the chain. /// -/// This is used to verify if specific blocks are part of the canonical chain. /// The generic parameter `B` represents the block identifier type, which defaults to `BlockId`. -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct ChainRequest { - /// The chain tip to use as reference for the query. - pub chain_tip: B, - /// The block identifiers to check for confirmation in the chain. - pub block_ids: Vec, -} +pub type ChainRequest = Vec; /// Response containing the best confirmed block identifier, if any. /// @@ -48,6 +41,9 @@ 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) -> B; + /// Returns the next query needed, if any. /// /// This method should return `Some(request)` if more information is needed, From e306a36dfb4de97c0f0bafbd6c52e68230bf6f10 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=BF=97=E5=AE=87?= Date: Fri, 13 Feb 2026 13:35:47 +0000 Subject: [PATCH 11/15] refactor(core)!: remove generics from `ChainQuery`, `ChainRequest`, `ChainResponse` These types only ever used `BlockId`, so the generic parameter added unnecessary complexity. All three are now hardcoded to `BlockId`. Co-Authored-By: Claude Opus 4.6 --- crates/chain/src/local_chain.rs | 2 +- crates/core/src/chain_query.rs | 62 ++++++++++----------------------- 2 files changed, 20 insertions(+), 44 deletions(-) diff --git a/crates/chain/src/local_chain.rs b/crates/chain/src/local_chain.rs index 31c72cb3a..5de8383e8 100644 --- a/crates/chain/src/local_chain.rs +++ b/crates/chain/src/local_chain.rs @@ -145,7 +145,7 @@ impl LocalChain { /// ``` pub fn canonicalize(&self, mut task: Q) -> Q::Output where - Q: ChainQuery, + Q: ChainQuery, { let chain_tip = task.tip(); while let Some(request) = task.next_query() { diff --git a/crates/core/src/chain_query.rs b/crates/core/src/chain_query.rs index f225a50cb..047d0bb50 100644 --- a/crates/core/src/chain_query.rs +++ b/crates/core/src/chain_query.rs @@ -1,64 +1,40 @@ -//! Generic trait for query-based operations that require external blockchain data. +//! Trait for query-based canonicalization against blockchain data. //! -//! The [`ChainQuery`] trait provides a standardized interface for implementing -//! algorithms that need to make queries to blockchain sources and process responses -//! in a sans-IO manner. +//! 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 block identifiers to check for confirmation in the chain. -/// -/// The generic parameter `B` represents the block identifier type, which defaults to `BlockId`. -pub type ChainRequest = Vec; +/// A request containing [`BlockId`]s to check for confirmation in the chain. +pub type ChainRequest = Vec; -/// Response containing the best confirmed block identifier, if any. -/// -/// Returns `Some(B)` if at least one of the requested blocks -/// is confirmed in the chain, or `None` if none are confirmed. -/// The generic parameter `B` represents the block identifier type, which defaults to `BlockId`. -pub type ChainResponse = Option; +/// Response containing the best confirmed [`BlockId`], if any. +pub type ChainResponse = Option; -/// A trait for types that perform query-based operations against blockchain data. -/// -/// This trait enables types to request blockchain information via queries and process -/// responses in a decoupled, sans-IO manner. It's particularly useful for algorithms -/// that need to interact with blockchain oracles, chain sources, or other blockchain -/// data providers without directly performing I/O. -/// -/// # Protocol +/// A trait for types that verify block confirmations against blockchain data. /// -/// Callers must drive the task by calling [`next_query`](Self::next_query) and -/// [`resolve_query`](Self::resolve_query) in a loop. `resolve_query` must only be called -/// after `next_query` returns `Some`. Once `next_query` returns `None`, call -/// [`finish`](Self::finish) to get the output. Calling `resolve_query` or `finish` out of -/// sequence is a programming error. +/// 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. /// -/// # Type Parameters -/// -/// * `B` - The type of block identifier used in queries (defaults to `BlockId`) -pub trait ChainQuery { +/// `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) -> B; + fn tip(&self) -> BlockId; - /// Returns the next query needed, if any. - /// - /// This method should return `Some(request)` if more information is needed, - /// or `None` if no more queries are required. - fn next_query(&mut self) -> Option>; + /// Returns the next query needed, or `None` if no more queries are required. + fn next_query(&mut self) -> Option; /// Resolves a query with the given response. - /// - /// This method processes the response to a previous query request and updates - /// the internal state accordingly. - fn resolve_query(&mut self, response: ChainResponse); + fn resolve_query(&mut self, response: ChainResponse); /// Completes the query process and returns the final output. /// - /// This method should be called once [`next_query`](Self::next_query) returns `None`. - /// It consumes `self` and produces the final output. + /// This should be called once [`next_query`](Self::next_query) returns `None`. fn finish(self) -> Self::Output; } From f07b1b356101a0bbf51b75d4ce7ad2be700f47cd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=BF=97=E5=AE=87?= Date: Fri, 13 Feb 2026 14:28:28 +0000 Subject: [PATCH 12/15] fix(chain): check anchors for assumed txs in `CanonicalViewTask` Assumed transactions bypass the `AnchoredTxs` stage and are marked canonical immediately with `CanonicalReason::Assumed`. Previously, `view_task()` only queued anchor checks for transitive txs, so directly assumed txs (`Assumed { descendant: None }`) were never checked and always resolved to `Unconfirmed` even when they had confirmed anchors. Queue all `Assumed` txs for anchor checks in `view_task()` and look up `direct_anchors` for both `Assumed` variants in `finish()`. Fixes https://github.com/bitcoindevkit/bdk/issues/2088 Co-Authored-By: Claude Opus 4.6 --- crates/chain/src/canonical_task.rs | 21 ++++++++++----------- 1 file changed, 10 insertions(+), 11 deletions(-) diff --git a/crates/chain/src/canonical_task.rs b/crates/chain/src/canonical_task.rs index 2ddf90816..a1f19ae39 100644 --- a/crates/chain/src/canonical_task.rs +++ b/crates/chain/src/canonical_task.rs @@ -439,16 +439,10 @@ impl<'g, A: Anchor> ChainQuery for CanonicalViewTask<'g, A> { // 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, - }, + CanonicalReason::Assumed { .. } => match self.direct_anchors.get(txid) { + Some(anchor) => ChainPosition::Confirmed { + anchor, + transitively: None, }, None => ChainPosition::Unconfirmed { first_seen: tx_node.first_seen, @@ -509,7 +503,7 @@ impl CanonicalTxs { continue; } // Transitively anchored transactions need their own anchor checked - if reason.is_transitive() { + if reason.is_transitive() || reason.is_assumed() { if let Some(anchors) = all_anchors.get(txid) { unprocessed_anchor_checks.push_back((*txid, anchors)); } @@ -623,6 +617,11 @@ impl CanonicalReason { pub fn is_transitive(&self) -> bool { self.descendant().is_some() } + + /// Returns true if this reason is [`CanonicalReason::Assumed`]. + pub fn is_assumed(&self) -> bool { + matches!(self, CanonicalReason::Assumed { .. }) + } } #[cfg(test)] From 031de40494b0cf706a5f052b008e37d2a4b74946 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=BF=97=E5=AE=87?= Date: Fri, 13 Feb 2026 14:43:13 +0000 Subject: [PATCH 13/15] refactor(chain): split `canonical_view.rs` into `canonical.rs` + `canonical_view_task.rs` Move shared types (`CanonicalTx`, `Canonical`, `CanonicalView`, `CanonicalTxs`) and convenience methods into `canonical.rs`. Keep only the phase-2 task (`CanonicalViewTask`) in `canonical_view_task.rs`. Also rename `FullTxOut` to `CanonicalTxOut` and move it to `canonical.rs`. Co-Authored-By: Claude Opus 4.6 --- .../src/{canonical_view.rs => canonical.rs} | 130 +++++++++++- crates/chain/src/canonical_task.rs | 188 +---------------- crates/chain/src/canonical_view_task.rs | 199 ++++++++++++++++++ crates/chain/src/chain_data.rs | 100 +-------- crates/chain/src/lib.rs | 6 +- examples/example_cli/src/lib.rs | 4 +- 6 files changed, 328 insertions(+), 299 deletions(-) rename crates/chain/src/{canonical_view.rs => canonical.rs} (77%) create mode 100644 crates/chain/src/canonical_view_task.rs diff --git a/crates/chain/src/canonical_view.rs b/crates/chain/src/canonical.rs similarity index 77% rename from crates/chain/src/canonical_view.rs rename to crates/chain/src/canonical.rs index c4da9b2a0..9af90b3b1 100644 --- a/crates/chain/src/canonical_view.rs +++ b/crates/chain/src/canonical.rs @@ -24,14 +24,15 @@ use crate::collections::HashMap; use alloc::sync::Arc; -use core::{fmt, ops::RangeBounds}; - use alloc::vec::Vec; +use core::{fmt, ops::RangeBounds}; use bdk_core::BlockId; -use bitcoin::{Amount, OutPoint, ScriptBuf, Transaction, Txid}; +use bitcoin::{ + constants::COINBASE_MATURITY, Amount, OutPoint, ScriptBuf, Transaction, TxOut, Txid, +}; -use crate::{spk_txout::SpkTxOutIndex, Anchor, Balance, ChainPosition, FullTxOut}; +use crate::{spk_txout::SpkTxOutIndex, Anchor, Balance, CanonicalViewTask, ChainPosition, TxGraph}; /// A single canonical transaction with its position. /// @@ -68,6 +69,104 @@ impl PartialOrd for CanonicalTx

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

{ + /// The position of the transaction in `outpoint` in the overall chain. + pub pos: P, + /// The location of the `TxOut`. + pub outpoint: OutPoint, + /// The `TxOut`. + pub txout: TxOut, + /// The txid and position of the transaction (if any) that has spent this output. + pub spent_by: Option<(P, Txid)>, + /// Whether this output is on a coinbase transaction. + pub is_on_coinbase: bool, +} + +impl Ord for CanonicalTxOut

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

{ + fn partial_cmp(&self, other: &Self) -> Option { + Some(self.cmp(other)) + } +} + +impl CanonicalTxOut> { + /// Whether the `txout` is considered mature. + /// + /// Depending on the implementation of [`confirmation_height_upper_bound`] in [`Anchor`], this + /// method may return false-negatives. In other words, interpreted confirmation count may be + /// less than the actual value. + /// + /// [`confirmation_height_upper_bound`]: Anchor::confirmation_height_upper_bound + pub fn is_mature(&self, tip: u32) -> bool { + if self.is_on_coinbase { + let conf_height = match self.pos.confirmation_height_upper_bound() { + Some(height) => height, + None => { + debug_assert!(false, "coinbase tx can never be unconfirmed"); + return false; + } + }; + let age = tip.saturating_sub(conf_height); + if age + 1 < COINBASE_MATURITY { + return false; + } + } + + true + } + + /// Whether the utxo is/was/will be spendable with chain `tip`. + /// + /// This method does not take into account the lock time. + /// + /// Depending on the implementation of [`confirmation_height_upper_bound`] in [`Anchor`], this + /// method may return false-negatives. In other words, interpreted confirmation count may be + /// less than the actual value. + /// + /// [`confirmation_height_upper_bound`]: Anchor::confirmation_height_upper_bound + pub fn is_confirmed_and_spendable(&self, tip: u32) -> bool { + if !self.is_mature(tip) { + return false; + } + + let conf_height = match self.pos.confirmation_height_upper_bound() { + Some(height) => height, + None => return false, + }; + if conf_height > tip { + return false; + } + + // if the spending tx is confirmed within tip height, the txout is no longer spendable + if let Some(spend_height) = self + .spent_by + .as_ref() + .and_then(|(pos, _)| pos.confirmation_height_upper_bound()) + { + if spend_height <= tip { + return false; + } + } + + true + } +} + /// Canonical set of transactions from a [`TxGraph`]. /// /// `Canonical` provides an ordered, conflict-resolved set of transactions. It determines @@ -151,7 +250,7 @@ impl Canonical { /// - The transaction doesn't exist in the canonical set /// - The output index is out of bounds /// - The transaction was excluded due to conflicts - pub fn txout(&self, op: OutPoint) -> Option> { + pub fn txout(&self, op: OutPoint) -> Option> { let (tx, pos) = self.txs.get(&op.txid)?; let vout: usize = op.vout.try_into().ok()?; let txout = tx.output.get(vout)?; @@ -159,7 +258,7 @@ impl Canonical { let (_, spent_by_pos) = &self.txs[spent_by_txid]; (spent_by_pos.clone(), *spent_by_txid) }); - Some(FullTxOut { + Some(CanonicalTxOut { pos: pos.clone(), outpoint: op, txout: txout.clone(), @@ -202,7 +301,7 @@ impl Canonical { /// Get a filtered list of outputs from the given outpoints. /// /// This method takes an iterator of `(identifier, outpoint)` pairs and returns an iterator - /// of `(identifier, full_txout)` pairs for outpoints that exist in the canonical set. + /// of `(identifier, canonical_txout)` pairs for outpoints that exist in the canonical set. /// Non-existent outpoints are silently filtered out. /// /// The identifier type `O` is useful for tracking which outpoints correspond to which addresses @@ -228,7 +327,7 @@ impl Canonical { pub fn filter_outpoints<'v, O: Clone + 'v>( &'v self, outpoints: impl IntoIterator + 'v, - ) -> impl Iterator)> + 'v { + ) -> impl Iterator)> + 'v { outpoints .into_iter() .filter_map(|(op_i, op)| Some((op_i, self.txout(op)?))) @@ -259,7 +358,7 @@ impl Canonical { pub fn filter_unspent_outpoints<'v, O: Clone + 'v>( &'v self, outpoints: impl IntoIterator + 'v, - ) -> impl Iterator)> + 'v { + ) -> impl Iterator)> + 'v { self.filter_outpoints(outpoints) .filter(|(_, txo)| txo.spent_by.is_none()) } @@ -337,7 +436,7 @@ impl CanonicalView { pub fn balance<'v, O: Clone + 'v>( &'v self, outpoints: impl IntoIterator + 'v, - mut trust_predicate: impl FnMut(&O, &FullTxOut>) -> bool, + mut trust_predicate: impl FnMut(&O, &CanonicalTxOut>) -> bool, min_confirmations: u32, ) -> Balance { let mut immature = Amount::ZERO; @@ -387,3 +486,14 @@ impl CanonicalView { } } } + +impl CanonicalTxs { + /// Creates a [`CanonicalViewTask`] that resolves [`CanonicalReason`]s into [`ChainPosition`]s. + /// + /// This is the second phase of the canonicalization pipeline. The resulting task + /// queries the chain to verify anchors for transitively anchored transactions and + /// produces a [`CanonicalView`] with resolved chain positions. + pub fn view_task<'g>(self, tx_graph: &'g TxGraph) -> CanonicalViewTask<'g, A> { + CanonicalViewTask::new(tx_graph, self.tip, self.order, self.txs, self.spends) + } +} diff --git a/crates/chain/src/canonical_task.rs b/crates/chain/src/canonical_task.rs index a1f19ae39..70dc9d2d6 100644 --- a/crates/chain/src/canonical_task.rs +++ b/crates/chain/src/canonical_task.rs @@ -1,6 +1,6 @@ use crate::collections::{HashMap, HashSet, VecDeque}; use crate::tx_graph::{TxAncestors, TxDescendants}; -use crate::{Anchor, CanonicalTxs, CanonicalView, ChainPosition, TxGraph}; +use crate::{Anchor, CanonicalTxs, TxGraph}; use alloc::boxed::Box; use alloc::collections::BTreeSet; use alloc::sync::Arc; @@ -339,191 +339,6 @@ impl<'g, A: Anchor> CanonicalTask<'g, A> { } } -/// Represents the current stage of view task processing. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] -enum ViewStage { - /// Processing transactions to resolve their chain positions. - #[default] - ResolvingPositions, - /// All processing is complete. - Finished, -} - -/// Resolves [`CanonicalReason`]s into [`ChainPosition`]s. -/// -/// This task implements the second phase of canonicalization: given a set of canonical -/// transactions with their reasons (from [`CanonicalTask`]), it resolves each reason -/// into a concrete [`ChainPosition`] (confirmed or unconfirmed). For transitively -/// anchored transactions, it queries the chain to check if they have their own direct -/// anchors. -pub struct CanonicalViewTask<'g, A> { - tx_graph: &'g TxGraph, - tip: BlockId, - - /// Transactions in canonical order with their reasons. - canonical_order: Vec, - canonical_txs: HashMap, CanonicalReason)>, - spends: HashMap, - - /// Transactions that need anchor verification (transitively anchored). - unprocessed_anchor_checks: VecDeque<(Txid, &'g BTreeSet)>, - - /// Resolved direct anchors for transitively anchored transactions. - direct_anchors: HashMap, - - current_stage: ViewStage, -} - -impl<'g, A: Anchor> ChainQuery for CanonicalViewTask<'g, A> { - type Output = CanonicalView; - - fn tip(&self) -> BlockId { - self.tip - } - - fn next_query(&mut self) -> Option { - loop { - match self.current_stage { - ViewStage::ResolvingPositions => { - if let Some((_txid, anchors)) = self.unprocessed_anchor_checks.front() { - let block_ids = - anchors.iter().map(|anchor| anchor.anchor_block()).collect(); - return Some(block_ids); - } - } - ViewStage::Finished => return None, - } - - self.current_stage = ViewStage::Finished; - } - } - - fn resolve_query(&mut self, response: ChainResponse) { - match self.current_stage { - ViewStage::ResolvingPositions => { - if let Some((txid, anchors)) = self.unprocessed_anchor_checks.pop_front() { - let best_anchor = response.and_then(|block_id| { - anchors - .iter() - .find(|anchor| anchor.anchor_block() == block_id) - .cloned() - }); - - if let Some(best_anchor) = best_anchor { - self.direct_anchors.insert(txid, best_anchor); - } - } - } - ViewStage::Finished => { - debug_assert!(false, "resolve_query called in Finished stage"); - } - } - } - - fn finish(self) -> Self::Output { - let mut view_order = Vec::new(); - let mut view_txs = HashMap::new(); - - for txid in &self.canonical_order { - if let Some((tx, reason)) = self.canonical_txs.get(txid) { - view_order.push(*txid); - - // Get transaction node for first_seen/last_seen info - let tx_node = match self.tx_graph.get_tx_node(*txid) { - Some(tx_node) => tx_node, - None => { - debug_assert!(false, "tx node must exist!"); - continue; - } - }; - - // Determine chain position based on reason - let chain_position = match reason { - CanonicalReason::Assumed { .. } => 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.clone()) - } -} - -impl CanonicalTxs { - /// Creates a [`CanonicalViewTask`] that resolves [`CanonicalReason`]s into [`ChainPosition`]s. - /// - /// This is the second phase of the canonicalization pipeline. The resulting task - /// queries the chain to verify anchors for transitively anchored transactions and - /// produces a [`CanonicalView`] with resolved chain positions. - pub fn view_task<'g>(self, tx_graph: &'g TxGraph) -> CanonicalViewTask<'g, A> { - let all_anchors = tx_graph.all_anchors(); - - // Find transactions that need anchor verification - let mut unprocessed_anchor_checks = VecDeque::new(); - for txid in &self.order { - if let Some((_, reason)) = self.txs.get(txid) { - // Skip ObservedIn transactions — they don't have anchors to verify - if matches!(reason, CanonicalReason::ObservedIn { .. }) { - continue; - } - // Transitively anchored transactions need their own anchor checked - if reason.is_transitive() || reason.is_assumed() { - 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 { @@ -628,6 +443,7 @@ impl CanonicalReason { mod tests { use super::*; use crate::local_chain::LocalChain; + use crate::ChainPosition; use bitcoin::{hashes::Hash, BlockHash, TxIn, TxOut}; #[test] diff --git a/crates/chain/src/canonical_view_task.rs b/crates/chain/src/canonical_view_task.rs new file mode 100644 index 000000000..a6770de84 --- /dev/null +++ b/crates/chain/src/canonical_view_task.rs @@ -0,0 +1,199 @@ +//! Phase 2 task: resolves canonical reasons into chain positions. + +use crate::canonical_task::{CanonicalReason, ObservedIn}; +use crate::collections::{HashMap, VecDeque}; +use alloc::collections::BTreeSet; +use alloc::sync::Arc; +use alloc::vec::Vec; + +use bdk_core::{BlockId, ChainQuery, ChainRequest, ChainResponse}; +use bitcoin::{OutPoint, Transaction, Txid}; + +use crate::{Anchor, CanonicalView, ChainPosition, TxGraph}; + +/// Represents the current stage of view task processing. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +enum ViewStage { + /// Processing transactions to resolve their chain positions. + #[default] + ResolvingPositions, + /// All processing is complete. + Finished, +} + +/// Resolves [`CanonicalReason`]s into [`ChainPosition`]s. +/// +/// This task implements the second phase of canonicalization: given a set of canonical +/// transactions with their reasons (from [`CanonicalTask`](crate::CanonicalTask)), it resolves each +/// reason into a concrete [`ChainPosition`] (confirmed or unconfirmed). For transitively +/// anchored transactions, it queries the chain to check if they have their own direct +/// anchors. +pub struct CanonicalViewTask<'g, A> { + tx_graph: &'g TxGraph, + tip: BlockId, + + /// Transactions in canonical order with their reasons. + canonical_order: Vec, + canonical_txs: HashMap, CanonicalReason)>, + spends: HashMap, + + /// Transactions that need anchor verification (transitively anchored). + unprocessed_anchor_checks: VecDeque<(Txid, &'g BTreeSet)>, + + /// Resolved direct anchors for transitively anchored transactions. + direct_anchors: HashMap, + + current_stage: ViewStage, +} + +impl<'g, A: Anchor> CanonicalViewTask<'g, A> { + /// Creates a new [`CanonicalViewTask`]. + /// + /// Accepts canonical transaction data and a reference to the [`TxGraph`]. + /// Scans transactions to find those needing anchor verification. + pub fn new( + tx_graph: &'g TxGraph, + tip: BlockId, + order: Vec, + txs: HashMap, CanonicalReason)>, + spends: HashMap, + ) -> Self { + let all_anchors = tx_graph.all_anchors(); + + let mut unprocessed_anchor_checks = VecDeque::new(); + for txid in &order { + if let Some((_, reason)) = txs.get(txid) { + if matches!(reason, CanonicalReason::ObservedIn { .. }) { + continue; + } + if reason.is_transitive() || reason.is_assumed() { + if let Some(anchors) = all_anchors.get(txid) { + unprocessed_anchor_checks.push_back((*txid, anchors)); + } + } + } + } + + Self { + tx_graph, + tip, + canonical_order: order, + canonical_txs: txs, + spends, + unprocessed_anchor_checks, + direct_anchors: HashMap::new(), + current_stage: ViewStage::default(), + } + } +} + +impl<'g, A: Anchor> ChainQuery for CanonicalViewTask<'g, A> { + type Output = CanonicalView; + + fn tip(&self) -> BlockId { + self.tip + } + + fn next_query(&mut self) -> Option { + loop { + match self.current_stage { + ViewStage::ResolvingPositions => { + if let Some((_txid, anchors)) = self.unprocessed_anchor_checks.front() { + let block_ids = + anchors.iter().map(|anchor| anchor.anchor_block()).collect(); + return Some(block_ids); + } + } + ViewStage::Finished => return None, + } + + self.current_stage = ViewStage::Finished; + } + } + + fn resolve_query(&mut self, response: ChainResponse) { + match self.current_stage { + ViewStage::ResolvingPositions => { + if let Some((txid, anchors)) = self.unprocessed_anchor_checks.pop_front() { + let best_anchor = response.and_then(|block_id| { + anchors + .iter() + .find(|anchor| anchor.anchor_block() == block_id) + .cloned() + }); + + if let Some(best_anchor) = best_anchor { + self.direct_anchors.insert(txid, best_anchor); + } + } + } + ViewStage::Finished => { + debug_assert!(false, "resolve_query called in Finished stage"); + } + } + } + + fn finish(self) -> Self::Output { + let mut view_order = Vec::new(); + let mut view_txs = HashMap::new(); + + for txid in &self.canonical_order { + if let Some((tx, reason)) = self.canonical_txs.get(txid) { + view_order.push(*txid); + + // Get transaction node for first_seen/last_seen info + let tx_node = match self.tx_graph.get_tx_node(*txid) { + Some(tx_node) => tx_node, + None => { + debug_assert!(false, "tx node must exist!"); + continue; + } + }; + + // Determine chain position based on reason + let chain_position = match reason { + CanonicalReason::Assumed { .. } => match self.direct_anchors.get(txid) { + Some(anchor) => ChainPosition::Confirmed { + anchor, + transitively: None, + }, + None => ChainPosition::Unconfirmed { + first_seen: tx_node.first_seen, + last_seen: tx_node.last_seen, + }, + }, + CanonicalReason::Anchor { anchor, descendant } => match descendant { + Some(_) => match self.direct_anchors.get(txid) { + Some(anchor) => ChainPosition::Confirmed { + anchor, + transitively: None, + }, + None => ChainPosition::Confirmed { + anchor, + transitively: *descendant, + }, + }, + None => ChainPosition::Confirmed { + anchor, + transitively: None, + }, + }, + CanonicalReason::ObservedIn { observed_in, .. } => match observed_in { + ObservedIn::Mempool(last_seen) => ChainPosition::Unconfirmed { + first_seen: tx_node.first_seen, + last_seen: Some(*last_seen), + }, + ObservedIn::Block(_) => ChainPosition::Unconfirmed { + first_seen: tx_node.first_seen, + last_seen: None, + }, + }, + }; + + view_txs.insert(*txid, (tx.clone(), chain_position.cloned())); + } + } + + CanonicalView::new(self.tip, view_order, view_txs, self.spends.clone()) + } +} diff --git a/crates/chain/src/chain_data.rs b/crates/chain/src/chain_data.rs index 9bbf3a85c..7ec88fba8 100644 --- a/crates/chain/src/chain_data.rs +++ b/crates/chain/src/chain_data.rs @@ -1,4 +1,4 @@ -use bitcoin::{constants::COINBASE_MATURITY, OutPoint, TxOut, Txid}; +use bitcoin::Txid; use crate::Anchor; @@ -161,104 +161,6 @@ impl PartialOrd for ChainPosition { } } -/// A `TxOut` with as much data as we can retrieve about it. -/// -/// The position type `P` is generic — it can be [`ChainPosition`] for resolved views, -/// or [`CanonicalReason`](crate::canonical_task::CanonicalReason) for unresolved canonicalization -/// results. -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct FullTxOut

{ - /// The position of the transaction in `outpoint` in the overall chain. - pub pos: P, - /// The location of the `TxOut`. - pub outpoint: OutPoint, - /// The `TxOut`. - pub txout: TxOut, - /// The txid and position of the transaction (if any) that has spent this output. - pub spent_by: Option<(P, Txid)>, - /// Whether this output is on a coinbase transaction. - pub is_on_coinbase: bool, -} - -impl Ord for FullTxOut

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

{ - fn partial_cmp(&self, other: &Self) -> Option { - Some(self.cmp(other)) - } -} - -impl FullTxOut> { - /// Whether the `txout` is considered mature. - /// - /// Depending on the implementation of [`confirmation_height_upper_bound`] in [`Anchor`], this - /// method may return false-negatives. In other words, interpreted confirmation count may be - /// less than the actual value. - /// - /// [`confirmation_height_upper_bound`]: Anchor::confirmation_height_upper_bound - pub fn is_mature(&self, tip: u32) -> bool { - if self.is_on_coinbase { - let conf_height = match self.pos.confirmation_height_upper_bound() { - Some(height) => height, - None => { - debug_assert!(false, "coinbase tx can never be unconfirmed"); - return false; - } - }; - let age = tip.saturating_sub(conf_height); - if age + 1 < COINBASE_MATURITY { - return false; - } - } - - true - } - - /// Whether the utxo is/was/will be spendable with chain `tip`. - /// - /// This method does not take into account the lock time. - /// - /// Depending on the implementation of [`confirmation_height_upper_bound`] in [`Anchor`], this - /// method may return false-negatives. In other words, interpreted confirmation count may be - /// less than the actual value. - /// - /// [`confirmation_height_upper_bound`]: Anchor::confirmation_height_upper_bound - pub fn is_confirmed_and_spendable(&self, tip: u32) -> bool { - if !self.is_mature(tip) { - return false; - } - - let conf_height = match self.pos.confirmation_height_upper_bound() { - Some(height) => height, - None => return false, - }; - if conf_height > tip { - return false; - } - - // if the spending tx is confirmed within tip height, the txout is no longer spendable - if let Some(spend_height) = self - .spent_by - .as_ref() - .and_then(|(pos, _)| pos.confirmation_height_upper_bound()) - { - if spend_height <= tip { - return false; - } - } - - true - } -} - #[cfg(test)] #[cfg_attr(coverage_nightly, coverage(off))] mod test { diff --git a/crates/chain/src/lib.rs b/crates/chain/src/lib.rs index 2e0a83c27..8c42473ba 100644 --- a/crates/chain/src/lib.rs +++ b/crates/chain/src/lib.rs @@ -46,8 +46,10 @@ mod chain_oracle; pub use chain_oracle::*; mod canonical_task; pub use canonical_task::*; -mod canonical_view; -pub use canonical_view::*; +mod canonical; +pub use canonical::*; +mod canonical_view_task; +pub use canonical_view_task::*; #[doc(hidden)] pub mod example_utils; diff --git a/examples/example_cli/src/lib.rs b/examples/example_cli/src/lib.rs index 8975c0da4..f824a45d7 100644 --- a/examples/example_cli/src/lib.rs +++ b/examples/example_cli/src/lib.rs @@ -24,7 +24,7 @@ use bdk_chain::ConfirmationBlockTime; use bdk_chain::{ indexer::keychain_txout::{self, KeychainTxOutIndex}, local_chain::{self, LocalChain}, - tx_graph, ChainOracle, ChainPosition, DescriptorExt, FullTxOut, IndexedTxGraph, Merge, + tx_graph, CanonicalTxOut, ChainOracle, ChainPosition, DescriptorExt, IndexedTxGraph, Merge, }; use bdk_coin_select::{ metrics::LowestFee, Candidate, ChangePolicy, CoinSelector, DrainWeights, FeeRate, Target, @@ -418,7 +418,7 @@ pub fn create_tx( } // Alias the elements of `planned_utxos` -pub type PlanUtxo = (Plan, FullTxOut>); +pub type PlanUtxo = (Plan, CanonicalTxOut>); pub fn planned_utxos( graph: &KeychainTxGraph, From 37eb136c31ca0390106b8d101e9b6dea75bd26a5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=BF=97=E5=AE=87?= Date: Sun, 8 Mar 2026 23:50:34 +0000 Subject: [PATCH 14/15] chore(chain,example)!: Remove `ChainOracle` and fix docs --- crates/chain/src/canonical.rs | 3 +- crates/chain/src/canonical_task.rs | 6 +- crates/chain/src/chain_oracle.rs | 25 ------ crates/chain/src/indexed_tx_graph.rs | 3 +- crates/chain/src/lib.rs | 2 - crates/chain/src/local_chain.rs | 84 +++++++-------------- crates/chain/src/tx_graph.rs | 3 + crates/chain/tests/test_indexed_tx_graph.rs | 2 +- crates/chain/tests/test_tx_graph.rs | 4 +- examples/example_cli/src/lib.rs | 8 +- 10 files changed, 43 insertions(+), 97 deletions(-) delete mode 100644 crates/chain/src/chain_oracle.rs diff --git a/crates/chain/src/canonical.rs b/crates/chain/src/canonical.rs index 9af90b3b1..e3c863f78 100644 --- a/crates/chain/src/canonical.rs +++ b/crates/chain/src/canonical.rs @@ -488,7 +488,8 @@ impl CanonicalView { } impl CanonicalTxs { - /// Creates a [`CanonicalViewTask`] that resolves [`CanonicalReason`]s into [`ChainPosition`]s. + /// Creates a [`CanonicalViewTask`] that resolves [`CanonicalReason`](crate::CanonicalReason)s + /// into [`ChainPosition`]s. /// /// This is the second phase of the canonicalization pipeline. The resulting task /// queries the chain to verify anchors for transitively anchored transactions and diff --git a/crates/chain/src/canonical_task.rs b/crates/chain/src/canonical_task.rs index 70dc9d2d6..8365ce26a 100644 --- a/crates/chain/src/canonical_task.rs +++ b/crates/chain/src/canonical_task.rs @@ -53,9 +53,9 @@ pub struct CanonicalParams { /// /// This task implements the first phase of canonicalization: it walks the transaction /// graph and determines which transactions are canonical (non-conflicting) and why -/// (via [`CanonicalReason`]). The output is a [`CanonicalTxs`] which can then be -/// further processed by [`CanonicalViewTask`] to resolve reasons into -/// [`ChainPosition`]s. +/// (via [`CanonicalReason`](crate::CanonicalReason)). The output is a [`CanonicalTxs`] which can +/// then be further processed by [`CanonicalViewTask`](crate::CanonicalViewTask) to resolve reasons +/// into [`ChainPosition`](crate::ChainPosition)s. pub struct CanonicalTask<'g, A> { tx_graph: &'g TxGraph, chain_tip: BlockId, diff --git a/crates/chain/src/chain_oracle.rs b/crates/chain/src/chain_oracle.rs deleted file mode 100644 index 08e697ed4..000000000 --- a/crates/chain/src/chain_oracle.rs +++ /dev/null @@ -1,25 +0,0 @@ -use crate::BlockId; - -/// Represents a service that tracks the blockchain. -/// -/// The main method is [`is_block_in_chain`] which determines whether a given block of [`BlockId`] -/// is an ancestor of the `chain_tip`. -/// -/// [`is_block_in_chain`]: Self::is_block_in_chain -pub trait ChainOracle { - /// Error type. - type Error: core::fmt::Debug; - - /// Determines whether `block` of [`BlockId`] exists as an ancestor of `chain_tip`. - /// - /// If `None` is returned, it means the implementation cannot determine whether `block` exists - /// under `chain_tip`. - fn is_block_in_chain( - &self, - block: BlockId, - chain_tip: BlockId, - ) -> Result, Self::Error>; - - /// Get the best chain's chain tip. - fn get_chain_tip(&self) -> Result; -} diff --git a/crates/chain/src/indexed_tx_graph.rs b/crates/chain/src/indexed_tx_graph.rs index e486ad2e3..98c5d16db 100644 --- a/crates/chain/src/indexed_tx_graph.rs +++ b/crates/chain/src/indexed_tx_graph.rs @@ -432,7 +432,8 @@ impl IndexedTxGraph where A: Anchor, { - /// Creates a [`CanonicalTask`] to determine the [`CanonicalView`] of transactions. + /// Creates a [`CanonicalTask`] to determine the [`CanonicalView`](crate::CanonicalView) of + /// transactions. /// /// This method delegates to the underlying [`TxGraph`] to create a [`CanonicalTask`] /// that can be used to determine which transactions are canonical based on the provided diff --git a/crates/chain/src/lib.rs b/crates/chain/src/lib.rs index 8c42473ba..41ed7cc09 100644 --- a/crates/chain/src/lib.rs +++ b/crates/chain/src/lib.rs @@ -42,8 +42,6 @@ mod tx_data_traits; pub use tx_data_traits::*; pub mod tx_graph; pub use tx_graph::TxGraph; -mod chain_oracle; -pub use chain_oracle::*; mod canonical_task; pub use canonical_task::*; mod canonical; diff --git a/crates/chain/src/local_chain.rs b/crates/chain/src/local_chain.rs index 5de8383e8..83cc072e2 100644 --- a/crates/chain/src/local_chain.rs +++ b/crates/chain/src/local_chain.rs @@ -1,11 +1,10 @@ -//! The [`LocalChain`] is a local implementation of [`ChainOracle`]. +//! The [`LocalChain`] is a local chain of checkpoints. -use core::convert::Infallible; use core::fmt; use core::ops::RangeBounds; use crate::collections::BTreeMap; -use crate::{Anchor, BlockId, CanonicalParams, CanonicalView, ChainOracle, Merge, TxGraph}; +use crate::{Anchor, BlockId, CanonicalParams, CanonicalView, Merge, TxGraph}; use bdk_core::{ChainQuery, ToBlockHash}; pub use bdk_core::{CheckPoint, CheckPointIter}; use bitcoin::block::Header; @@ -57,7 +56,7 @@ where Ok(init_cp) } -/// This is a local implementation of [`ChainOracle`]. +/// A local chain of checkpoints. #[derive(Debug, Clone)] pub struct LocalChain { tip: CheckPoint, @@ -69,62 +68,37 @@ impl PartialEq for LocalChain { } } -impl ChainOracle for LocalChain { - type Error = Infallible; - - fn is_block_in_chain( - &self, - block: BlockId, - chain_tip: BlockId, - ) -> Result, Self::Error> { +// Methods for `LocalChain` +impl LocalChain { + /// Check if a block is in the chain. + /// + /// # Arguments + /// * `block` - The block to check + /// * `chain_tip` - The chain tip to check against + /// + /// # Returns + /// * `Some(true)` if the block is in the chain + /// * `Some(false)` if the block is not in the chain + /// * `None` if it cannot be determined + pub fn is_block_in_chain(&self, block: BlockId, chain_tip: BlockId) -> Option { let chain_tip_cp = match self.tip.get(chain_tip.height) { // we can only determine whether `block` is in chain of `chain_tip` if `chain_tip` can // be identified in chain Some(cp) if cp.hash() == chain_tip.hash => cp, - _ => return Ok(None), + _ => return None, }; - match chain_tip_cp.get(block.height) { - Some(cp) => Ok(Some(cp.hash() == block.hash)), - None => Ok(None), - } + chain_tip_cp + .get(block.height) + .map(|cp| cp.hash() == block.hash) } - fn get_chain_tip(&self) -> Result { - Ok(self.tip.block_id()) + /// Get the chain tip. + /// + /// # Returns + /// The [`BlockId`] of the chain tip. + pub fn chain_tip(&self) -> BlockId { + self.tip.block_id() } -} - -// Methods for `LocalChain` -impl LocalChain { - // /// Check if a block is in the chain. - // /// - // /// # Arguments - // /// * `block` - The block to check - // /// * `chain_tip` - The chain tip to check against - // /// - // /// # Returns - // /// * `Some(true)` if the block is in the chain - // /// * `Some(false)` if the block is not in the chain - // /// * `None` if it cannot be determined - // pub fn is_block_in_chain(&self, block: BlockId, chain_tip: BlockId) -> Option { - // let chain_tip_cp = match self.tip.get(chain_tip.height) { - // // we can only determine whether `block` is in chain of `chain_tip` if `chain_tip` - // can // be identified in chain - // Some(cp) if cp.hash() == chain_tip.hash => cp, - // _ => return None, - // }; - // chain_tip_cp - // .get(block.height) - // .map(|cp| cp.hash() == block.hash) - // } - - // /// Get the chain tip. - // /// - // /// # Returns - // /// The [`BlockId`] of the chain tip. - // pub fn chain_tip(&self) -> BlockId { - // self.tip.block_id() - // } /// Canonicalize a transaction graph using this chain. /// @@ -151,11 +125,7 @@ impl LocalChain { while let Some(request) = task.next_query() { let mut best_block_id = None; for block_id in &request { - if self - .is_block_in_chain(*block_id, chain_tip) - .expect("infallible") - == Some(true) - { + if self.is_block_in_chain(*block_id, chain_tip) == Some(true) { best_block_id = Some(*block_id); break; } diff --git a/crates/chain/src/tx_graph.rs b/crates/chain/src/tx_graph.rs index 2fcf26bcd..ae38aaaf2 100644 --- a/crates/chain/src/tx_graph.rs +++ b/crates/chain/src/tx_graph.rs @@ -124,6 +124,7 @@ //! assert!(changeset.is_empty()); //! ``` //! [`insert_txout`]: TxGraph::insert_txout +//! [`CanonicalView`]: crate::CanonicalView use crate::collections::*; use crate::CanonicalParams; @@ -978,6 +979,8 @@ impl TxGraph { /// that can be used to determine which transactions are canonical based on the provided /// parameters. The task handles the stateless canonicalization logic and can be polled /// for anchor verification requests. + /// + /// [`CanonicalView`]: crate::CanonicalView pub fn canonical_task( &'_ self, chain_tip: BlockId, diff --git a/crates/chain/tests/test_indexed_tx_graph.rs b/crates/chain/tests/test_indexed_tx_graph.rs index 27e9b972e..8129a8033 100644 --- a/crates/chain/tests/test_indexed_tx_graph.rs +++ b/crates/chain/tests/test_indexed_tx_graph.rs @@ -305,7 +305,7 @@ fn insert_relevant_txs() { } /// Ensure consistency IndexedTxGraph list_* and balance methods. These methods lists -/// relevant txouts and utxos from the information fetched from a ChainOracle (here a LocalChain). +/// relevant txouts and utxos from the information fetched from a LocalChain. /// /// Test Setup: /// diff --git a/crates/chain/tests/test_tx_graph.rs b/crates/chain/tests/test_tx_graph.rs index 2e10395d1..9181f8af2 100644 --- a/crates/chain/tests/test_tx_graph.rs +++ b/crates/chain/tests/test_tx_graph.rs @@ -7,7 +7,7 @@ use bdk_chain::{ local_chain::LocalChain, tx_graph::{self, CalculateFeeError}, tx_graph::{ChangeSet, TxGraph}, - Anchor, ChainOracle, ChainPosition, Merge, + Anchor, ChainPosition, Merge, }; use bdk_testenv::{block_id, hash, utils::new_tx}; use bitcoin::hex::FromHex; @@ -758,7 +758,7 @@ fn test_walk_ancestors() { let tx_node = graph.get_tx_node(tx.compute_txid())?; for block in tx_node.anchors { match local_chain.is_block_in_chain(block.anchor_block(), tip.block_id()) { - Ok(Some(true)) => return None, + Some(true) => return None, _ => continue, } } diff --git a/examples/example_cli/src/lib.rs b/examples/example_cli/src/lib.rs index f824a45d7..62667e365 100644 --- a/examples/example_cli/src/lib.rs +++ b/examples/example_cli/src/lib.rs @@ -24,7 +24,7 @@ use bdk_chain::ConfirmationBlockTime; use bdk_chain::{ indexer::keychain_txout::{self, KeychainTxOutIndex}, local_chain::{self, LocalChain}, - tx_graph, CanonicalTxOut, ChainOracle, ChainPosition, DescriptorExt, IndexedTxGraph, Merge, + tx_graph, CanonicalTxOut, ChainPosition, DescriptorExt, IndexedTxGraph, Merge, }; use bdk_coin_select::{ metrics::LowestFee, Candidate, ChangePolicy, CoinSelector, DrainWeights, FeeRate, Target, @@ -390,9 +390,7 @@ pub fn create_tx( version: transaction::Version::TWO, lock_time: assets .absolute_timelock - .unwrap_or(absolute::LockTime::from_height( - chain.get_chain_tip()?.height, - )?), + .unwrap_or(absolute::LockTime::from_height(chain.chain_tip().height)?), input: selected .iter() .map(|(plan, utxo)| TxIn { @@ -554,7 +552,7 @@ pub fn handle_commands( Commands::TxOut { txout_cmd } => { let graph = &*graph.lock().unwrap(); let chain = &*chain.lock().unwrap(); - let chain_tip = chain.get_chain_tip()?; + let chain_tip = chain.chain_tip(); let outpoints = graph.index.outpoints(); match txout_cmd { From c1ad161f1c8ef0eb76a8fb49acf63543ae088478 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=BF=97=E5=AE=87?= Date: Mon, 9 Mar 2026 15:13:11 +0000 Subject: [PATCH 15/15] refactor(core,chain)!: redesign `ChainQuery` as a sans-IO task trait Replace `ChainRequest`/`ChainResponse` with a poll-based `ChainQuery` trait. The driver calls `poll()` in a loop, resolving block-height queries as needed. A generic parameter `B` (default `BlockHash`) lets tasks receive richer block data (e.g. `Header`) from the chain source. Add per-transaction and tip MTP fields to `CanonicalView`, computed optionally via `CanonicalViewTask::with_mtp()`. Add `BlockQueries` helper for tracking request/resolve state, and `LocalChain::canonical_view{,_with_mtp}()` convenience methods. --- crates/chain/src/canonical.rs | 115 ++++++--- crates/chain/src/canonical_task.rs | 323 +++++++++++++++--------- crates/chain/src/canonical_view_task.rs | 279 +++++++++++++------- crates/chain/src/indexed_tx_graph.rs | 4 +- crates/chain/src/local_chain.rs | 164 ++++++------ crates/chain/src/tx_graph.rs | 6 +- crates/core/src/block_queries.rs | 67 +++++ crates/core/src/chain_query.rs | 115 +++++++-- crates/core/src/lib.rs | 3 + 9 files changed, 731 insertions(+), 345 deletions(-) create mode 100644 crates/core/src/block_queries.rs diff --git a/crates/chain/src/canonical.rs b/crates/chain/src/canonical.rs index e3c863f78..e89b5d4e5 100644 --- a/crates/chain/src/canonical.rs +++ b/crates/chain/src/canonical.rs @@ -6,15 +6,14 @@ //! ## Example //! //! ``` -//! # use bdk_chain::{TxGraph, CanonicalParams, CanonicalTask, local_chain::LocalChain}; +//! # use bdk_chain::{TxGraph, CanonicalParams, local_chain::LocalChain}; //! # use bdk_core::BlockId; //! # use bitcoin::hashes::Hash; //! # let tx_graph = TxGraph::::default(); //! # let chain = LocalChain::from_blocks([(0, bitcoin::BlockHash::all_zeros())].into_iter().collect()).unwrap(); //! let chain_tip = chain.tip().block_id(); //! let params = CanonicalParams::default(); -//! let task = CanonicalTask::new(&tx_graph, chain_tip, params); -//! let view = chain.canonicalize(task); +//! let view = chain.canonical_view(&tx_graph, chain_tip, params); //! //! // Iterate over canonical transactions //! for tx in view.txs() { @@ -27,13 +26,21 @@ use alloc::sync::Arc; use alloc::vec::Vec; use core::{fmt, ops::RangeBounds}; -use bdk_core::BlockId; +use bdk_core::{BlockId, BlockQueries}; use bitcoin::{ constants::COINBASE_MATURITY, Amount, OutPoint, ScriptBuf, Transaction, TxOut, Txid, }; use crate::{spk_txout::SpkTxOutIndex, Anchor, Balance, CanonicalViewTask, ChainPosition, TxGraph}; +/// Internal per-transaction entry in [`Canonical`]. +#[derive(Clone, Debug)] +pub(crate) struct CanonicalEntry

{ + pub(crate) tx: Arc, + pub(crate) pos: P, + pub(crate) mtp: Option, +} + /// A single canonical transaction with its position. /// /// This struct represents a transaction that has been determined to be canonical (not @@ -52,6 +59,11 @@ pub struct CanonicalTx

{ pub txid: Txid, /// The full transaction. pub tx: Arc, + /// The median-time-past at the confirmation height, if computed. + /// + /// This is `Some` only when the transaction is confirmed and MTP computation was + /// enabled via [`CanonicalViewTask::with_mtp`](crate::CanonicalViewTask::with_mtp). + pub mtp: Option, } impl Ord for CanonicalTx

{ @@ -86,6 +98,11 @@ pub struct CanonicalTxOut

{ pub spent_by: Option<(P, Txid)>, /// Whether this output is on a coinbase transaction. pub is_on_coinbase: bool, + /// The median-time-past at the confirmation height, if computed. + /// + /// This is `Some` only when the output's transaction is confirmed and MTP computation + /// was enabled via [`CanonicalViewTask::with_mtp`](crate::CanonicalViewTask::with_mtp). + pub mtp: Option, } impl Ord for CanonicalTxOut

{ @@ -186,12 +203,14 @@ impl CanonicalTxOut> { pub struct Canonical { /// Ordered list of transaction IDs in topological-spending order. pub(crate) order: Vec, - /// Map of transaction IDs to their transaction data and position. - pub(crate) txs: HashMap, P)>, + /// Map of transaction IDs to their transaction data, position, and MTP. + pub(crate) txs: HashMap>, /// Map of outpoints to the transaction ID that spends them. pub(crate) spends: HashMap, /// The chain tip at the time this view was created. pub(crate) tip: BlockId, + /// Median-time-past at the chain tip height. + pub(crate) tip_mtp: Option, /// Marker for the anchor type. pub(crate) _anchor: core::marker::PhantomData, } @@ -213,14 +232,16 @@ impl Canonical { pub(crate) fn new( tip: BlockId, order: Vec, - txs: HashMap, P)>, + txs: HashMap>, spends: HashMap, + tip_mtp: Option, ) -> Self { Self { tip, order, txs, spends, + tip_mtp, _anchor: core::marker::PhantomData, } } @@ -230,15 +251,25 @@ impl Canonical { self.tip } + /// Get the MTP at the chain tip height. + /// + /// Returns `None` if MTP was not computed. + pub fn tip_mtp(&self) -> Option { + self.tip_mtp + } + /// Get a single canonical transaction by its transaction ID. /// /// Returns `Some(CanonicalTx)` if the transaction exists in the canonical set, /// or `None` if the transaction doesn't exist or was excluded due to conflicts. pub fn tx(&self, txid: Txid) -> Option> { - self.txs - .get(&txid) - .cloned() - .map(|(tx, pos)| CanonicalTx { pos, txid, tx }) + let entry = self.txs.get(&txid)?; + Some(CanonicalTx { + pos: entry.pos.clone(), + txid, + tx: entry.tx.clone(), + mtp: entry.mtp, + }) } /// Get a single canonical transaction output. @@ -251,19 +282,20 @@ impl Canonical { /// - The output index is out of bounds /// - The transaction was excluded due to conflicts pub fn txout(&self, op: OutPoint) -> Option> { - let (tx, pos) = self.txs.get(&op.txid)?; + let entry = self.txs.get(&op.txid)?; let vout: usize = op.vout.try_into().ok()?; - let txout = tx.output.get(vout)?; + let txout = entry.tx.output.get(vout)?; let spent_by = self.spends.get(&op).map(|spent_by_txid| { - let (_, spent_by_pos) = &self.txs[spent_by_txid]; - (spent_by_pos.clone(), *spent_by_txid) + let spent_by_entry = &self.txs[spent_by_txid]; + (spent_by_entry.pos.clone(), *spent_by_txid) }); Some(CanonicalTxOut { - pos: pos.clone(), + pos: entry.pos.clone(), outpoint: op, txout: txout.clone(), spent_by, - is_on_coinbase: tx.is_coinbase(), + is_on_coinbase: entry.tx.is_coinbase(), + mtp: entry.mtp, }) } @@ -275,14 +307,13 @@ impl Canonical { /// # Example /// /// ``` - /// # use bdk_chain::{TxGraph, CanonicalTask, local_chain::LocalChain}; + /// # use bdk_chain::{TxGraph, local_chain::LocalChain}; /// # use bdk_core::BlockId; /// # use bitcoin::hashes::Hash; /// # let tx_graph = TxGraph::::default(); /// # let chain = LocalChain::from_blocks([(0, bitcoin::BlockHash::all_zeros())].into_iter().collect()).unwrap(); /// # let chain_tip = chain.tip().block_id(); - /// # let task = CanonicalTask::new(&tx_graph, chain_tip, Default::default()); - /// # let view = chain.canonicalize(task); + /// # let view = chain.canonical_view(&tx_graph, chain_tip, Default::default()); /// // Iterate over all canonical transactions /// for tx in view.txs() { /// println!("TX {}: {:?}", tx.txid, tx.pos); @@ -293,8 +324,13 @@ impl Canonical { /// ``` pub fn txs(&self) -> impl ExactSizeIterator> + DoubleEndedIterator + '_ { self.order.iter().map(|&txid| { - let (tx, pos) = self.txs[&txid].clone(); - CanonicalTx { pos, txid, tx } + let entry = &self.txs[&txid]; + CanonicalTx { + pos: entry.pos.clone(), + txid, + tx: entry.tx.clone(), + mtp: entry.mtp, + } }) } @@ -310,14 +346,13 @@ impl Canonical { /// # Example /// /// ``` - /// # use bdk_chain::{TxGraph, CanonicalTask, local_chain::LocalChain, keychain_txout::KeychainTxOutIndex}; + /// # use bdk_chain::{TxGraph, local_chain::LocalChain, keychain_txout::KeychainTxOutIndex}; /// # use bdk_core::BlockId; /// # use bitcoin::hashes::Hash; /// # let tx_graph = TxGraph::::default(); /// # let chain = LocalChain::from_blocks([(0, bitcoin::BlockHash::all_zeros())].into_iter().collect()).unwrap(); /// # let chain_tip = chain.tip().block_id(); - /// # let task = CanonicalTask::new(&tx_graph, chain_tip, Default::default()); - /// # let view = chain.canonicalize(task); + /// # let view = chain.canonical_view(&tx_graph, chain_tip, Default::default()); /// # let indexer = KeychainTxOutIndex::<&str>::default(); /// // Get all outputs from an indexer /// for (keychain, txout) in view.filter_outpoints(indexer.outpoints().clone()) { @@ -341,14 +376,13 @@ impl Canonical { /// # Example /// /// ``` - /// # use bdk_chain::{TxGraph, CanonicalTask, local_chain::LocalChain, keychain_txout::KeychainTxOutIndex}; + /// # use bdk_chain::{TxGraph, local_chain::LocalChain, keychain_txout::KeychainTxOutIndex}; /// # use bdk_core::BlockId; /// # use bitcoin::hashes::Hash; /// # let tx_graph = TxGraph::::default(); /// # let chain = LocalChain::from_blocks([(0, bitcoin::BlockHash::all_zeros())].into_iter().collect()).unwrap(); /// # let chain_tip = chain.tip().block_id(); - /// # let task = CanonicalTask::new(&tx_graph, chain_tip, Default::default()); - /// # let view = chain.canonicalize(task); + /// # let view = chain.canonical_view(&tx_graph, chain_tip, Default::default()); /// # let indexer = KeychainTxOutIndex::<&str>::default(); /// // Get unspent outputs (UTXOs) from an indexer /// for (keychain, utxo) in view.filter_unspent_outpoints(indexer.outpoints().clone()) { @@ -488,13 +522,22 @@ impl CanonicalView { } impl CanonicalTxs { - /// Creates a [`CanonicalViewTask`] that resolves [`CanonicalReason`](crate::CanonicalReason)s - /// into [`ChainPosition`]s. - /// - /// This is the second phase of the canonicalization pipeline. The resulting task - /// queries the chain to verify anchors for transitively anchored transactions and - /// produces a [`CanonicalView`] with resolved chain positions. - pub fn view_task<'g>(self, tx_graph: &'g TxGraph) -> CanonicalViewTask<'g, A> { - CanonicalViewTask::new(tx_graph, self.tip, self.order, self.txs, self.spends) + /// Creates a [`CanonicalViewTask`] that resolves [`CanonicalReason`]s into [`ChainPosition`]s. + /// + /// This is the second phase of the canonicalization pipeline. Blocks fetched during + /// phase 1 are passed through so they can be reused without redundant queries. + pub fn view_task<'g, B>( + self, + tx_graph: &'g TxGraph, + queries: BlockQueries, + ) -> CanonicalViewTask<'g, A, B> { + CanonicalViewTask::new( + tx_graph, + self.tip, + self.order, + self.txs, + self.spends, + queries, + ) } } diff --git a/crates/chain/src/canonical_task.rs b/crates/chain/src/canonical_task.rs index 8365ce26a..abff856f9 100644 --- a/crates/chain/src/canonical_task.rs +++ b/crates/chain/src/canonical_task.rs @@ -1,14 +1,14 @@ use crate::collections::{HashMap, HashSet, VecDeque}; use crate::tx_graph::{TxAncestors, TxDescendants}; -use crate::{Anchor, CanonicalTxs, TxGraph}; +use crate::{canonical::CanonicalEntry, Anchor, CanonicalTxs, TxGraph}; use alloc::boxed::Box; use alloc::collections::BTreeSet; use alloc::sync::Arc; use alloc::vec::Vec; -use bdk_core::{BlockId, ChainQuery, ChainRequest, ChainResponse}; +use bdk_core::{BlockId, BlockQueries, ChainQuery, TaskProgress, ToBlockHash}; use bitcoin::{Transaction, Txid}; -type CanonicalMap = HashMap, CanonicalReason)>; +type CanonicalMap = HashMap>>; type NotCanonicalSet = HashSet; /// Represents the current stage of canonicalization processing. @@ -28,7 +28,7 @@ enum CanonicalStage { } impl CanonicalStage { - fn advance(&mut self) { + fn next_stage(&mut self) { *self = match self { CanonicalStage::AssumedTxs => Self::AnchoredTxs, CanonicalStage::AnchoredTxs => Self::SeenTxs, @@ -56,10 +56,12 @@ pub struct CanonicalParams { /// (via [`CanonicalReason`](crate::CanonicalReason)). The output is a [`CanonicalTxs`] which can /// then be further processed by [`CanonicalViewTask`](crate::CanonicalViewTask) to resolve reasons /// into [`ChainPosition`](crate::ChainPosition)s. -pub struct CanonicalTask<'g, A> { +pub struct CanonicalTask<'g, A, B> { tx_graph: &'g TxGraph, chain_tip: BlockId, + queries: BlockQueries, + unprocessed_assumed_txs: Box)> + 'g>, unprocessed_anchored_txs: VecDeque<(Txid, Arc, &'g BTreeSet)>, unprocessed_seen_txs: Box, u64)> + 'g>, @@ -75,149 +77,150 @@ pub struct CanonicalTask<'g, A> { current_stage: CanonicalStage, } -impl<'g, A: Anchor> ChainQuery for CanonicalTask<'g, A> { - type Output = CanonicalTxs; +impl<'g, A: Anchor, B: ToBlockHash> ChainQuery for CanonicalTask<'g, A, B> { + type Output = (CanonicalTxs, BlockQueries); fn tip(&self) -> BlockId { self.chain_tip } - fn next_query(&mut self) -> Option { - loop { - match self.current_stage { - CanonicalStage::AssumedTxs => { - if let Some((txid, tx)) = self.unprocessed_assumed_txs.next() { - if !self.is_canonicalized(txid) { - self.mark_canonical(txid, tx, CanonicalReason::assumed()); - } - continue; + fn unresolved_queries<'a>(&'a self) -> impl Iterator + 'a { + self.queries.unresolved() + } + + fn poll(&mut self) -> TaskProgress { + match self.current_stage { + CanonicalStage::AssumedTxs => { + if let Some((txid, tx)) = self.unprocessed_assumed_txs.next() { + if !self.is_canonicalized(txid) { + self.mark_canonical(txid, tx, CanonicalReason::assumed()); } + } else { + self.current_stage.next_stage(); } - 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); + TaskProgress::Advanced + } + CanonicalStage::AnchoredTxs => { + if let Some((txid, tx, anchors)) = self.unprocessed_anchored_txs.pop_front() { + if self.is_canonicalized(txid) { + return TaskProgress::Advanced; } - } - CanonicalStage::SeenTxs => { - if let Some((txid, tx, last_seen)) = self.unprocessed_seen_txs.next() { - debug_assert!( - !tx.is_coinbase(), - "Coinbase txs must not have `last_seen` (in mempool) value" - ); - if !self.is_canonicalized(txid) { - let observed_in = ObservedIn::Mempool(last_seen); - self.mark_canonical( - txid, - tx, - CanonicalReason::from_observed_in(observed_in), - ); + + let mut best_anchor = Option::::None; + let mut has_unresolved_heights = false; + + for a in anchors.iter() { + let h = a.anchor_block().height; + match self.queries.get(h) { + Some(Some(b)) if b.to_blockhash() == a.anchor_block().hash => { + best_anchor = Some(a.clone()); + break; + } + None => has_unresolved_heights = true, + // Either the block does not match the anchor, or the chain source + // failed to fetch a block at this height. + _ => {} } - 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; + + if let Some(a) = best_anchor { + self.mark_canonical(txid, tx, CanonicalReason::from_anchor(a)); + return TaskProgress::Advanced; } - } - CanonicalStage::Finished => return None, - } - self.current_stage.advance(); - } - } + if has_unresolved_heights { + let heights = self + .queries + .request(anchors.iter().map(|a| a.anchor_block().height)); + self.unprocessed_anchored_txs + .push_front((txid, tx, anchors)); + return TaskProgress::Query(heights); + } - fn resolve_query(&mut self, response: ChainResponse) { - 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| { + // No confirmed anchor found. + self.unprocessed_leftover_txs.push_back(( + txid, + tx, anchors .iter() - .find(|anchor| anchor.anchor_block() == block_id) - .cloned() - }); - - match best_anchor { - Some(best_anchor) => { - // Transaction has a confirmed anchor - 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(), - )) - } + .last() + .expect("must have at least one anchor") + .confirmation_height_upper_bound(), + )); + } else { + self.current_stage.next_stage(); + } + TaskProgress::Advanced + } + CanonicalStage::SeenTxs => { + if let Some((txid, tx, last_seen)) = self.unprocessed_seen_txs.next() { + debug_assert!( + !tx.is_coinbase(), + "Coinbase txs must not have `last_seen` (in mempool) value" + ); + if !self.is_canonicalized(txid) { + let observed_in = ObservedIn::Mempool(last_seen); + self.mark_canonical( + txid, + tx, + CanonicalReason::from_observed_in(observed_in), + ); } + } else { + self.current_stage.next_stage(); } + TaskProgress::Advanced } - CanonicalStage::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 - ); + CanonicalStage::LeftOverTxs => { + if let Some((txid, tx, height)) = self.unprocessed_leftover_txs.pop_front() { + if !self.is_canonicalized(txid) && !tx.is_coinbase() { + let observed_in = ObservedIn::Block(height); + self.mark_canonical( + txid, + tx, + CanonicalReason::from_observed_in(observed_in), + ); + } + } else { + self.current_stage.next_stage(); + } + TaskProgress::Advanced } + CanonicalStage::Finished => TaskProgress::Done, } } + fn resolve_query(&mut self, height: u32, block: Option) { + self.queries.resolve(height, block); + } + fn finish(self) -> Self::Output { - let mut view_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 { + if let Some(entry) = self.canonical.get(txid) { + if !entry.tx.is_coinbase() { + for input in &entry.tx.input { view_spends.insert(input.previous_output, *txid); } } - - view_txs.insert(*txid, (tx.clone(), reason.clone())); } } - CanonicalTxs::new(self.chain_tip, view_order, view_txs, view_spends) + ( + CanonicalTxs::new( + self.chain_tip, + self.canonical_order, + self.canonical, + view_spends, + None, + ), + self.queries, + ) } } -impl<'g, A: Anchor> CanonicalTask<'g, A> { +impl<'g, A: Anchor, B: ToBlockHash> CanonicalTask<'g, A, B> { /// Creates a new canonicalization task. pub fn new(tx_graph: &'g TxGraph, chain_tip: BlockId, params: CanonicalParams) -> Self { let anchors = tx_graph.all_anchors(); @@ -252,6 +255,8 @@ impl<'g, A: Anchor> CanonicalTask<'g, A> { canonical_order: Vec::new(), current_stage: CanonicalStage::default(), + + queries: BlockQueries::new(), } } @@ -315,7 +320,11 @@ impl<'g, A: Anchor> CanonicalTask<'g, A> { } staged_canonical.push((this_txid, tx.clone(), this_reason.clone())); - canonical_entry.insert((tx.clone(), this_reason)); + canonical_entry.insert(CanonicalEntry { + tx: tx.clone(), + pos: this_reason, + mtp: None, + }); Some(this_txid) }, ) @@ -442,6 +451,7 @@ impl CanonicalReason { #[cfg(test)] mod tests { use super::*; + use crate::collections::BTreeMap; use crate::local_chain::LocalChain; use crate::ChainPosition; use bitcoin::{hashes::Hash, BlockHash, TxIn, TxOut}; @@ -483,8 +493,8 @@ mod tests { // Create canonicalization task and canonicalize using the two-step pipeline let params = CanonicalParams::default(); let task = CanonicalTask::new(&tx_graph, chain_tip, params); - let canonical_txs = chain.canonicalize(task); - let view_task = canonical_txs.view_task(&tx_graph); + let (txs, queries) = chain.canonicalize(task); + let view_task = txs.view_task(&tx_graph, queries); let canonical_view = chain.canonicalize(view_task); // Should have one canonical transaction @@ -496,4 +506,85 @@ mod tests { // Should be confirmed (anchored) assert!(matches!(canon_tx.pos, ChainPosition::Confirmed { .. })); } + + #[test] + fn test_mtp_with_header_chain() { + use bitcoin::block::Header; + use bitcoin::CompactTarget; + + // Helper to create a header with a specific prev_blockhash and time + fn make_header(prev_blockhash: BlockHash, time: u32) -> Header { + Header { + version: bitcoin::block::Version::ONE, + prev_blockhash, + merkle_root: bitcoin::TxMerkleNode::all_zeros(), + time, + bits: CompactTarget::from_consensus(0x2000_0000), + nonce: 0, + } + } + + // Build a chain of 12 headers (heights 0..=11) with known timestamps. + // Timestamps: height h has time = (h + 1) * 100 + let genesis_header = make_header(BlockHash::all_zeros(), 100); + let genesis_hash = genesis_header.block_hash(); + + let mut headers: BTreeMap = BTreeMap::new(); + headers.insert(0, genesis_header); + + let mut prev_hash = genesis_hash; + for h in 1u32..=11 { + let header = make_header(prev_hash, (h + 1) * 100); + prev_hash = header.block_hash(); + headers.insert(h, header); + } + + let chain = LocalChain::

::from_blocks(headers.clone()).unwrap(); + let chain_tip = chain.tip().block_id(); + + // Create a transaction graph with a tx anchored at height 5 + let mut tx_graph = TxGraph::default(); + + let tx = bitcoin::Transaction { + version: bitcoin::transaction::Version::ONE, + lock_time: bitcoin::absolute::LockTime::ZERO, + input: vec![TxIn::default()], + output: vec![TxOut { + value: bitcoin::Amount::from_sat(1000), + script_pubkey: bitcoin::ScriptBuf::new(), + }], + }; + let _ = tx_graph.insert_tx(tx.clone()); + let txid = tx.compute_txid(); + + let anchor = crate::ConfirmationBlockTime { + block_id: chain.get(5).unwrap().block_id(), + confirmation_time: 600, + }; + let _ = tx_graph.insert_anchor(txid, anchor); + + // Run the two-phase pipeline with MTP enabled + let params = CanonicalParams::default(); + let task = CanonicalTask::new(&tx_graph, chain_tip, params); + let (txs, queries) = chain.canonicalize(task); + let view_task = txs.view_task(&tx_graph, queries).with_mtp(); + let view = chain.canonicalize(view_task); + + // Should have one tx + assert_eq!(view.txs().len(), 1); + assert!(matches!( + view.txs().next().unwrap().pos, + ChainPosition::Confirmed { .. } + )); + + // Per-tx MTP at height 5: median of timestamps at heights 0..=5 + // Timestamps: 100, 200, 300, 400, 500, 600 → sorted, median = ts[len/2] = ts[3] = 400 + let canon_tx = view.txs().next().unwrap(); + assert_eq!(canon_tx.mtp, Some(400)); + + // MTP at tip height (11): median of timestamps at heights 1..=11 + // Timestamps: 200, 300, 400, 500, 600, 700, 800, 900, 1000, 1100, 1200 + // 11 elements, median = ts[5] = 700 + assert_eq!(view.tip_mtp(), Some(700)); + } } diff --git a/crates/chain/src/canonical_view_task.rs b/crates/chain/src/canonical_view_task.rs index a6770de84..b13798129 100644 --- a/crates/chain/src/canonical_view_task.rs +++ b/crates/chain/src/canonical_view_task.rs @@ -3,20 +3,21 @@ 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 bdk_core::{BlockId, BlockQueries, ChainQuery, TaskProgress, ToBlockHash, ToBlockTime}; +use bitcoin::{OutPoint, Txid}; -use crate::{Anchor, CanonicalView, ChainPosition, TxGraph}; +use crate::{canonical::CanonicalEntry, Anchor, CanonicalView, ChainPosition, TxGraph}; /// Represents the current stage of view task processing. #[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] enum ViewStage { - /// Processing transactions to resolve their chain positions. + /// Verifying anchors for transitively anchored transactions. #[default] ResolvingPositions, + /// Fetching blocks needed for MTP computation. + FetchingMtpBlocks, /// All processing is complete. Finished, } @@ -28,48 +29,61 @@ enum ViewStage { /// 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> { +/// +/// When `with_mtp()` is called, this task also computes median-time-past (MTP) values +/// for confirmed heights and stores them in the resulting [`CanonicalView`]. +pub struct CanonicalViewTask<'g, A, B> { tx_graph: &'g TxGraph, tip: BlockId, - /// Transactions in canonical order with their reasons. + queries: BlockQueries, + canonical_order: Vec, - canonical_txs: HashMap, CanonicalReason)>, + canonical_txs: HashMap>>, spends: HashMap, - - /// Transactions that need anchor verification (transitively anchored). unprocessed_anchor_checks: VecDeque<(Txid, &'g BTreeSet)>, - - /// Resolved direct anchors for transitively anchored transactions. direct_anchors: HashMap, + // MTP support — `extract_time` being `Some` means MTP is enabled. + extract_time: Option u32>, + current_stage: ViewStage, } -impl<'g, A: Anchor> CanonicalViewTask<'g, A> { +impl<'g, A: Anchor, B> CanonicalViewTask<'g, A, B> { /// 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( + /// Accepts canonical transaction data, a reference to the [`TxGraph`], and blocks + /// already fetched during phase 1 (to avoid redundant queries). + pub(crate) fn new( tx_graph: &'g TxGraph, tip: BlockId, order: Vec, - txs: HashMap, CanonicalReason)>, + txs: HashMap>>, spends: HashMap, + queries: BlockQueries, ) -> Self { let all_anchors = tx_graph.all_anchors(); let mut unprocessed_anchor_checks = VecDeque::new(); + let mut direct_anchors = HashMap::new(); for txid in &order { - if let Some((_, 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)); + if let Some(entry) = txs.get(txid) { + match &entry.pos { + CanonicalReason::Anchor { + anchor, + descendant: None, + } => { + // Non-transitive anchor — already resolved. + direct_anchors.insert(*txid, anchor.clone()); } + CanonicalReason::Anchor { .. } | CanonicalReason::Assumed { .. } => { + // Transitive or assumed — needs anchor verification. + if let Some(anchors) = all_anchors.get(txid) { + unprocessed_anchor_checks.push_back((*txid, anchors)); + } + } + CanonicalReason::ObservedIn { .. } => {} } } } @@ -77,68 +91,141 @@ impl<'g, A: Anchor> CanonicalViewTask<'g, A> { Self { tx_graph, tip, + queries, canonical_order: order, canonical_txs: txs, spends, unprocessed_anchor_checks, - direct_anchors: HashMap::new(), + direct_anchors, + extract_time: None, current_stage: ViewStage::default(), } } } -impl<'g, A: Anchor> ChainQuery for CanonicalViewTask<'g, A> { +impl<'g, A: Anchor, B: ToBlockHash + ToBlockTime> CanonicalViewTask<'g, A, B> { + /// Enable MTP (median-time-past) computation. + /// + /// When enabled, the task will fetch additional blocks needed to compute MTP values + /// for each confirmed height and the tip height. The resulting [`CanonicalView`] will + /// have per-tx MTP on [`CanonicalTx::mtp`](crate::CanonicalTx::mtp) and the tip MTP + /// accessible via [`tip_mtp()`](crate::Canonical::tip_mtp). + pub fn with_mtp(mut self) -> Self { + self.extract_time = Some(B::to_blocktime); + self + } +} + +impl<'g, A: Anchor, B: ToBlockHash> ChainQuery for CanonicalViewTask<'g, A, B> { type Output = CanonicalView; fn tip(&self) -> BlockId { self.tip } - fn next_query(&mut self) -> Option { - loop { - match self.current_stage { - ViewStage::ResolvingPositions => { - if let Some((_txid, anchors)) = self.unprocessed_anchor_checks.front() { - let block_ids = - anchors.iter().map(|anchor| anchor.anchor_block()).collect(); - return Some(block_ids); - } - } - ViewStage::Finished => return None, - } - - self.current_stage = ViewStage::Finished; - } + fn unresolved_queries<'a>(&'a self) -> impl Iterator + 'a { + self.queries.unresolved() } - fn resolve_query(&mut self, response: ChainResponse) { + fn poll(&mut self) -> TaskProgress { 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); + let mut best_anchor = Option::::None; + let mut has_unresolved_heights = false; + + for a in anchors.iter() { + let h = a.anchor_block().height; + match self.queries.get(h) { + Some(Some(b)) => { + if b.to_blockhash() == a.anchor_block().hash { + best_anchor = Some(a.clone()); + break; + } + } + Some(None) => {} + None => { + has_unresolved_heights = true; + } + } } + + if let Some(anchor) = best_anchor { + self.direct_anchors.insert(txid, anchor); + return TaskProgress::Advanced; + } + + if has_unresolved_heights { + let heights = self + .queries + .request(anchors.iter().map(|a| a.anchor_block().height)); + self.unprocessed_anchor_checks.push_front((txid, anchors)); + return TaskProgress::Query(heights); + } + + // No confirmed anchor found for this tx + TaskProgress::Advanced + } else { + self.current_stage = ViewStage::FetchingMtpBlocks; + TaskProgress::Advanced } } - ViewStage::Finished => { - debug_assert!(false, "resolve_query called in Finished stage"); + ViewStage::FetchingMtpBlocks => { + if self.extract_time.is_none() { + self.current_stage = ViewStage::Finished; + return TaskProgress::Advanced; + } + + // Collect all MTP heights needed. + let mut mtp_heights = BTreeSet::new(); + mtp_heights.insert(self.tip.height); + for anchor in self.direct_anchors.values() { + mtp_heights.insert(anchor.confirmation_height_upper_bound()); + } + + let needed = self + .queries + .request(mtp_heights.iter().flat_map(|&h| h.saturating_sub(10)..=h)); + + if needed.is_empty() { + self.current_stage = ViewStage::Finished; + return TaskProgress::Advanced; + } + + TaskProgress::Query(needed) } + ViewStage::Finished => TaskProgress::Done, } } + fn resolve_query(&mut self, height: u32, block: Option) { + self.queries.resolve(height, block); + } + fn finish(self) -> Self::Output { + // Helper: compute MTP for a given height from the blocks map. + let compute_mtp_at = |h: u32, f: fn(&B) -> u32| -> Option { + let start = h.saturating_sub(10); + let mut ts: Vec = (start..=h) + .map(|mtp_h| self.queries.get(mtp_h).and_then(|b| b.as_ref()).map(f)) + .collect::>>()?; + ts.sort_unstable(); + Some(ts[ts.len() / 2]) + }; + + let extract_time = self.extract_time; + + // Compute tip MTP. + let tip_mtp = extract_time.and_then(|f| compute_mtp_at(self.tip.height, f)); + let mut view_order = Vec::new(); let mut view_txs = HashMap::new(); for txid in &self.canonical_order { - if let Some((tx, reason)) = self.canonical_txs.get(txid) { + if let Some(CanonicalEntry { + tx, pos: reason, .. + }) = self.canonical_txs.get(txid) + { view_order.push(*txid); // Get transaction node for first_seen/last_seen info @@ -150,50 +237,64 @@ impl<'g, A: Anchor> ChainQuery for CanonicalViewTask<'g, A> { } }; - // Determine chain position based on reason - let chain_position = match reason { - CanonicalReason::Assumed { .. } => match self.direct_anchors.get(txid) { - Some(anchor) => ChainPosition::Confirmed { + // Determine chain position and per-tx MTP. For confirmed txs, prefer + // direct_anchors (which includes both non-transitive anchors and resolved + // anchors for transitive/assumed txs). + let (chain_position, mtp) = if let Some(anchor) = self.direct_anchors.get(txid) { + let mtp = extract_time + .and_then(|f| compute_mtp_at(anchor.confirmation_height_upper_bound(), f)); + ( + ChainPosition::Confirmed { anchor, transitively: None, }, - 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, + mtp, + ) + } else { + match reason { + CanonicalReason::Anchor { anchor, descendant } => { + let mtp = extract_time.and_then(|f| { + compute_mtp_at(anchor.confirmation_height_upper_bound(), f) + }); + ( + ChainPosition::Confirmed { + anchor, + transitively: *descendant, + }, + mtp, + ) + } + CanonicalReason::ObservedIn { + observed_in: ObservedIn::Mempool(last_seen), + .. + } => ( + ChainPosition::Unconfirmed { + first_seen: tx_node.first_seen, + last_seen: Some(*last_seen), }, - None => ChainPosition::Confirmed { - anchor, - transitively: *descendant, + None, + ), + _ => ( + ChainPosition::Unconfirmed { + first_seen: tx_node.first_seen, + last_seen: tx_node.last_seen, }, - }, - 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, - }, - }, + None, + ), + } }; - view_txs.insert(*txid, (tx.clone(), chain_position.cloned())); + view_txs.insert( + *txid, + CanonicalEntry { + tx: tx.clone(), + pos: chain_position.cloned(), + mtp, + }, + ); } } - CanonicalView::new(self.tip, view_order, view_txs, self.spends.clone()) + CanonicalView::new(self.tip, view_order, view_txs, self.spends.clone(), tip_mtp) } } diff --git a/crates/chain/src/indexed_tx_graph.rs b/crates/chain/src/indexed_tx_graph.rs index 98c5d16db..02549bba7 100644 --- a/crates/chain/src/indexed_tx_graph.rs +++ b/crates/chain/src/indexed_tx_graph.rs @@ -439,11 +439,11 @@ where /// that can be used to determine which transactions are canonical based on the provided /// parameters. The task handles the stateless canonicalization logic and can be polled /// for anchor verification requests. - pub fn canonical_task( + pub fn canonical_task( &'_ self, chain_tip: BlockId, params: CanonicalParams, - ) -> CanonicalTask<'_, A> { + ) -> CanonicalTask<'_, A, B> { self.graph.canonical_task(chain_tip, params) } } diff --git a/crates/chain/src/local_chain.rs b/crates/chain/src/local_chain.rs index 83cc072e2..86eea4fa8 100644 --- a/crates/chain/src/local_chain.rs +++ b/crates/chain/src/local_chain.rs @@ -4,8 +4,8 @@ use core::fmt; use core::ops::RangeBounds; use crate::collections::BTreeMap; -use crate::{Anchor, BlockId, CanonicalParams, CanonicalView, Merge, TxGraph}; -use bdk_core::{ChainQuery, ToBlockHash}; +use crate::{Anchor, BlockId, CanonicalParams, CanonicalTask, CanonicalView, Merge, TxGraph}; +use bdk_core::{ChainQuery, TaskProgress, ToBlockHash, ToBlockTime}; pub use bdk_core::{CheckPoint, CheckPointIter}; use bitcoin::block::Header; use bitcoin::BlockHash; @@ -70,28 +70,6 @@ impl PartialEq for LocalChain { // Methods for `LocalChain` impl LocalChain { - /// Check if a block is in the chain. - /// - /// # Arguments - /// * `block` - The block to check - /// * `chain_tip` - The chain tip to check against - /// - /// # Returns - /// * `Some(true)` if the block is in the chain - /// * `Some(false)` if the block is not in the chain - /// * `None` if it cannot be determined - pub fn is_block_in_chain(&self, block: BlockId, chain_tip: BlockId) -> Option { - let chain_tip_cp = match self.tip.get(chain_tip.height) { - // we can only determine whether `block` is in chain of `chain_tip` if `chain_tip` can - // be identified in chain - Some(cp) if cp.hash() == chain_tip.hash => cp, - _ => return None, - }; - chain_tip_cp - .get(block.height) - .map(|cp| cp.hash() == block.hash) - } - /// Get the chain tip. /// /// # Returns @@ -100,58 +78,6 @@ impl LocalChain { self.tip.block_id() } - /// 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::{CanonicalTask, CanonicalParams, TxGraph, local_chain::LocalChain}; - /// # use bdk_core::BlockId; - /// # use bitcoin::hashes::Hash; - /// # let tx_graph: TxGraph = TxGraph::default(); - /// # let chain = LocalChain::from_blocks([(0, bitcoin::BlockHash::all_zeros())].into_iter().collect()).unwrap(); - /// let chain_tip = chain.tip().block_id(); - /// let task = CanonicalTask::new(&tx_graph, chain_tip, CanonicalParams::default()); - /// let view = chain.canonicalize(task); - /// ``` - pub fn canonicalize(&self, mut task: Q) -> Q::Output - where - Q: ChainQuery, - { - let chain_tip = task.tip(); - while let Some(request) = task.next_query() { - let mut best_block_id = None; - for block_id in &request { - if self.is_block_in_chain(*block_id, chain_tip) == Some(true) { - best_block_id = Some(*block_id); - break; - } - } - task.resolve_query(best_block_id); - } - task.finish() - } - - /// Convenience method that runs both canonicalization phases and returns a [`CanonicalView`]. - /// - /// This is equivalent to: - /// ```ignore - /// let canonical_txs = chain.canonicalize(tx_graph.canonical_task(tip, params)); - /// let view = chain.canonicalize(canonical_txs.view_task(tx_graph)); - /// ``` - pub fn canonical_view( - &self, - tx_graph: &TxGraph, - tip: BlockId, - params: CanonicalParams, - ) -> CanonicalView { - let canonical_txs = self.canonicalize(tx_graph.canonical_task(tip, params)); - self.canonicalize(canonical_txs.view_task(tx_graph)) - } - /// Update the chain with a given [`Header`] at `height` which you claim is connected to a /// existing block in the chain. /// @@ -462,6 +388,92 @@ where Ok(changeset) } + /// Check if a block is in the chain. + /// + /// # Returns + /// * `Some(true)` if the block is in the chain + /// * `Some(false)` if the block is not in the chain + /// * `None` if it cannot be determined + pub fn is_block_in_chain(&self, block: BlockId, chain_tip: BlockId) -> Option { + let chain_tip_cp = match self.tip.get(chain_tip.height) { + Some(cp) if cp.hash() == chain_tip.hash => cp, + _ => return None, + }; + chain_tip_cp + .get(block.height) + .map(|cp| cp.hash() == block.hash) + } + + /// Canonicalize a transaction graph using this chain. + /// + /// This method processes any type implementing [`ChainQuery`], handling all its requests + /// to determine which transactions are canonical, and returns the query's output. + /// The chain responds with its data type `D`, so `LocalChain
` responds with + /// headers and `LocalChain` responds with hashes. + pub fn canonicalize(&self, mut task: Q) -> Q::Output + where + Q: ChainQuery, + D: Clone, + { + loop { + match task.poll() { + TaskProgress::Advanced => continue, + TaskProgress::Done => return task.finish(), + TaskProgress::Query(heights) => { + debug_assert!(!heights.is_empty(), "TaskProgress::Query must not be empty"); + for height in heights { + let data = self + .get(height) + .filter(|cp| { + let chain_tip = task.tip(); + self.is_block_in_chain(cp.block_id(), chain_tip) == Some(true) + }) + .map(|cp| cp.data()); + task.resolve_query(height, data); + } + } + } + } + } + + /// Convenience method that runs both canonicalization phases and returns a [`CanonicalView`]. + /// + /// This is equivalent to: + /// ```ignore + /// let (txs, queries) = chain.canonicalize(CanonicalTask::new(&tx_graph, tip, params)); + /// let view = chain.canonicalize(txs.view_task(tx_graph, queries)); + /// ``` + pub fn canonical_view( + &self, + tx_graph: &TxGraph, + tip: BlockId, + params: CanonicalParams, + ) -> CanonicalView { + let (txs, queries) = self.canonicalize(CanonicalTask::new(tx_graph, tip, params)); + self.canonicalize(txs.view_task(tx_graph, queries)) + } + + /// Like [`canonical_view`](Self::canonical_view), but also computes median-time-past (MTP) + /// values for confirmed transactions and the chain tip. + /// + /// Requires `D: ToBlockTime` (e.g. `LocalChain
`). + /// + /// The resulting [`CanonicalView`] will have per-tx MTP on + /// [`CanonicalTx::mtp`](crate::CanonicalTx::mtp) and the tip MTP accessible via + /// [`tip_mtp()`](crate::Canonical::tip_mtp). + pub fn canonical_view_with_mtp( + &self, + tx_graph: &TxGraph, + tip: BlockId, + params: CanonicalParams, + ) -> CanonicalView + where + D: ToBlockTime, + { + let (txs, queries) = self.canonicalize(CanonicalTask::new(tx_graph, tip, params)); + self.canonicalize(txs.view_task(tx_graph, queries).with_mtp()) + } + fn _check_changeset_is_applied(&self, changeset: &ChangeSet) -> bool { let mut cur = self.tip.clone(); for (&exp_height, exp_data) in changeset.blocks.iter().rev() { diff --git a/crates/chain/src/tx_graph.rs b/crates/chain/src/tx_graph.rs index ae38aaaf2..f2adc7e01 100644 --- a/crates/chain/src/tx_graph.rs +++ b/crates/chain/src/tx_graph.rs @@ -133,8 +133,8 @@ use crate::{Anchor, BlockId, Merge}; use alloc::collections::vec_deque::VecDeque; use alloc::sync::Arc; use alloc::vec::Vec; -use bdk_core::ConfirmationBlockTime; pub use bdk_core::TxUpdate; +use bdk_core::{ConfirmationBlockTime, ToBlockHash}; use bitcoin::{Amount, OutPoint, SignedAmount, Transaction, TxOut, Txid}; use core::fmt::{self, Formatter}; use core::ops::{Deref, RangeInclusive}; @@ -981,11 +981,11 @@ impl TxGraph { /// for anchor verification requests. /// /// [`CanonicalView`]: crate::CanonicalView - pub fn canonical_task( + pub fn canonical_task( &'_ self, chain_tip: BlockId, params: CanonicalParams, - ) -> CanonicalTask<'_, A> { + ) -> CanonicalTask<'_, A, B> { CanonicalTask::new(self, chain_tip, params) } } diff --git a/crates/core/src/block_queries.rs b/crates/core/src/block_queries.rs new file mode 100644 index 000000000..fbf365e80 --- /dev/null +++ b/crates/core/src/block_queries.rs @@ -0,0 +1,67 @@ +use crate::collections::BTreeMap; +use alloc::collections::BTreeSet; +use alloc::vec::Vec; + +/// Tracks block-height queries: which heights have been requested, +/// which are still pending, and the resolved data. +/// +/// This is a helper for [`ChainQuery`](crate::ChainQuery) implementors that need to +/// track which block heights have been requested from the chain source and which +/// responses have been received. It deduplicates requests and stores resolved blocks. +/// +/// # Usage +/// +/// 1. Call [`request`](Self::request) with the heights you need — it returns only the heights that +/// haven't been requested or resolved yet. +/// 2. Feed responses back via [`resolve`](Self::resolve). +/// 3. Look up resolved blocks with [`get`](Self::get). +/// 4. Check for outstanding requests with [`unresolved`](Self::unresolved). +pub struct BlockQueries { + pending: BTreeSet, + blocks: BTreeMap>, +} + +impl BlockQueries { + /// Creates an empty `BlockQueries`. + pub fn new() -> Self { + Self { + pending: BTreeSet::new(), + blocks: BTreeMap::new(), + } + } + + /// Filters out already-resolved/pending heights, inserts new ones into pending, + /// and returns the list of newly requested heights. + pub fn request(&mut self, heights: impl Iterator) -> Vec { + heights + .filter(|h| !self.blocks.contains_key(h)) + .filter(|h| self.pending.insert(*h)) + .collect() + } + + /// Marks a height as resolved. Removes from pending and inserts into blocks. + pub fn resolve(&mut self, height: u32, block: Option) { + let was_pending = self.pending.remove(&height); + self.blocks.insert(height, block); + debug_assert!( + was_pending, + "request must be previously unresolved to be resolved now" + ); + } + + /// Iterates over heights that are still pending (unresolved). + pub fn unresolved(&self) -> impl Iterator + '_ { + self.pending.iter().copied() + } + + /// Looks up a resolved block by height. + pub fn get(&self, height: u32) -> Option<&Option> { + self.blocks.get(&height) + } +} + +impl Default for BlockQueries { + fn default() -> Self { + Self::new() + } +} diff --git a/crates/core/src/chain_query.rs b/crates/core/src/chain_query.rs index 047d0bb50..635fd238f 100644 --- a/crates/core/src/chain_query.rs +++ b/crates/core/src/chain_query.rs @@ -1,40 +1,109 @@ -//! Trait for query-based canonicalization against blockchain data. +//! Sans-IO trait for tasks that need to query block data from a chain source. //! -//! The [`ChainQuery`] trait provides a sans-IO interface for algorithms that -//! need to verify block confirmations against a chain source. +//! [`ChainQuery`] defines a cooperative protocol between a **task** (the implementor) +//! and a **driver** (the caller that has access to the chain source). The task +//! declares what block heights it needs, the driver fetches them, and the task +//! makes progress until it produces a final output. +//! +//! # The driver loop +//! +//! A correct driver calls [`poll`](ChainQuery::poll) in a loop, matching the returned +//! [`TaskProgress`]: +//! +//! ```ignore +//! loop { +//! match task.poll() { +//! TaskProgress::Advanced => continue, +//! TaskProgress::Done => return task.finish(), +//! TaskProgress::Query(heights) => { +//! debug_assert!(!heights.is_empty()); +//! for h in heights { +//! let block = chain_source.get(h); +//! task.resolve_query(h, block); +//! } +//! } +//! } +//! } +//! ``` +//! +//! # The block type `B` +//! +//! The generic parameter `B` is the block data the task receives from the driver. +//! It defaults to [`BlockHash`] but can be any type (e.g. [`Header`](bitcoin::block::Header)) +//! depending on the chain source. A response of `None` means the chain source has +//! no block at that height. use crate::BlockId; use alloc::vec::Vec; +use bitcoin::BlockHash; -/// A request containing [`BlockId`]s to check for confirmation in the chain. -pub type ChainRequest = Vec; - -/// Response containing the best confirmed [`BlockId`], if any. -pub type ChainResponse = Option; +/// Progress indicator returned by [`ChainQuery::poll`]. +/// +/// This enum makes "stuck" states unrepresentable: the task must always either +/// make progress, request data, or declare itself done. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum TaskProgress { + /// Internal progress was made. The driver should call [`poll`](ChainQuery::poll) again. + /// + /// Implementations should return this at stage transitions and after processing + /// individual items, rather than looping internally. This keeps `poll()` doing + /// one unit of work per call, giving the driver observability into progress and + /// an opportunity to cancel or log between steps. + Advanced, + /// The task needs block data at these heights. The driver should resolve each + /// height via [`resolve_query`](ChainQuery::resolve_query) and then call + /// [`poll`](ChainQuery::poll) again. + /// + /// The heights vector is guaranteed to be non-empty by correct implementations. + Query(Vec), + /// The task is complete. The driver should call [`finish`](ChainQuery::finish). + Done, +} -/// A trait for types that verify block confirmations against blockchain data. +/// A sans-IO task that queries block data by height from a chain source. /// -/// 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. +/// See the [module-level documentation](self) for the driver loop contract. /// -/// `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. +/// # Contract for implementors +/// +/// - [`poll`](Self::poll) should do one logical unit of work and return: +/// - [`TaskProgress::Advanced`] when internal progress was made (e.g. a stage transition or +/// processing a single item). The driver will call `poll()` again. +/// - [`TaskProgress::Query`] when the task needs block data at one or more heights. +/// - [`TaskProgress::Done`] when all processing is complete. +/// - Prefer returning [`TaskProgress::Advanced`] over looping internally. This gives the driver +/// control between steps for progress reporting, cancellation, or logging. +/// - [`finish`](Self::finish) consumes the task and produces the final output. It should only be +/// called after `poll` returns [`TaskProgress::Done`]. +pub trait ChainQuery { + /// The final output produced when the task completes. type Output; - /// Returns the chain tip used as the reference point for all queries. + /// The chain tip that serves as the reference point for all queries. fn tip(&self) -> BlockId; - /// Returns the next query needed, or `None` if no more queries are required. - fn next_query(&mut self) -> Option; + /// Drive the task forward, returning its progress status. + /// + /// Each call should perform one logical unit of work. The driver calls this + /// in a loop, matching the returned [`TaskProgress`]. + fn poll(&mut self) -> TaskProgress; + + /// Provides the block data (or `None`) for a previously requested `height`. + /// + /// A response of `None` signals that the chain source has no block at that + /// height. The task records this so it can distinguish "not yet queried" + /// from "queried but absent." + fn resolve_query(&mut self, height: u32, response: Option); - /// Resolves a query with the given response. - fn resolve_query(&mut self, response: ChainResponse); + /// Returns heights that have been requested but not yet resolved. + /// + /// This is useful for retrying failed queries or reporting progress. + fn unresolved_queries<'a>(&'a self) -> impl Iterator + 'a; - /// Completes the query process and returns the final output. + /// Consumes the task and returns the final output. + /// + /// # Panics /// - /// This should be called once [`next_query`](Self::next_query) returns `None`. + /// May panic if called before [`poll`](Self::poll) returns [`TaskProgress::Done`]. fn finish(self) -> Self::Output; } diff --git a/crates/core/src/lib.rs b/crates/core/src/lib.rs index 33e921687..757d35050 100644 --- a/crates/core/src/lib.rs +++ b/crates/core/src/lib.rs @@ -75,3 +75,6 @@ pub mod spk_client; mod chain_query; pub use chain_query::*; + +mod block_queries; +pub use block_queries::*;