From 88b7de27ce36162c2e04b0ef2523c1e53825dd65 Mon Sep 17 00:00:00 2001 From: Leonardo Lima Date: Fri, 12 Sep 2025 14:39:49 +1000 Subject: [PATCH] feat(chain): add topological ordering to `CanonicalView` - add `sort_topologically` to `CanonicalViewTask::finish()` using Kahn's algorithm, ensuring `CanonicalView` returns `Txid`s in topological order (parents before children) - add `test_list_ordered_canonical_txs` with scenarios covering various transaction graph shapes --- crates/chain/src/canonical.rs | 6 +- crates/chain/src/canonical_view_task.rs | 78 +++- crates/chain/tests/test_tx_graph.rs | 510 ++++++++++++++++++++++++ 3 files changed, 590 insertions(+), 4 deletions(-) diff --git a/crates/chain/src/canonical.rs b/crates/chain/src/canonical.rs index c2aecb756..3a32d0fdf 100644 --- a/crates/chain/src/canonical.rs +++ b/crates/chain/src/canonical.rs @@ -169,7 +169,7 @@ impl CanonicalTxOut> { /// Canonical set of transactions from a [`TxGraph`]. /// -/// `Canonical` provides a conflict-resolved list 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. /// @@ -179,14 +179,14 @@ impl CanonicalTxOut> { /// [`CanonicalTxs`]) /// /// The view maintains: -/// - A list of canonical transactions +/// - 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 /// /// [`TxGraph`]: crate::TxGraph #[derive(Debug)] pub struct Canonical { - /// List of canonical transaction IDs. + /// 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)>, diff --git a/crates/chain/src/canonical_view_task.rs b/crates/chain/src/canonical_view_task.rs index d981bcef4..222439af8 100644 --- a/crates/chain/src/canonical_view_task.rs +++ b/crates/chain/src/canonical_view_task.rs @@ -1,7 +1,7 @@ //! Phase 2 task: resolves canonical reasons into chain positions. use crate::canonical_task::{CanonicalReason, ObservedIn}; -use crate::collections::{HashMap, VecDeque}; +use crate::collections::{HashMap, HashSet, VecDeque}; use crate::tx_graph::TxDescendants; use alloc::collections::BTreeSet; use alloc::sync::Arc; @@ -88,6 +88,81 @@ impl<'g, A: Anchor> CanonicalViewTask<'g, A> { } } +/// Topologically sort [`Txid`]s (parents before children) using Kahn's algorithm. +/// +/// Given a set of canonical transactions and their spending relationships, +/// returns a new ordering where for every spending relationship A -> B +/// (where B spends an output of A), A appears before B. +/// +/// The algorithm works in three phases: +/// +/// 1. **Build the dependency graph**: Using the `spends` map, derive parent->child edges. Each +/// `(outpoint, child_txid)` entry means `outpoint.txid` (the parent) must come before +/// `child_txid`. Only edges where both parent and child are in the canonical set are considered. +/// +/// 2. **Find roots**: [`Txid`]s with `in_degree == 0` have no canonical parents and can appear +/// first. These seed the processing queue. +/// +/// 3. **BFS traversal**: Dequeue a [`Txid`], append it to the result, and decrement the `in_degree` +/// of each of its children. Whenever a child reaches `in_degree == 0`, all its parents have been +/// placed, so it is enqueued. +/// +/// # Note +/// +/// The relative order among unrelated transactions (those with no spending +/// relationship) is not guaranteed to be deterministic across runs, since +/// it depends on `HashMap` iteration order. +fn sort_topologically(order: &[Txid], spends: &HashMap) -> Vec { + // Set of canonical txids — we only consider parent→child edges where + // both sides are in this set. + let canonical_set: HashSet = order.iter().copied().collect(); + + // in_degree[txid] tracks how many canonical parents this tx spends from. + // A tx with in_degree 0 has no canonical parents and can appear first. + let mut in_degree: HashMap = order.iter().map(|&txid| (txid, 0)).collect(); + + // Adjacency list: maps each parent txid to the children that spend its + // outputs. Derived from `spends` where each entry (outpoint → child_txid) + // gives us an edge from outpoint.txid (parent) to child_txid. + let mut children: HashMap> = HashMap::new(); + + for (outpoint, &child) in spends { + let parent = outpoint.txid; + // Only consider edges where the parent is also canonical — spending + // from a tx outside this canonical set is not a dependency. + if canonical_set.contains(&parent) { + children.entry(parent).or_default().push(child); + *in_degree.entry(child).or_default() += 1; + } + } + + // Seed the queue with root transactions (those with no canonical parents). + let mut queue: VecDeque = in_degree + .iter() + .filter(|(_, &d)| d == 0) + .map(|(&txid, _)| txid) + .collect(); + + // Process the queue: each time we "remove" a tx from the graph, we + // decrement the in_degree of its children. When a child reaches + // in_degree 0, all its parents have been placed so it is ready. + let mut sorted = Vec::with_capacity(order.len()); + while let Some(txid) = queue.pop_front() { + sorted.push(txid); + if let Some(deps) = children.get(&txid) { + for &child in deps { + let d = in_degree.get_mut(&child).unwrap(); + *d -= 1; + if *d == 0 { + queue.push_back(child); + } + } + } + } + + sorted +} + impl<'g, A: Anchor> ChainQuery for CanonicalViewTask<'g, A> { type Output = CanonicalView; @@ -218,6 +293,7 @@ impl<'g, A: Anchor> ChainQuery for CanonicalViewTask<'g, A> { } } + let view_order = sort_topologically(&view_order, &self.spends); CanonicalView::new(self.tip, view_order, view_txs, self.spends) } } diff --git a/crates/chain/tests/test_tx_graph.rs b/crates/chain/tests/test_tx_graph.rs index 621bd6706..0cc327579 100644 --- a/crates/chain/tests/test_tx_graph.rs +++ b/crates/chain/tests/test_tx_graph.rs @@ -9,6 +9,7 @@ use bdk_chain::{ tx_graph::{ChangeSet, TxGraph}, Anchor, ChainPosition, Merge, }; +use bdk_testenv::local_chain; use bdk_testenv::{block_id, hash, utils::new_tx}; use bitcoin::hex::FromHex; use bitcoin::Witness; @@ -1525,3 +1526,512 @@ fn test_get_first_seen_of_a_tx() { let first_seen = graph.get_tx_node(txid).unwrap().first_seen; assert_eq!(first_seen, Some(seen_at)); } + +/// A helper structure to constructs multiple [`TxGraph`] scenarios, used in +/// `test_list_ordered_canonical_txs`. +struct Scenario<'a> { + /// Name of the test scenario + name: &'a str, + /// Transaction templates + tx_templates: &'a [TxTemplate<'a, BlockId>], + /// Names of txs that must exist in the output of `list_canonical_txs` + exp_chain_txs: Vec<&'a str>, +} + +/// A helper method to assert the expected topological order for a given [`Vec`]. +fn is_ordered_topologically(txs: Vec, tx_graph: TxGraph) -> bool { + let mut seen: HashSet = HashSet::new(); + + for txid in txs { + let tx = tx_graph.get_tx(txid).expect("should exist"); + let inputs: Vec = tx + .input + .iter() + .map(|txin| txin.previous_output.txid) + .collect(); + + // assert that all the txin's have been seen already + for input_txid in inputs { + if !seen.contains(&input_txid) { + return false; + } + } + + // Add current transaction to seen set + seen.insert(txid); + } + + true +} + +#[test] +fn test_list_ordered_canonical_txs() { + // chain + let local_chain: LocalChain = local_chain!( + (0, hash!("A")), + (1, hash!("B")), + (2, hash!("C")), + (3, hash!("D")), + (4, hash!("E")), + (5, hash!("F")), + (6, hash!("G")) + ); + let chain_tip = local_chain.tip().block_id(); + + let scenarios = [ + // a0 b0 c0 + Scenario { + name: "a0, b0 and c0 are roots, does not spend from any other transaction, and are in the best chain", + tx_templates: &[ + TxTemplate { + tx_name: "a0", + inputs: &[], + outputs: &[TxOutTemplate::new(10000, Some(0))], + anchors: &[block_id!(1, "B")], + last_seen: None, + assume_canonical: false, + }, + TxTemplate { + tx_name: "b0", + inputs: &[], + outputs: &[TxOutTemplate::new(5000, Some(0))], + anchors: &[block_id!(1, "B")], + last_seen: None, + assume_canonical: false, + }, + TxTemplate { + tx_name: "c0", + inputs: &[], + outputs: &[TxOutTemplate::new(2500, Some(0))], + anchors: &[block_id!(1, "B")], + last_seen: None, + assume_canonical: false, + }, + ], + exp_chain_txs: Vec::from(["a0", "b0", "c0"]), + }, + // a0 b0 c0 + Scenario { + name: "a0, b0 and c0 are roots, does not spend from any other transaction, and have no anchor or last_seen", + tx_templates: &[ + TxTemplate { + tx_name: "a0", + inputs: &[], + outputs: &[TxOutTemplate::new(10000, Some(0))], + anchors: &[], + last_seen: None, + assume_canonical: false, + }, + TxTemplate { + tx_name: "b0", + inputs: &[], + outputs: &[TxOutTemplate::new(5000, Some(0))], + anchors: &[], + last_seen: None, + assume_canonical: false, + }, + TxTemplate { + tx_name: "c0", + inputs: &[], + outputs: &[TxOutTemplate::new(2500, Some(0))], + anchors: &[], + last_seen: None, + assume_canonical: false, + }, + ], + exp_chain_txs: Vec::from([]), + }, + // a0 b0 c0 + Scenario { + name: "A, B and C are roots, does not spend from any other transaction, and are all have the same `last_seen`", + tx_templates: &[ + TxTemplate { + tx_name: "A", + inputs: &[], + outputs: &[TxOutTemplate::new(10000, Some(0))], + anchors: &[], + last_seen: Some(1000), + assume_canonical: false, + }, + TxTemplate { + tx_name: "B", + inputs: &[], + outputs: &[TxOutTemplate::new(5000, Some(0))], + anchors: &[], + last_seen: Some(1000), + assume_canonical: false, + }, + TxTemplate { + tx_name: "C", + inputs: &[], + outputs: &[TxOutTemplate::new(2500, Some(0))], + anchors: &[], + last_seen: Some(1000), + assume_canonical: false, + }, + ], + exp_chain_txs: Vec::from(["A", "B", "C"]), + }, + // a0 + // \ + // b0 + // \ + // \ c0 + // \ / + // d0 + Scenario { + name: "b0 spends a0, d0 spends both b0 and c0, and are in the best chain", + tx_templates: &[ + TxTemplate { + tx_name: "a0", + inputs: &[], + outputs: &[TxOutTemplate::new(10000, Some(0))], + anchors: &[block_id!(1, "A")], + last_seen: None, + assume_canonical: false, + }, + TxTemplate { + tx_name: "b0", + inputs: &[TxInTemplate::PrevTx("a0", 0)], + outputs: &[TxOutTemplate::new(5000, Some(0))], + anchors: &[block_id!(2, "B")], + last_seen: None, + assume_canonical: false, + }, + TxTemplate { + tx_name: "c0", + inputs: &[], + outputs: &[TxOutTemplate::new(5000, Some(0))], + anchors: &[block_id!(3, "C")], + last_seen: None, + assume_canonical: false, + }, + TxTemplate { + tx_name: "d0", + inputs: &[TxInTemplate::PrevTx("b0", 0), TxInTemplate::PrevTx("c0", 0)], + outputs: &[TxOutTemplate::new(5000, Some(0))], + anchors: &[block_id!(3, "C")], + last_seen: None, + assume_canonical: false, + }, + ], + exp_chain_txs: Vec::from(["a0", "b0", "c0", "d0"]), + }, + // a0 c0 + // \ + // b0 + // \ + // d0 + Scenario { + name: "b0 spends a0, d0 spends b0, and a0, b0 and c0 are in the best chain", + tx_templates: &[ + TxTemplate { + tx_name: "a0", + inputs: &[], + outputs: &[TxOutTemplate::new(10000, Some(0))], + anchors: &[block_id!(1, "A")], + last_seen: None, + assume_canonical: false, + }, + TxTemplate { + tx_name: "b0", + inputs: &[TxInTemplate::PrevTx("a0", 0)], + outputs: &[TxOutTemplate::new(5000, Some(0))], + anchors: &[block_id!(2, "B")], + last_seen: None, + assume_canonical: false, + }, + TxTemplate { + tx_name: "c0", + inputs: &[], + outputs: &[TxOutTemplate::new(5000, Some(0))], + anchors: &[block_id!(3, "C")], + last_seen: None, + assume_canonical: false, + }, + TxTemplate { + tx_name: "d0", + inputs: &[TxInTemplate::PrevTx("b0", 0)], + outputs: &[TxOutTemplate::new(2500, Some(0))], + anchors: &[], + last_seen: Some(1000), + assume_canonical: false, + }, + ], + exp_chain_txs: Vec::from(["a0", "b0", "c0", "d0"]), + }, + // a0 + // \ + // b0 + // \ + // c0 + Scenario { + name: "c0 spend a0, b0 spend a0, and a0, b0 are in the best chain", + tx_templates: &[ + TxTemplate { + tx_name: "a0", + inputs: &[], + outputs: &[TxOutTemplate::new(10000, Some(0))], + anchors: &[block_id!(1, "B")], + last_seen: None, + assume_canonical: false, + }, + TxTemplate { + tx_name: "b0", + inputs: &[TxInTemplate::PrevTx("a0", 0)], + outputs: &[TxOutTemplate::new(5000, Some(0))], + anchors: &[block_id!(1, "B")], + last_seen: None, + assume_canonical: false, + }, + TxTemplate { + tx_name: "c0", + inputs: &[TxInTemplate::PrevTx("b0", 0)], + outputs: &[TxOutTemplate::new(2500, Some(0))], + anchors: &[], + last_seen: Some(1000), + assume_canonical: false, + }, + ], + exp_chain_txs: Vec::from(["a0", "b0", "c0"]), + }, + // a0 + // / \ + // b0 b1 + // / \ \ + // c0 \ c1 + // \ / + // d0 + Scenario { + name: "c0 spend b0, b0 spend a0, d0 spends both b0 and c1, c1 spend b1, b1 spend a0, and are all in the best chain", + tx_templates: &[TxTemplate { + tx_name: "a0", + inputs: &[], + outputs: &[TxOutTemplate::new(10000, Some(0)), TxOutTemplate::new(10000, Some(1))], + anchors: &[block_id!(1, "B")], + last_seen: None, + assume_canonical: false, + }, TxTemplate { + tx_name: "b0", + inputs: &[TxInTemplate::PrevTx("a0", 0)], + outputs: &[TxOutTemplate::new(10000, Some(0)), TxOutTemplate::new(10000, Some(1))], + anchors: &[block_id!(1, "B")], + last_seen: None, + assume_canonical: false, + }, + TxTemplate { + tx_name: "c0", + inputs: &[TxInTemplate::PrevTx("b0", 0)], + outputs: &[TxOutTemplate::new(5000, Some(0))], + anchors: &[block_id!(1, "B")], + last_seen: None, + assume_canonical: false, + }, + TxTemplate { + tx_name: "b1", + inputs: &[TxInTemplate::PrevTx("a0", 1)], + outputs: &[TxOutTemplate::new(10000, Some(0))], + anchors: &[block_id!(1, "B")], + last_seen: None, + assume_canonical: false, + }, + TxTemplate { + tx_name: "c1", + inputs: &[TxInTemplate::PrevTx("b1", 0)], + outputs: &[TxOutTemplate::new(10000, Some(0))], + anchors: &[block_id!(1, "B")], + last_seen: None, + assume_canonical: false, + }, + TxTemplate { + tx_name: "d0", + inputs: &[TxInTemplate::PrevTx("b0", 1), TxInTemplate::PrevTx("c1", 0),], + outputs: &[TxOutTemplate::new(10000, Some(0))], + anchors: &[block_id!(1, "B")], + last_seen: None, + assume_canonical: false, + }], + exp_chain_txs: Vec::from(["a0", "b0", "c0", "b1", "c1", "d0"]), + }, + // a0 d0 e0 + // / / \ + // b0 f0 f1 + // / \ / + // c0 g0 + Scenario { + name: "c0 spend b0, b0 spend a0, d0 does not spend any nor is spent by, g0 spends f0, f1, and f0 and f1 spends e0, and a0, d0, and e0 are in the best chain", + tx_templates: &[TxTemplate { + tx_name: "a0", + inputs: &[], + outputs: &[TxOutTemplate::new(10000, Some(0))], + anchors: &[block_id!(1, "B")], + last_seen: None, + assume_canonical: false, + }, TxTemplate { + tx_name: "b0", + inputs: &[TxInTemplate::PrevTx("a0", 0)], + outputs: &[TxOutTemplate::new(5000, Some(0)), TxOutTemplate::new(10000, Some(1))], + anchors: &[block_id!(2, "C")], + last_seen: None, + assume_canonical: false, + }, + TxTemplate { + tx_name: "c0", + inputs: &[TxInTemplate::PrevTx("b0", 0)], + outputs: &[TxOutTemplate::new(2500, Some(0))], + anchors: &[block_id!(3, "D")], + last_seen: None, + assume_canonical: false, + }, + TxTemplate { + tx_name: "d0", + inputs: &[], + outputs: &[TxOutTemplate::new(10000, Some(0))], + anchors: &[block_id!(3, "D")], + last_seen: None, + assume_canonical: false, + }, + TxTemplate { + tx_name: "e0", + inputs: &[], + outputs: &[TxOutTemplate::new(10000, Some(0)), TxOutTemplate::new(10000, Some(1))], + anchors: &[block_id!(4, "E")], + last_seen: None, + assume_canonical: false, + }, + TxTemplate { + tx_name: "f0", + inputs: &[TxInTemplate::PrevTx("e0", 0)], + outputs: &[TxOutTemplate::new(5000, Some(0))], + anchors: &[block_id!(5, "F")], + last_seen: None, + assume_canonical: false, + }, + TxTemplate { + tx_name: "f1", + inputs: &[TxInTemplate::PrevTx("e0", 1)], + outputs: &[TxOutTemplate::new(5000, Some(0))], + anchors: &[block_id!(5, "F")], + last_seen: None, + assume_canonical: false, + }, + TxTemplate { + tx_name: "g0", + inputs: &[TxInTemplate::PrevTx("f0", 0), TxInTemplate::PrevTx("f1", 0)], + outputs: &[TxOutTemplate::new(1000, Some(0))], + anchors: &[], + last_seen: Some(1000), + assume_canonical: false, + } + ], + exp_chain_txs: Vec::from(["a0", "b0", "c0", "d0", "e0", "f0", "f1", "g0"]), + }, + // a0 + // / \ \ + // e0 / b1 + // / / \ + // f0 / \ + // \/ \ + // b0 \ + // / \ / + // c0 \ c1 + // \ / + // d0 + Scenario { + name: "c0 spend b0, b0 spends both f0 and a0, f0 spend e0, e0 spend a0, d0 spends both b0 and c1, c1 spend b1, b1 spend a0, and are all in the best chain", + tx_templates: &[TxTemplate { + tx_name: "a0", + inputs: &[], + outputs: &[TxOutTemplate::new(10000, Some(0)), TxOutTemplate::new(10000, Some(1)), TxOutTemplate::new(10000, Some(2))], + // outputs: &[TxOutTemplate::new(10000, Some(1)), TxOutTemplate::new(10000, Some(2))], + anchors: &[block_id!(1, "B")], + last_seen: None, + assume_canonical: false, + }, + TxTemplate { + tx_name: "e0", + inputs: &[TxInTemplate::PrevTx("a0", 0)], + outputs: &[TxOutTemplate::new(10000, Some(0))], + anchors: &[block_id!(1, "B")], + last_seen: None, + assume_canonical: false, + }, + TxTemplate { + tx_name: "f0", + inputs: &[TxInTemplate::PrevTx("e0", 0)], + outputs: &[TxOutTemplate::new(10000, Some(0))], + anchors: &[block_id!(1, "B")], + last_seen: None, + assume_canonical: false, + }, + TxTemplate { + tx_name: "b0", + inputs: &[TxInTemplate::PrevTx("f0", 0), TxInTemplate::PrevTx("a0", 1)], + outputs: &[TxOutTemplate::new(10000, Some(0)), TxOutTemplate::new(10000, Some(1))], + anchors: &[block_id!(1, "B")], + last_seen: None, + assume_canonical: false, + }, + TxTemplate { + tx_name: "c0", + inputs: &[TxInTemplate::PrevTx("b0", 0)], + outputs: &[TxOutTemplate::new(5000, Some(0))], + anchors: &[block_id!(1, "B")], + last_seen: None, + assume_canonical: false, + }, + TxTemplate { + tx_name: "b1", + inputs: &[TxInTemplate::PrevTx("a0", 2)], + outputs: &[TxOutTemplate::new(10000, Some(0))], + anchors: &[block_id!(1, "B")], + last_seen: None, + assume_canonical: false, + }, + TxTemplate { + tx_name: "c1", + inputs: &[TxInTemplate::PrevTx("b1", 0)], + outputs: &[TxOutTemplate::new(10000, Some(0))], + anchors: &[block_id!(1, "B")], + last_seen: None, + assume_canonical: false, + }, + TxTemplate { + tx_name: "d0", + inputs: &[TxInTemplate::PrevTx("b0", 1), TxInTemplate::PrevTx("c1", 0), ], + outputs: &[TxOutTemplate::new(10000, Some(0))], + anchors: &[block_id!(1, "B")], + last_seen: None, + assume_canonical: false, + }], + exp_chain_txs: Vec::from(["a0", "e0", "f0", "b0", "c0", "b1", "c1", "d0"]), + }]; + + for scenario in scenarios.iter() { + let env = init_graph(scenario.tx_templates.iter()); + + let canonical_view = + local_chain.canonical_view(&env.tx_graph, chain_tip, env.canonicalization_params); + + let canonical_txs: Vec = canonical_view.txs().map(|tx| tx.txid).collect(); + + let exp_txs = scenario + .exp_chain_txs + .iter() + .map(|txid| *env.txid_to_name.get(txid).expect("txid must exist")) + .collect::>(); + + assert_eq!( + canonical_txs.iter().copied().collect::>(), + exp_txs, + "\n[{}] 'list_canonical_txs' failed", + scenario.name + ); + + assert!( + is_ordered_topologically(canonical_txs, env.tx_graph), + "\n[{}] 'list_canonical_txs' failed to output the txs in topological order", + scenario.name + ); + } +}