]> Untitled Git - bdk/commitdiff
feat(chain): introduce new `list_ordered_canonical_txs`
authorLeonardo Lima <oleonardolima@users.noreply.github.com>
Tue, 23 Sep 2025 06:35:50 +0000 (03:35 -0300)
committerLeonardo Lima <oleonardolima@users.noreply.github.com>
Sat, 22 Nov 2025 04:39:23 +0000 (01:39 -0300)
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<Item =
CanonicalTx<'a, Arc<Transaction>, 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 <noreply@anthropic.com>
crates/chain/benches/canonicalization.rs
crates/chain/src/canonical_iter.rs
crates/chain/src/tx_graph.rs
crates/chain/tests/test_indexed_tx_graph.rs
crates/chain/tests/test_tx_graph.rs
examples/example_bitcoind_rpc_polling/src/main.rs
examples/example_electrum/src/main.rs
examples/example_esplora/src/main.rs

index df9c08b01e2e3518707da522e00df891d225a6d9..cacc26ec16d89b816557a44f3fe2bcc70077880d 100644 (file)
@@ -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))
index da1a124e85df32c1b4dae30958d1d597115fa29f..7ff04249b5cedf8f69900a20f02223412ee9dc8b 100644 (file)
@@ -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<A: Clone> CanonicalReason<A> {
         }
     }
 }
+
+/// 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<Txid, CanonicalTx<'a, Arc<Transaction>, A>>,
+
+    /// Current level of transactions to process
+    current_level: Vec<Txid>,
+    /// Next level of transactions to process
+    next_level: Vec<Txid>,
+
+    /// Adjacency list: parent txid -> list of children txids
+    children_map: HashMap<Txid, Vec<Txid>>,
+    /// Number of unprocessed parents for each transaction
+    parent_count: HashMap<Txid, usize>,
+
+    /// 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<Item = CanonicalTx<'a, Arc<Transaction>, A>>,
+    ) -> Self {
+        // Build a map from txid to canonical tx for quick lookup
+        let mut tx_map: HashMap<Txid, CanonicalTx<'a, Arc<Transaction>, A>> = HashMap::new();
+        let mut canonical_set: HashSet<Txid> = 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<Txid, Vec<Txid>> = HashMap::new();
+        let mut has_parents: HashSet<Txid> = 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<Txid, Vec<Txid>> = 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<Txid> = 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<Txid, CanonicalTx<'a, Arc<Transaction>, 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<Transaction>, A>;
+
+    fn next(&mut self) -> Option<Self::Item> {
+        // 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(&current_txid).cloned()
+    }
+}
index 7bbdef63f0d01abe5668bf6aef53fb43d24403dd..546c4b5939c6f612df55bf61c935a99a00c177c8 100644 (file)
@@ -988,6 +988,9 @@ impl<A: Anchor> TxGraph<A> {
     /// 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<A: Anchor> TxGraph<A> {
     /// 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<A: Anchor> TxGraph<A> {
     ///
     /// 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<Error = Infallible> + 'a>(
         &'a self,
         chain: &'a C,
@@ -1090,6 +1098,28 @@ impl<A: Anchor> TxGraph<A> {
             .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<Error = Infallible>>(
+        &'a self,
+        chain: &'a C,
+        chain_tip: BlockId,
+        params: CanonicalizationParams,
+    ) -> impl Iterator<Item = CanonicalTx<'a, Arc<Transaction>, 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<A: Anchor> TxGraph<A> {
     ) -> Result<impl Iterator<Item = (OI, FullTxOut<A>)> + 'a, C::Error> {
         let mut canon_txs = HashMap::<Txid, CanonicalTx<Arc<Transaction>, A>>::new();
         let mut canon_spends = HashMap::<OutPoint, Txid>::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<A: Anchor> TxGraph<A> {
         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<Result<(ScriptBuf, Txid), C::Error>> {
                 let range = &spk_index_range;
index 1e87f009a72212ce4792b752468ca2109d5f0dbb..d6efef2ee6ddfbf0dbb34991ebe4848d959604cf 100644 (file)
@@ -782,6 +782,7 @@ fn test_get_chain_position() {
         }
 
         // check chain position
+
         let chain_pos = graph
             .graph()
             .list_canonical_txs(
index 205ef58b20d41ca5f8c62691d0388d3c1ca55b11..0e43d8e787778fa7a71f34e0921e66609d36fb91 100644 (file)
@@ -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::<BTreeSet<_>>();
+            .collect::<Vec<_>>();
 
-        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::<BTreeSet<_>>();
+            .collect::<Vec<_>>();
 
         assert_eq!(
-            canonical_txs, exp_txs,
+            HashSet::<Txid>::from_iter(canonical_txids.clone()),
+            HashSet::<Txid>::from_iter(exp_txids.clone()),
             "\n[{}] 'list_canonical_txs' failed",
             scenario.name
         );
 
-        let canonical_txs = canonical_txs.iter().map(|txid| *txid).collect::<Vec<_>>();
-
         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
         );
index cb710151a858422e494d4691d8a6e7adece8795b..ff2426add1b7730185e40426add5ebd5f3e27cb2 100644 (file)
@@ -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();
index bc76776a6e87a8842d14102d36a970ba33bc62be..ede41315b9a6edf36031e850a90af4d0a227abab 100644 (file)
@@ -238,6 +238,7 @@ fn main() -> anyhow::Result<()> {
                         .map(|(_, utxo)| utxo.outpoint),
                 );
             };
+
             if unconfirmed {
                 request = request.txids(
                     graph
index f41d2536e3d262ce9f5ef310c91e5bae088d9513..c08f23348c164d47b33707863321a126d9f20c47 100644 (file)
@@ -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