From edf197650f7c2e469c65b3c043d463040d1d9977 Mon Sep 17 00:00:00 2001 From: Leonardo Lima Date: Sat, 20 Jun 2026 19:37:50 -0300 Subject: [PATCH 1/3] feat(chain): add opt-in topological ordering to `CanonicalView` `CanonicalView::txs()` returns canonical transactions in canonical order (confirmed by height, then unconfirmed). Add `CanonicalView::txs_in_topological_order()`, which returns them in topological order (every transaction after the transactions whose outputs it spends), computed on demand with Kahn's algorithm from the canonical `order` and `spends`. Co-Authored-By: Claude Opus 4.8 --- crates/chain/src/canonical.rs | 120 ++++++- crates/chain/tests/test_tx_graph.rs | 522 ++++++++++++++++++++++++++++ 2 files changed, 638 insertions(+), 4 deletions(-) diff --git a/crates/chain/src/canonical.rs b/crates/chain/src/canonical.rs index c2aecb756..025f429c6 100644 --- a/crates/chain/src/canonical.rs +++ b/crates/chain/src/canonical.rs @@ -21,8 +21,12 @@ //! 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::VecDeque; use alloc::sync::Arc; use alloc::vec::Vec; use core::{fmt, ops::RangeBounds}; @@ -169,7 +173,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 +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,107 @@ 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`) + /// - sources (transactions with no canonical parent) keep their relative [canonical + /// order](Self::txs) + /// + /// 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. + 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().push(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 + }); + + // Begin with the sources, in canonical order. + let mut sources: VecDeque = self + .order + .iter() + .copied() + .filter(|txid| !in_degree.contains_key(txid)) + .collect(); + + // Emit each source and remove its outgoing edges. A child becomes a source once its last + // parent has been emitted. + let mut sorted = Vec::with_capacity(self.order.len()); + while let Some(txid) = sources.pop_front() { + 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 { + sources.push_back(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. + /// + /// Sources (transactions with no canonical parent) keep their relative + /// [canonical order](Self::txs). + /// + /// # 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..15da9e316 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,524 @@ 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 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 + ); + } +} From 5b69dbde77a6bc2a3166763a02d9b9444fd07351 Mon Sep 17 00:00:00 2001 From: Leonardo Lima Date: Mon, 22 Jun 2026 20:28:49 -0300 Subject: [PATCH 2/3] refactor(chain): order `CanonicalView` topological output deterministically Order the topological sort's ready set by `(ChainPosition, Txid)` with a min-heap instead of a FIFO queue: - transactions not constrained by a spending relationship now come out confirmed-first by block height, then unconfirmed, with txid as a tiebreaker. The result is deterministic and groups same-level txs by confirmation block (useful for wallet display). - collect children into a `BTreeSet`, deduping multi-output spends from the same parent. Seed the sources from `txs` rather than `order`, so the topological ordering no longer depends on the `order` field. Co-Authored-By: Claude Opus 4.8 --- crates/chain/src/canonical.rs | 42 ++++++++------- crates/chain/tests/test_tx_graph.rs | 80 +++++++++++++++++++++++++++++ 2 files changed, 104 insertions(+), 18 deletions(-) diff --git a/crates/chain/src/canonical.rs b/crates/chain/src/canonical.rs index 025f429c6..9d7ce5c6f 100644 --- a/crates/chain/src/canonical.rs +++ b/crates/chain/src/canonical.rs @@ -26,10 +26,10 @@ //! [`CanonicalView::txs_in_topological_order`]. use crate::collections::HashMap; -use alloc::collections::VecDeque; +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::{ @@ -411,19 +411,20 @@ impl CanonicalView { /// /// - every transaction appears after the transactions whose outputs it spends (if `B` spends an /// output of `A`, then `A` comes before `B`) - /// - sources (transactions with no canonical parent) keep their relative [canonical - /// order](Self::txs) + /// - transactions not constrained by a spending relationship are ordered by chain position + /// (confirmed transactions by block height, then unconfirmed) and then by txid /// - /// The ordering is computed with Kahn's algorithm. + /// 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. - let children: HashMap> = self + // 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().push(child); + children.entry(outpoint.txid).or_default().insert(child); children }); @@ -438,24 +439,28 @@ impl CanonicalView { in_degree }); - // Begin with the sources, in canonical order. - let mut sources: VecDeque = self - .order - .iter() + // 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 each source and remove its outgoing edges. A child becomes a source once its last - // parent has been emitted. + // 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(txid) = sources.pop_front() { + 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 { - sources.push_back(child); + ready.push(ready_key(child)); } } } @@ -478,8 +483,9 @@ impl CanonicalView { /// `A`), `A` appears before `B`. This is useful when transactions must be replayed or /// rebroadcast, since a parent must be processed before its children. /// - /// Sources (transactions with no canonical parent) keep their relative - /// [canonical order](Self::txs). + /// 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 /// diff --git a/crates/chain/tests/test_tx_graph.rs b/crates/chain/tests/test_tx_graph.rs index 15da9e316..256aad5dc 100644 --- a/crates/chain/tests/test_tx_graph.rs +++ b/crates/chain/tests/test_tx_graph.rs @@ -2047,3 +2047,83 @@ fn test_list_ordered_canonical_txs() { ); } } + +/// `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" + ); + } +} From 0fe25402587e9913065270fefbbf28c3b71c3fc9 Mon Sep 17 00:00:00 2001 From: Leonardo Lima Date: Sun, 28 Jun 2026 12:39:10 -0300 Subject: [PATCH 3/3] wip(test(chain)): add double-spend topological case, fix ordering helper - fix `is_ordered_topologically` to ignore non-canonical (external) inputs (the actual issue behind nymius's double-spend "bug") - add the double-spend-from-same-parent test case, replacing the relation-less scenarios - doc: "set" -> "list" in the `Canonical` struct doc Co-Authored-By: Claude Opus 4.8 --- crates/chain/src/canonical.rs | 4 +- crates/chain/tests/test_tx_graph.rs | 107 ++++++---------------------- 2 files changed, 23 insertions(+), 88 deletions(-) diff --git a/crates/chain/src/canonical.rs b/crates/chain/src/canonical.rs index 9d7ce5c6f..d842541ef 100644 --- a/crates/chain/src/canonical.rs +++ b/crates/chain/src/canonical.rs @@ -171,9 +171,9 @@ impl CanonicalTxOut> { } } -/// Canonical set of transactions from a [`TxGraph`]. +/// Canonical list of transactions from a [`TxGraph`]. /// -/// `Canonical` provides an ordered, conflict-resolved set 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. /// diff --git a/crates/chain/tests/test_tx_graph.rs b/crates/chain/tests/test_tx_graph.rs index 256aad5dc..ff9fc9f3d 100644 --- a/crates/chain/tests/test_tx_graph.rs +++ b/crates/chain/tests/test_tx_graph.rs @@ -1540,19 +1540,17 @@ struct Scenario<'a> { /// 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"); - 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) { + // 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; } } @@ -1579,98 +1577,35 @@ fn test_list_ordered_canonical_txs() { let chain_tip = local_chain.tip().block_id(); let scenarios = [ - // a0 b0 c0 + // a0 + // / \ + // a0:0 a0:1 + // \ / + // b0 Scenario { - name: "a0, b0 and c0 are roots, does not spend from any other transaction, and are in the best chain", + name: "b0 spends a0:0 and a0:1, and both do not have anchors or last_seen", 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))], + 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: &[], - outputs: &[TxOutTemplate::new(5000, Some(0))], + inputs: &[TxInTemplate::PrevTx("a0", 0), TxInTemplate::PrevTx("a0", 1)], + outputs: &[TxOutTemplate::new(18_000, None)], 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, + assume_canonical: true, }, ], - exp_chain_txs: Vec::from(["A", "B", "C"]), + exp_chain_txs: Vec::from(["a0", "b0"]), }, // a0 // \