From: Leonardo Lima Date: Tue, 23 Sep 2025 06:35:50 +0000 (-0300) Subject: feat(chain): introduce new `list_ordered_canonical_txs` X-Git-Tag: core-0.6.3~7^2 X-Git-Url: http://internal-gitweb-vhost/?a=commitdiff_plain;h=12c107637eb46cf9674d09067f65d07d1701f60d;p=bdk feat(chain): introduce new `list_ordered_canonical_txs` Adds a new `list_ordered_canonical_txs` method which uses the new `TopologicalIterator` on top of the result of `list_canonical_txs` method, yielding the canonical txs in topological spending order. The new `list_ordered_canonical_txs` guarantees that spending transactions appears after their inputs, in topological "spending order". - Introduce the new `TopologicalIterator` for level-based topological sorting, based on Kahn's Algorithm, it uses the `ChainPosition` for sorting within the same graph level, and it takes an `Iterator, A>> of canonical txs. - Introduce the new `list_ordered_canonical_txs` method to `TxGraph`. - Update the existing tests under `test_tx_graph.rs` to verify the topological ordering correctness. - Update the existing `canonicalization` benchmark to also use the new `topological_ordered_txs` method. NOTE: - I've squashed the previous commits into a single one, as they're changing the same files and applies to the same scope. - Also, I've partially used Claude to help with the Kahn's Algorithm. Co-Authored-By: Claude --- diff --git a/crates/chain/benches/canonicalization.rs b/crates/chain/benches/canonicalization.rs index df9c08b0..cacc26ec 100644 --- a/crates/chain/benches/canonicalization.rs +++ b/crates/chain/benches/canonicalization.rs @@ -99,6 +99,15 @@ fn run_list_canonical_txs(tx_graph: &KeychainTxGraph, chain: &LocalChain, exp_tx assert_eq!(txs.count(), exp_txs); } +fn run_list_ordered_canonical_txs(tx_graph: &KeychainTxGraph, chain: &LocalChain, exp_txs: usize) { + let txs = tx_graph.graph().list_ordered_canonical_txs( + chain, + chain.tip().block_id(), + CanonicalizationParams::default(), + ); + assert_eq!(txs.count(), exp_txs); +} + fn run_filter_chain_txouts(tx_graph: &KeychainTxGraph, chain: &LocalChain, exp_txos: usize) { let utxos = tx_graph.graph().filter_chain_txouts( chain, @@ -147,6 +156,13 @@ pub fn many_conflicting_unconfirmed(c: &mut Criterion) { let (tx_graph, chain) = (tx_graph.clone(), chain.clone()); move |b| b.iter(|| run_list_canonical_txs(&tx_graph, &chain, 2)) }); + c.bench_function( + "many_conflicting_unconfirmed::list_ordered_canonical_txs", + { + let (tx_graph, chain) = (tx_graph.clone(), chain.clone()); + move |b| b.iter(|| run_list_ordered_canonical_txs(&tx_graph, &chain, 2)) + }, + ); c.bench_function("many_conflicting_unconfirmed::filter_chain_txouts", { let (tx_graph, chain) = (tx_graph.clone(), chain.clone()); move |b| b.iter(|| run_filter_chain_txouts(&tx_graph, &chain, 2)) @@ -185,6 +201,10 @@ pub fn many_chained_unconfirmed(c: &mut Criterion) { let (tx_graph, chain) = (tx_graph.clone(), chain.clone()); move |b| b.iter(|| run_list_canonical_txs(&tx_graph, &chain, 2101)) }); + c.bench_function("many_chained_unconfirmed::list_ordered_canonical_txs", { + let (tx_graph, chain) = (tx_graph.clone(), chain.clone()); + move |b| b.iter(|| run_list_ordered_canonical_txs(&tx_graph, &chain, 2101)) + }); c.bench_function("many_chained_unconfirmed::filter_chain_txouts", { let (tx_graph, chain) = (tx_graph.clone(), chain.clone()); move |b| b.iter(|| run_filter_chain_txouts(&tx_graph, &chain, 1)) @@ -234,6 +254,13 @@ pub fn nested_conflicts(c: &mut Criterion) { let (tx_graph, chain) = (tx_graph.clone(), chain.clone()); move |b| b.iter(|| run_list_canonical_txs(&tx_graph, &chain, GRAPH_DEPTH)) }); + c.bench_function( + "nested_conflicts_unconfirmed::list_ordered_canonical_txs", + { + let (tx_graph, chain) = (tx_graph.clone(), chain.clone()); + move |b| b.iter(|| run_list_ordered_canonical_txs(&tx_graph, &chain, GRAPH_DEPTH)) + }, + ); c.bench_function("nested_conflicts_unconfirmed::filter_chain_txouts", { let (tx_graph, chain) = (tx_graph.clone(), chain.clone()); move |b| b.iter(|| run_filter_chain_txouts(&tx_graph, &chain, GRAPH_DEPTH)) diff --git a/crates/chain/src/canonical_iter.rs b/crates/chain/src/canonical_iter.rs index da1a124e..7ff04249 100644 --- a/crates/chain/src/canonical_iter.rs +++ b/crates/chain/src/canonical_iter.rs @@ -1,5 +1,5 @@ use crate::collections::{HashMap, HashSet, VecDeque}; -use crate::tx_graph::{TxAncestors, TxDescendants}; +use crate::tx_graph::{CanonicalTx, TxAncestors, TxDescendants}; use crate::{Anchor, ChainOracle, TxGraph}; use alloc::boxed::Box; use alloc::collections::BTreeSet; @@ -342,3 +342,157 @@ impl CanonicalReason { } } } + +/// Iterator based on the Kahn's Algorithm, that yields transactions in topological spending order +/// in depth, and properly sorted with level. +/// +/// NOTE: Please refer to the Kahn's Algorithm reference: https://dl.acm.org/doi/pdf/10.1145/368996.369025 +pub(crate) struct TopologicalIterator<'a, A> { + /// Map of txid to its canonical transaction + canonical_txs: HashMap, A>>, + + /// Current level of transactions to process + current_level: Vec, + /// Next level of transactions to process + next_level: Vec, + + /// Adjacency list: parent txid -> list of children txids + children_map: HashMap>, + /// Number of unprocessed parents for each transaction + parent_count: HashMap, + + /// Current index in the current level + current_index: usize, +} + +impl<'a, A: Clone + Anchor> TopologicalIterator<'a, A> { + /// Constructs [`TopologicalIterator`] from a list of `canonical_txs` (e.g [`CanonicalIter`]), + /// in order to handle all the graph building internally. + pub(crate) fn new( + canonical_txs: impl Iterator, A>>, + ) -> Self { + // Build a map from txid to canonical tx for quick lookup + let mut tx_map: HashMap, A>> = HashMap::new(); + let mut canonical_set: HashSet = HashSet::new(); + + for canonical_tx in canonical_txs { + let txid = canonical_tx.tx_node.txid; + canonical_set.insert(txid); + tx_map.insert(txid, canonical_tx); + } + + // Build the dependency graph (txid -> parents it depends on) + let mut dependencies: HashMap> = HashMap::new(); + let mut has_parents: HashSet = HashSet::new(); + + for &txid in canonical_set.iter() { + let canonical_tx = tx_map.get(&txid).expect("txid must exist in map"); + let tx = &canonical_tx.tx_node.tx; + + // Find all parents (transactions this one depends on) + let mut parents = Vec::new(); + if !tx.is_coinbase() { + for txin in &tx.input { + let parent_txid = txin.previous_output.txid; + // Only include if the parent is also canonical + if canonical_set.contains(&parent_txid) { + parents.push(parent_txid); + has_parents.insert(txid); + } + } + } + + if !parents.is_empty() { + dependencies.insert(txid, parents); + } + } + + // Build adjacency list and parent counts for traversal + let mut parent_count = HashMap::new(); + let mut children_map: HashMap> = HashMap::new(); + + for (txid, parents) in &dependencies { + for parent_txid in parents { + children_map.entry(*parent_txid).or_default().push(*txid); + *parent_count.entry(*txid).or_insert(0) += 1; + } + } + + // Find root transactions (those with no parents in the canonical set) + let roots: Vec = canonical_set + .iter() + .filter(|&&txid| !has_parents.contains(&txid)) + .copied() + .collect(); + + // Sort the initial level + let mut current_level = roots; + Self::sort_level_by_chain_position(&mut current_level, &tx_map); + + Self { + canonical_txs: tx_map, + current_level, + next_level: Vec::new(), + children_map, + parent_count, + current_index: 0, + } + } + + /// Sort transactions within a level by their chain position + /// Confirmed transactions come first (sorted by height), then unconfirmed (sorted by last_seen) + fn sort_level_by_chain_position( + level: &mut [Txid], + canonical_txs: &HashMap, A>>, + ) { + level.sort_by(|&a_txid, &b_txid| { + let a_tx = canonical_txs.get(&a_txid).expect("txid must exist"); + let b_tx = canonical_txs.get(&b_txid).expect("txid must exist"); + + a_tx.cmp(b_tx) + }); + } + + fn advance_to_next_level(&mut self) { + self.current_level = core::mem::take(&mut self.next_level); + Self::sort_level_by_chain_position(&mut self.current_level, &self.canonical_txs); + self.current_index = 0; + } +} + +impl<'a, A: Clone + Anchor> Iterator for TopologicalIterator<'a, A> { + type Item = CanonicalTx<'a, Arc, A>; + + fn next(&mut self) -> Option { + // If we've exhausted the current level, move to next + if self.current_index >= self.current_level.len() { + if self.next_level.is_empty() { + return None; + } + self.advance_to_next_level(); + } + + let current_txid = self.current_level[self.current_index]; + self.current_index += 1; + + // If this is the last item in current level, prepare dependents for next level + if self.current_index == self.current_level.len() { + // Process all dependents of all transactions in current level + for &tx in &self.current_level { + if let Some(children) = self.children_map.get(&tx) { + for &child in children { + if let Some(count) = self.parent_count.get_mut(&child) { + *count -= 1; + if *count == 0 { + self.next_level.push(child); + } + } + } + } + } + } + + // Return the CanonicalTx for the current txid + self.canonical_txs.get(¤t_txid).cloned() + } +} diff --git a/crates/chain/src/tx_graph.rs b/crates/chain/src/tx_graph.rs index 7bbdef63..546c4b59 100644 --- a/crates/chain/src/tx_graph.rs +++ b/crates/chain/src/tx_graph.rs @@ -988,6 +988,9 @@ impl TxGraph { /// Each transaction is represented as a [`CanonicalTx`] that contains where the transaction is /// observed in-chain, and the [`TxNode`]. /// + /// NOTE: It does not guarantee the topological order of yielded transactions, the + /// [`list_ordered_canonical_txs`] can be used instead. + /// /// # Error /// /// If the [`ChainOracle`] implementation (`chain`) fails, an error will be returned with the @@ -996,6 +999,7 @@ impl TxGraph { /// If the [`ChainOracle`] is infallible, [`list_canonical_txs`] can be used instead. /// /// [`list_canonical_txs`]: Self::list_canonical_txs + /// [`list_ordered_canonical_txs`]: Self::list_ordered_canonical_txs pub fn try_list_canonical_txs<'a, C: ChainOracle + 'a>( &'a self, chain: &'a C, @@ -1079,7 +1083,11 @@ impl TxGraph { /// /// This is the infallible version of [`try_list_canonical_txs`]. /// + /// NOTE: It does not guarantee the topological order of yielded transactions, the + /// [`list_ordered_canonical_txs`] can be used instead. + /// /// [`try_list_canonical_txs`]: Self::try_list_canonical_txs + /// [`list_ordered_canonical_txs`]: Self::list_ordered_canonical_txs pub fn list_canonical_txs<'a, C: ChainOracle + 'a>( &'a self, chain: &'a C, @@ -1090,6 +1098,28 @@ impl TxGraph { .map(|res| res.expect("infallible")) } + /// List graph transactions that are in `chain` with `chain_tip` in topological order. + /// + /// Each transaction is represented as a [`CanonicalTx`] that contains where the transaction is + /// observed in-chain, and the [`TxNode`]. + /// + /// Transactions are returned in topological spending order, meaning that if transaction B + /// spends from transaction A, then A will always appear before B in the resulting list. + /// + /// This is the infallible version which uses [`list_canonical_txs`] internally and then + /// reorders the transactions based on their spending relationships. + /// + /// [`list_canonical_txs`]: Self::list_canonical_txs + pub fn list_ordered_canonical_txs<'a, C: ChainOracle>( + &'a self, + chain: &'a C, + chain_tip: BlockId, + params: CanonicalizationParams, + ) -> impl Iterator, A>> { + use crate::canonical_iter::TopologicalIterator; + TopologicalIterator::new(self.list_canonical_txs(chain, chain_tip, params)) + } + /// Get a filtered list of outputs from the given `outpoints` that are in `chain` with /// `chain_tip`. /// @@ -1118,6 +1148,7 @@ impl TxGraph { ) -> Result)> + 'a, C::Error> { let mut canon_txs = HashMap::, A>>::new(); let mut canon_spends = HashMap::::new(); + for r in self.try_list_canonical_txs(chain, chain_tip, params) { let canonical_tx = r?; let txid = canonical_tx.tx_node.txid; @@ -1418,6 +1449,7 @@ impl TxGraph { I: fmt::Debug + Clone + Ord + 'a, { let indexer = indexer.as_ref(); + self.try_list_canonical_txs(chain, chain_tip, CanonicalizationParams::default()) .flat_map(move |res| -> Vec> { let range = &spk_index_range; diff --git a/crates/chain/tests/test_indexed_tx_graph.rs b/crates/chain/tests/test_indexed_tx_graph.rs index 1e87f009..d6efef2e 100644 --- a/crates/chain/tests/test_indexed_tx_graph.rs +++ b/crates/chain/tests/test_indexed_tx_graph.rs @@ -782,6 +782,7 @@ fn test_get_chain_position() { } // check chain position + let chain_pos = graph .graph() .list_canonical_txs( diff --git a/crates/chain/tests/test_tx_graph.rs b/crates/chain/tests/test_tx_graph.rs index 205ef58b..0e43d8e7 100644 --- a/crates/chain/tests/test_tx_graph.rs +++ b/crates/chain/tests/test_tx_graph.rs @@ -1201,6 +1201,7 @@ 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 .list_canonical_txs( &chain, @@ -1212,6 +1213,7 @@ fn transactions_inserted_into_tx_graph_are_not_canonical_until_they_have_an_anch // tx0 with seen_at should be returned by canonical txs let _ = graph.insert_seen_at(txids[0], 2); + let mut canonical_txs = graph.list_canonical_txs( &chain, chain.tip().block_id(), @@ -1225,6 +1227,7 @@ 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 canonical_txids: Vec<_> = graph .list_canonical_txs( &chain, @@ -2022,31 +2025,34 @@ fn test_list_ordered_canonical_txs() { exp_chain_txs: Vec::from(["a0", "e0", "f0", "b0", "c0", "b1", "c1", "d0"]), }]; - for (_, scenario) in scenarios.iter().enumerate() { + for scenario in scenarios { let env = init_graph(scenario.tx_templates.iter()); - let canonical_txs = env + let canonical_txids = env .tx_graph - .list_canonical_txs(&local_chain, chain_tip, env.canonicalization_params.clone()) + .list_ordered_canonical_txs( + &local_chain, + chain_tip, + env.canonicalization_params.clone(), + ) .map(|tx| tx.tx_node.txid) - .collect::>(); + .collect::>(); - let exp_txs = scenario + let exp_txids = scenario .exp_chain_txs .iter() .map(|txid| *env.tx_name_to_txid.get(txid).expect("txid must exist")) - .collect::>(); + .collect::>(); assert_eq!( - canonical_txs, exp_txs, + HashSet::::from_iter(canonical_txids.clone()), + HashSet::::from_iter(exp_txids.clone()), "\n[{}] 'list_canonical_txs' failed", scenario.name ); - let canonical_txs = canonical_txs.iter().map(|txid| *txid).collect::>(); - assert!( - is_txs_in_topological_order(canonical_txs, env.tx_graph), + is_txs_in_topological_order(canonical_txids, env.tx_graph), "\n[{}] 'list_canonical_txs' failed to output the txs in topological order", scenario.name ); diff --git a/examples/example_bitcoind_rpc_polling/src/main.rs b/examples/example_bitcoind_rpc_polling/src/main.rs index cb710151..ff2426ad 100644 --- a/examples/example_bitcoind_rpc_polling/src/main.rs +++ b/examples/example_bitcoind_rpc_polling/src/main.rs @@ -137,6 +137,7 @@ fn main() -> anyhow::Result<()> { } = rpc_args; let rpc_client = rpc_args.new_client()?; + let mut emitter = { let chain = chain.lock().unwrap(); let graph = graph.lock().unwrap(); @@ -237,6 +238,7 @@ fn main() -> anyhow::Result<()> { let sigterm_flag = start_ctrlc_handler(); let rpc_client = Arc::new(rpc_args.new_client()?); + let mut emitter = { let chain = chain.lock().unwrap(); let graph = graph.lock().unwrap(); diff --git a/examples/example_electrum/src/main.rs b/examples/example_electrum/src/main.rs index bc76776a..ede41315 100644 --- a/examples/example_electrum/src/main.rs +++ b/examples/example_electrum/src/main.rs @@ -238,6 +238,7 @@ fn main() -> anyhow::Result<()> { .map(|(_, utxo)| utxo.outpoint), ); }; + if unconfirmed { request = request.txids( graph diff --git a/examples/example_esplora/src/main.rs b/examples/example_esplora/src/main.rs index f41d2536..c08f2334 100644 --- a/examples/example_esplora/src/main.rs +++ b/examples/example_esplora/src/main.rs @@ -253,6 +253,7 @@ fn main() -> anyhow::Result<()> { .map(|(_, utxo)| utxo.outpoint), ); }; + if unconfirmed { // We want to search for whether the unconfirmed transaction is now confirmed. // We provide the unconfirmed txids to