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 + ); + } +}