diff --git a/crates/chain/src/canonical.rs b/crates/chain/src/canonical.rs index c2aecb756..d842541ef 100644 --- a/crates/chain/src/canonical.rs +++ b/crates/chain/src/canonical.rs @@ -21,11 +21,15 @@ //! println!("Transaction {}: {:?}", tx.txid, tx.pos); //! } //! ``` +//! +//! For an ordering where every transaction appears after the transactions it spends from, see +//! [`CanonicalView::txs_in_topological_order`]. use crate::collections::HashMap; +use alloc::collections::{BTreeSet, BinaryHeap}; use alloc::sync::Arc; use alloc::vec::Vec; -use core::{fmt, ops::RangeBounds}; +use core::{cmp::Reverse, fmt, ops::RangeBounds}; use bdk_core::BlockId; use bitcoin::{ @@ -167,9 +171,9 @@ impl CanonicalTxOut> { } } -/// Canonical set of transactions from a [`TxGraph`]. +/// Canonical list of transactions from a [`TxGraph`]. /// -/// `Canonical` provides a conflict-resolved list of transactions. It determines +/// `Canonical` provides an ordered, conflict-resolved list of transactions. It determines /// which transactions are canonical (non-conflicted) based on the current chain state and /// provides methods to query transaction data, unspent outputs, and balances. /// @@ -179,14 +183,18 @@ impl CanonicalTxOut> { /// [`CanonicalTxs`]) /// /// The view maintains: -/// - A list of canonical transactions +/// - A list of canonical transactions in canonical order /// - A mapping of outpoints to the transactions that spend them /// - The chain tip used for canonicalization /// +/// Use [`txs`](Self::txs) to iterate in canonical order, or +/// [`txs_in_topological_order`](Self::txs_in_topological_order) for an ordering where every +/// transaction appears after the transactions it spends from. +/// /// [`TxGraph`]: crate::TxGraph #[derive(Debug)] pub struct Canonical { - /// List of canonical transaction IDs. + /// List of canonical transaction IDs in canonical order. pub(crate) order: Vec, /// Map of transaction IDs to their transaction data and position. pub(crate) txs: HashMap, P)>, @@ -269,11 +277,14 @@ impl Canonical { }) } - /// Get an iterator over all canonical transactions in order. + /// Get an iterator over all canonical transactions in canonical order. /// /// Transactions are returned in canonical order, with confirmed transactions ordered by /// block height and position, followed by unconfirmed transactions. /// + /// For an ordering where every transaction appears after the transactions it spends from, see + /// [`txs_in_topological_order`](Self::txs_in_topological_order). + /// /// # Example /// /// ``` @@ -394,6 +405,113 @@ impl Canonical { } impl CanonicalView { + /// Returns the canonical [`Txid`]s in topological order. + /// + /// The topological order guarantees: + /// + /// - every transaction appears after the transactions whose outputs it spends (if `B` spends an + /// output of `A`, then `A` comes before `B`) + /// - transactions not constrained by a spending relationship are ordered by chain position + /// (confirmed transactions by block height, then unconfirmed) and then by txid + /// + /// The result is therefore deterministic. The ordering is computed with Kahn's algorithm. + fn topological_sort(&self) -> Vec { + // Map each canonical parent to the txs that spend its outputs. The spending tx is always + // canonical, so only the parent needs checking. A `BTreeSet` dedups multi-output spends + // from the same parent. + let children: HashMap> = self + .spends + .iter() + .filter(|(outpoint, _)| self.txs.contains_key(&outpoint.txid)) + .fold(HashMap::new(), |mut children, (outpoint, &child)| { + children.entry(outpoint.txid).or_default().insert(child); + children + }); + + // Count how many canonical parents each tx has. Txs missing from the map have none, so they + // are the initial sources. + let mut in_degree: HashMap = + children + .values() + .flatten() + .fold(HashMap::new(), |mut in_degree, &child| { + *in_degree.entry(child).or_insert(0) += 1; + in_degree + }); + + // Ready set ordered by `(chain position, txid)` via a min-heap (`Reverse`): among txs not + // constrained by a spending relationship, confirmed txs are emitted first (by block), then + // unconfirmed, with txid as a tiebreaker. This makes the result deterministic. + let ready_key = |txid: Txid| Reverse((self.txs[&txid].1.clone(), txid)); + let mut ready: BinaryHeap, Txid)>> = self + .txs + .keys() + .copied() + .filter(|txid| !in_degree.contains_key(txid)) + .map(ready_key) + .collect(); + + // Emit the smallest ready tx, then remove its outgoing edges. A child becomes ready once + // its last parent has been emitted. + let mut sorted = Vec::with_capacity(self.order.len()); + while let Some(Reverse((_, txid))) = ready.pop() { + sorted.push(txid); + for &child in children.get(&txid).into_iter().flatten() { + if let Some(degree) = in_degree.get_mut(&child) { + *degree -= 1; + if *degree == 0 { + ready.push(ready_key(child)); + } + } + } + } + + // The tx graph is a DAG, so every tx must be placed. A shorter result indicates a cycle. + debug_assert_eq!( + sorted.len(), + self.order.len(), + "topological sort dropped transactions; dependency cycle?" + ); + + sorted + } + + /// Get an iterator over all canonical transactions in topological order. + /// + /// Unlike [`txs`](Self::txs), which yields transactions in canonical order, this method + /// guarantees that for every spending relationship `A -> B` (where `B` spends an output of + /// `A`), `A` appears before `B`. This is useful when transactions must be replayed or + /// rebroadcast, since a parent must be processed before its children. + /// + /// Transactions not constrained by a spending relationship are ordered by chain position + /// (confirmed transactions by block height, then unconfirmed) and then by txid, so the result + /// is deterministic. + /// + /// # Example + /// + /// ``` + /// # use bdk_chain::{CanonicalParams, 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 view = chain.canonical_view(&tx_graph, chain_tip, CanonicalParams::default()); + /// // Iterate over canonical transactions, parents before children + /// for tx in view.txs_in_topological_order() { + /// println!("TX {}: {:?}", tx.txid, tx.pos); + /// } + /// ``` + pub fn txs_in_topological_order( + &self, + ) -> impl ExactSizeIterator>> + DoubleEndedIterator + '_ + { + self.topological_sort().into_iter().map(|txid| { + let (tx, pos) = self.txs[&txid].clone(); + CanonicalTx { pos, txid, tx } + }) + } + /// Calculate the total balance of the given outpoints. /// /// This method computes a detailed balance breakdown for a set of outpoints, categorizing diff --git a/crates/chain/tests/test_tx_graph.rs b/crates/chain/tests/test_tx_graph.rs index 621bd6706..ff9fc9f3d 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,539 @@ 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 canonical: HashSet = txs.iter().copied().collect(); + let mut seen: HashSet = HashSet::new(); + + for txid in txs { + let tx = tx_graph.get_tx(txid).expect("should exist"); + + // Every canonical parent must already have been seen. Inputs spending non-canonical txs + // (e.g. external or bogus) are outside this set and irrelevant to the ordering. + for txin in &tx.input { + let parent = txin.previous_output.txid; + if canonical.contains(&parent) && !seen.contains(&parent) { + 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 + // / \ + // a0:0 a0:1 + // \ / + // b0 + Scenario { + name: "b0 spends a0:0 and a0:1, and both do not have anchors or last_seen", + tx_templates: &[ + TxTemplate { + tx_name: "a0", + inputs: &[TxInTemplate::Bogus], + outputs: &[ + TxOutTemplate::new(10_000, None), + TxOutTemplate::new(10_000, None), + ], + anchors: &[], + last_seen: None, + assume_canonical: false, + }, + TxTemplate { + tx_name: "b0", + inputs: &[TxInTemplate::PrevTx("a0", 0), TxInTemplate::PrevTx("a0", 1)], + outputs: &[TxOutTemplate::new(18_000, None)], + anchors: &[], + last_seen: None, + assume_canonical: true, + }, + ], + exp_chain_txs: Vec::from(["a0", "b0"]), + }, + // 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 topological_txs: Vec = canonical_view + .txs_in_topological_order() + .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 + ); + + // `txs_in_topological_order` must contain the same set as `txs`, only reordered. + assert_eq!( + topological_txs.iter().copied().collect::>(), + exp_txs, + "\n[{}] 'txs_in_topological_order' returned a different set than 'txs'", + scenario.name + ); + + assert!( + is_ordered_topologically(topological_txs, env.tx_graph), + "\n[{}] 'txs_in_topological_order' failed to output the txs in topological order", + scenario.name + ); + } +} + +/// `txs_in_topological_order` must be deterministic and order same-level txs by confirmation block. +/// +/// `b0`, `c0` and `d0` each spend a different output of the same parent `a0`, so nothing orders +/// them topologically relative to one another. They are confirmed in different blocks, so they must +/// come out by ascending confirmation block (`c0` @3, `d0` @4, `b0` @5), and the same way on every +/// run. With a non-deterministic implementation (children collected in `HashMap` iteration order) +/// this would vary across runs. +#[test] +fn test_topological_order_deterministic() { + let local_chain: LocalChain = local_chain!( + (0, hash!("A")), + (1, hash!("B")), + (2, hash!("C")), + (3, hash!("D")), + (4, hash!("E")), + (5, hash!("F")) + ); + let chain_tip = local_chain.tip().block_id(); + + let tx_templates = [ + TxTemplate { + tx_name: "a0", + inputs: &[], + outputs: &[ + TxOutTemplate::new(10_000, None), + TxOutTemplate::new(10_000, None), + TxOutTemplate::new(10_000, None), + ], + anchors: &[block_id!(1, "B")], + last_seen: None, + assume_canonical: false, + }, + TxTemplate { + tx_name: "b0", + inputs: &[TxInTemplate::PrevTx("a0", 0)], + outputs: &[TxOutTemplate::new(9_000, None)], + anchors: &[block_id!(5, "F")], + last_seen: None, + assume_canonical: false, + }, + TxTemplate { + tx_name: "c0", + inputs: &[TxInTemplate::PrevTx("a0", 1)], + outputs: &[TxOutTemplate::new(9_000, None)], + anchors: &[block_id!(3, "D")], + last_seen: None, + assume_canonical: false, + }, + TxTemplate { + tx_name: "d0", + inputs: &[TxInTemplate::PrevTx("a0", 2)], + outputs: &[TxOutTemplate::new(9_000, None)], + anchors: &[block_id!(4, "E")], + last_seen: None, + assume_canonical: false, + }, + ]; + + // Each `init_graph` builds fresh `txs`/`spends` HashMaps (random seed), so a non-deterministic + // implementation would yield different sibling orders across iterations. + for _ in 0..20 { + let env = init_graph(tx_templates.iter()); + let name_of: HashMap = env + .txid_to_name + .iter() + .map(|(&name, &txid)| (txid, name)) + .collect(); + let order: Vec<&str> = local_chain + .canonical_view(&env.tx_graph, chain_tip, env.canonicalization_params) + .txs_in_topological_order() + .map(|tx| name_of[&tx.txid]) + .collect(); + assert_eq!( + order, + vec!["a0", "c0", "d0", "b0"], + "topological order must be deterministic and confirmation-ordered" + ); + } +}