diff --git a/crates/chain/src/tx_graph.rs b/crates/chain/src/tx_graph.rs index cfd2de9d..edc1e496 100644 --- a/crates/chain/src/tx_graph.rs +++ b/crates/chain/src/tx_graph.rs @@ -54,6 +54,7 @@ use crate::{ collections::*, keychain::Balance, local_chain::LocalChain, Anchor, Append, BlockId, ChainOracle, ChainPosition, FullTxOut, }; +use alloc::collections::vec_deque::VecDeque; use alloc::vec::Vec; use bitcoin::{OutPoint, Script, Transaction, TxOut, Txid}; use core::{ @@ -319,15 +320,39 @@ impl TxGraph { .map(|(outpoint, spends)| (outpoint.vout, spends)) } + /// Creates an iterator that filters and maps ancestor transactions. + /// + /// The iterator starts with the ancestors of the supplied `tx` (ancestor transactions of `tx` + /// are transactions spent by `tx`). The supplied transaction is excluded from the iterator. + /// + /// The supplied closure takes in two inputs `(depth, ancestor_tx)`: + /// + /// * `depth` is the distance between the starting `Transaction` and the `ancestor_tx`. I.e., if + /// the `Transaction` is spending an output of the `ancestor_tx` then `depth` will be 1. + /// * `ancestor_tx` is the `Transaction`'s ancestor which we are considering to walk. + /// + /// The supplied closure returns an `Option`, allowing the caller to map each `Transaction` + /// it visits and decide whether to visit ancestors. + pub fn walk_ancestors<'g, F, O>( + &'g self, + tx: &'g Transaction, + walk_map: F, + ) -> TxAncestors<'g, A, F> + where + F: FnMut(usize, &'g Transaction) -> Option + 'g, + { + TxAncestors::new_exclude_root(self, tx, walk_map) + } + /// Creates an iterator that filters and maps descendants from the starting `txid`. /// /// The supplied closure takes in two inputs `(depth, descendant_txid)`: /// /// * `depth` is the distance between the starting `txid` and the `descendant_txid`. I.e., if the - /// descendant is spending an output of the starting `txid`; the `depth` will be 1. + /// descendant is spending an output of the starting `txid` then `depth` will be 1. /// * `descendant_txid` is the descendant's txid which we are considering to walk. /// - /// The supplied closure returns an `Option`, allowing the caller to map each node it vists + /// The supplied closure returns an `Option`, allowing the caller to map each node it visits /// and decide whether to visit descendants. pub fn walk_descendants<'g, F, O>(&'g self, txid: Txid, walk_map: F) -> TxDescendants where @@ -356,8 +381,9 @@ impl TxGraph { /// transaction's inputs (spends). The conflicting txids are returned with the given /// transaction's vin (in which it conflicts). /// - /// Note that this only returns directly conflicting txids and does not include descendants of - /// those txids (which are technically also conflicting). + /// Note that this only returns directly conflicting txids and won't include: + /// - descendants of conflicting transactions (which are technically also conflicting) + /// - transactions conflicting with the given transaction's ancestors pub fn direct_conflicts_of_tx<'g>( &'g self, tx: &'g Transaction, @@ -671,8 +697,9 @@ impl TxGraph { } } - // The tx is not anchored to a block which is in the best chain, let's check whether we can - // ignore it by checking conflicts! + // The tx is not anchored to a block which is in the best chain, which means that it + // might be in mempool, or it might have been dropped already. + // Let's check conflicts to find out! let tx = match tx_node { TxNodeInternal::Whole(tx) => tx, TxNodeInternal::Partial(_) => { @@ -681,18 +708,71 @@ impl TxGraph { } }; - // If a conflicting tx is in the best chain, or has `last_seen` higher than this tx, then - // this tx cannot exist in the best chain - for conflicting_tx in self.walk_conflicts(tx, |_, txid| self.get_tx_node(txid)) { - for block in conflicting_tx.anchors.iter().map(A::anchor_block) { - if chain.is_block_in_chain(block, chain_tip)? == Some(true) { - // conflicting tx is in best chain, so the current tx cannot be in best chain! + // We want to retrieve all the transactions that conflict with us, plus all the + // transactions that conflict with our unconfirmed ancestors, since they conflict with us + // as well. + // We only traverse unconfirmed ancestors since conflicts of confirmed transactions + // cannot be in the best chain. + + // First of all, we retrieve all our ancestors. Since we're using `new_include_root`, the + // resulting array will also include `tx` + let unconfirmed_ancestor_txs = + TxAncestors::new_include_root(self, tx, |_, ancestor_tx: &Transaction| { + let tx_node = self.get_tx_node(ancestor_tx.txid())?; + // We're filtering the ancestors to keep only the unconfirmed ones (= no anchors in + // the best chain) + for block in tx_node.anchors { + match chain.is_block_in_chain(block.anchor_block(), chain_tip) { + Ok(Some(true)) => return None, + Err(e) => return Some(Err(e)), + _ => continue, + } + } + Some(Ok(tx_node)) + }) + .collect::, C::Error>>()?; + + // We determine our tx's last seen, which is the max between our last seen, + // and our unconf descendants' last seen. + let unconfirmed_descendants_txs = + TxDescendants::new_include_root(self, tx.txid(), |_, descendant_txid: Txid| { + let tx_node = self.get_tx_node(descendant_txid)?; + // We're filtering the ancestors to keep only the unconfirmed ones (= no anchors in + // the best chain) + for block in tx_node.anchors { + match chain.is_block_in_chain(block.anchor_block(), chain_tip) { + Ok(Some(true)) => return None, + Err(e) => return Some(Err(e)), + _ => continue, + } + } + Some(Ok(tx_node)) + }) + .collect::, C::Error>>()?; + + let tx_last_seen = unconfirmed_descendants_txs + .iter() + .max_by_key(|tx| tx.last_seen_unconfirmed) + .map(|tx| tx.last_seen_unconfirmed) + .expect("descendants always includes at least one transaction (the root tx"); + + // Now we traverse our ancestors and consider all their conflicts + for tx_node in unconfirmed_ancestor_txs { + // We retrieve all the transactions conflicting with this specific ancestor + let conflicting_txs = self.walk_conflicts(tx_node.tx, |_, txid| self.get_tx_node(txid)); + + // If a conflicting tx is in the best chain, or has `last_seen` higher than this ancestor, then + // this tx cannot exist in the best chain + for conflicting_tx in conflicting_txs { + for block in conflicting_tx.anchors { + if chain.is_block_in_chain(block.anchor_block(), chain_tip)? == Some(true) { + return Ok(None); + } + } + if conflicting_tx.last_seen_unconfirmed > tx_last_seen { return Ok(None); } } - if conflicting_tx.last_seen_unconfirmed > *last_seen { - return Ok(None); - } } Ok(Some(ChainPosition::Unconfirmed(*last_seen))) @@ -1137,6 +1217,126 @@ impl AsRef> for TxGraph { } } +/// An iterator that traverses ancestors of a given root transaction. +/// +/// The iterator excludes partial transactions. +/// +/// This `struct` is created by the [`walk_ancestors`] method of [`TxGraph`]. +/// +/// [`walk_ancestors`]: TxGraph::walk_ancestors +pub struct TxAncestors<'g, A, F> { + graph: &'g TxGraph, + visited: HashSet, + queue: VecDeque<(usize, &'g Transaction)>, + filter_map: F, +} + +impl<'g, A, F> TxAncestors<'g, A, F> { + /// Creates a `TxAncestors` that includes the starting `Transaction` when iterating. + pub(crate) fn new_include_root( + graph: &'g TxGraph, + tx: &'g Transaction, + filter_map: F, + ) -> Self { + Self { + graph, + visited: Default::default(), + queue: [(0, tx)].into(), + filter_map, + } + } + + /// Creates a `TxAncestors` that excludes the starting `Transaction` when iterating. + pub(crate) fn new_exclude_root( + graph: &'g TxGraph, + tx: &'g Transaction, + filter_map: F, + ) -> Self { + let mut ancestors = Self { + graph, + visited: Default::default(), + queue: Default::default(), + filter_map, + }; + ancestors.populate_queue(1, tx); + ancestors + } + + /// Creates a `TxAncestors` from multiple starting `Transaction`s that includes the starting + /// `Transaction`s when iterating. + #[allow(unused)] + pub(crate) fn from_multiple_include_root( + graph: &'g TxGraph, + txs: I, + filter_map: F, + ) -> Self + where + I: IntoIterator, + { + Self { + graph, + visited: Default::default(), + queue: txs.into_iter().map(|tx| (0, tx)).collect(), + filter_map, + } + } + + /// Creates a `TxAncestors` from multiple starting `Transaction`s that excludes the starting + /// `Transaction`s when iterating. + #[allow(unused)] + pub(crate) fn from_multiple_exclude_root( + graph: &'g TxGraph, + txs: I, + filter_map: F, + ) -> Self + where + I: IntoIterator, + { + let mut ancestors = Self { + graph, + visited: Default::default(), + queue: Default::default(), + filter_map, + }; + for tx in txs { + ancestors.populate_queue(1, tx); + } + ancestors + } + + fn populate_queue(&mut self, depth: usize, tx: &'g Transaction) { + let ancestors = tx + .input + .iter() + .map(|txin| txin.previous_output.txid) + .filter(|&prev_txid| self.visited.insert(prev_txid)) + .filter_map(|prev_txid| self.graph.get_tx(prev_txid)) + .map(|tx| (depth, tx)); + self.queue.extend(ancestors); + } +} + +impl<'g, A, F, O> Iterator for TxAncestors<'g, A, F> +where + F: FnMut(usize, &'g Transaction) -> Option, +{ + type Item = O; + + fn next(&mut self) -> Option { + loop { + // we have exhausted all paths when queue is empty + let (ancestor_depth, tx) = self.queue.pop_front()?; + // ignore paths when user filters them out + let item = match (self.filter_map)(ancestor_depth, tx) { + Some(item) => item, + None => continue, + }; + self.populate_queue(ancestor_depth + 1, tx); + return Some(item); + } + } +} + /// An iterator that traverses transaction descendants. /// /// This `struct` is created by the [`walk_descendants`] method of [`TxGraph`]. @@ -1145,7 +1345,7 @@ impl AsRef> for TxGraph { pub struct TxDescendants<'g, A, F> { graph: &'g TxGraph, visited: HashSet, - stack: Vec<(usize, Txid)>, + queue: VecDeque<(usize, Txid)>, filter_map: F, } @@ -1156,7 +1356,7 @@ impl<'g, A, F> TxDescendants<'g, A, F> { Self { graph, visited: Default::default(), - stack: [(0, txid)].into(), + queue: [(0, txid)].into(), filter_map, } } @@ -1166,14 +1366,14 @@ impl<'g, A, F> TxDescendants<'g, A, F> { let mut descendants = Self { graph, visited: Default::default(), - stack: Default::default(), + queue: Default::default(), filter_map, }; - descendants.populate_stack(1, txid); + descendants.populate_queue(1, txid); descendants } - /// Creates a `TxDescendants` from multiple starting transactions that include the starting + /// Creates a `TxDescendants` from multiple starting transactions that includes the starting /// `txid`s when iterating. pub(crate) fn from_multiple_include_root( graph: &'g TxGraph, @@ -1186,7 +1386,7 @@ impl<'g, A, F> TxDescendants<'g, A, F> { Self { graph, visited: Default::default(), - stack: txids.into_iter().map(|txid| (0, txid)).collect(), + queue: txids.into_iter().map(|txid| (0, txid)).collect(), filter_map, } } @@ -1205,25 +1405,25 @@ impl<'g, A, F> TxDescendants<'g, A, F> { let mut descendants = Self { graph, visited: Default::default(), - stack: Default::default(), + queue: Default::default(), filter_map, }; for txid in txids { - descendants.populate_stack(1, txid); + descendants.populate_queue(1, txid); } descendants } } impl<'g, A, F> TxDescendants<'g, A, F> { - fn populate_stack(&mut self, depth: usize, txid: Txid) { + fn populate_queue(&mut self, depth: usize, txid: Txid) { let spend_paths = self .graph .spends .range(tx_outpoint_range(txid)) .flat_map(|(_, spends)| spends) .map(|&txid| (depth, txid)); - self.stack.extend(spend_paths); + self.queue.extend(spend_paths); } } @@ -1235,8 +1435,8 @@ where fn next(&mut self) -> Option { let (op_spends, txid, item) = loop { - // we have exhausted all paths when stack is empty - let (op_spends, txid) = self.stack.pop()?; + // we have exhausted all paths when queue is empty + let (op_spends, txid) = self.queue.pop_front()?; // we do not want to visit the same transaction twice if self.visited.insert(txid) { // ignore paths when user filters them out @@ -1246,7 +1446,7 @@ where } }; - self.populate_stack(op_spends + 1, txid); + self.populate_queue(op_spends + 1, txid); Some(item) } } diff --git a/crates/chain/tests/common/mod.rs b/crates/chain/tests/common/mod.rs index 2573fd96..d3db8191 100644 --- a/crates/chain/tests/common/mod.rs +++ b/crates/chain/tests/common/mod.rs @@ -1,3 +1,16 @@ +mod tx_template; +pub use tx_template::*; + +#[allow(unused_macros)] +macro_rules! block_id { + ($height:expr, $hash:literal) => {{ + bdk_chain::BlockId { + height: $height, + hash: bitcoin::hashes::Hash::hash($hash.as_bytes()), + } + }}; +} + #[allow(unused_macros)] macro_rules! h { ($index:literal) => {{ diff --git a/crates/chain/tests/common/tx_template.rs b/crates/chain/tests/common/tx_template.rs new file mode 100644 index 00000000..605a0ba7 --- /dev/null +++ b/crates/chain/tests/common/tx_template.rs @@ -0,0 +1,136 @@ +use rand::distributions::{Alphanumeric, DistString}; +use std::collections::HashMap; + +use bdk_chain::{tx_graph::TxGraph, BlockId, SpkTxOutIndex}; +use bitcoin::{ + locktime::absolute::LockTime, secp256k1::Secp256k1, OutPoint, ScriptBuf, Sequence, Transaction, + TxIn, TxOut, Txid, Witness, +}; +use miniscript::Descriptor; + +/// Template for creating a transaction in `TxGraph`. +/// +/// The incentive for transaction templates is to create a transaction history in a simple manner to +/// avoid having to explicitly hash previous transactions to form previous outpoints of later +/// transactions. +#[derive(Clone, Copy, Default)] +pub struct TxTemplate<'a, A> { + /// Uniquely identifies the transaction, before it can have a txid. + pub tx_name: &'a str, + pub inputs: &'a [TxInTemplate<'a>], + pub outputs: &'a [TxOutTemplate], + pub anchors: &'a [A], + pub last_seen: Option, +} + +#[allow(dead_code)] +pub enum TxInTemplate<'a> { + /// This will give a random txid and vout. + Bogus, + + /// This is used for coinbase transactions because they do not have previous outputs. + Coinbase, + + /// Contains the `tx_name` and `vout` that we are spending. The rule is that we must only spend + /// from tx of a previous `TxTemplate`. + PrevTx(&'a str, usize), +} + +pub struct TxOutTemplate { + pub value: u64, + pub spk_index: Option, // some = get spk from SpkTxOutIndex, none = random spk +} + +#[allow(unused)] +impl TxOutTemplate { + pub fn new(value: u64, spk_index: Option) -> Self { + TxOutTemplate { value, spk_index } + } +} + +#[allow(dead_code)] +pub fn init_graph<'a>( + tx_templates: impl IntoIterator>, +) -> (TxGraph, SpkTxOutIndex, HashMap<&'a str, Txid>) { + let (descriptor, _) = Descriptor::parse_descriptor(&Secp256k1::signing_only(), "tr(tprv8ZgxMBicQKsPd3krDUsBAmtnRsK3rb8u5yi1zhQgMhF1tR8MW7xfE4rnrbbsrbPR52e7rKapu6ztw1jXveJSCGHEriUGZV7mCe88duLp5pj/86'/1'/0'/0/*)").unwrap(); + let mut graph = TxGraph::::default(); + let mut spk_index = SpkTxOutIndex::default(); + (0..10).for_each(|index| { + spk_index.insert_spk( + index, + descriptor + .at_derivation_index(index) + .unwrap() + .script_pubkey(), + ); + }); + let mut tx_ids = HashMap::<&'a str, Txid>::new(); + + for (bogus_txin_vout, tx_tmp) in tx_templates.into_iter().enumerate() { + let tx = Transaction { + version: 0, + lock_time: LockTime::ZERO, + input: tx_tmp + .inputs + .iter() + .map(|input| match input { + TxInTemplate::Bogus => TxIn { + previous_output: OutPoint::new( + bitcoin::hashes::Hash::hash( + Alphanumeric + .sample_string(&mut rand::thread_rng(), 20) + .as_bytes(), + ), + bogus_txin_vout as u32, + ), + script_sig: ScriptBuf::new(), + sequence: Sequence::default(), + witness: Witness::new(), + }, + TxInTemplate::Coinbase => TxIn { + previous_output: OutPoint::null(), + script_sig: ScriptBuf::new(), + sequence: Sequence::MAX, + witness: Witness::new(), + }, + TxInTemplate::PrevTx(prev_name, prev_vout) => { + let prev_txid = tx_ids.get(prev_name).expect( + "txin template must spend from tx of template that comes before", + ); + TxIn { + previous_output: OutPoint::new(*prev_txid, *prev_vout as _), + script_sig: ScriptBuf::new(), + sequence: Sequence::default(), + witness: Witness::new(), + } + } + }) + .collect(), + output: tx_tmp + .outputs + .iter() + .map(|output| match &output.spk_index { + None => TxOut { + value: output.value, + script_pubkey: ScriptBuf::new(), + }, + Some(index) => TxOut { + value: output.value, + script_pubkey: spk_index.spk_at_index(index).unwrap().to_owned(), + }, + }) + .collect(), + }; + + tx_ids.insert(tx_tmp.tx_name, tx.txid()); + spk_index.scan(&tx); + let _ = graph.insert_tx(tx.clone()); + for anchor in tx_tmp.anchors.iter() { + let _ = graph.insert_anchor(tx.txid(), *anchor); + } + if let Some(seen_at) = tx_tmp.last_seen { + let _ = graph.insert_seen_at(tx.txid(), seen_at); + } + } + (graph, spk_index, tx_ids) +} diff --git a/crates/chain/tests/test_tx_graph.rs b/crates/chain/tests/test_tx_graph.rs index 4c68f510..a0efd100 100644 --- a/crates/chain/tests/test_tx_graph.rs +++ b/crates/chain/tests/test_tx_graph.rs @@ -5,7 +5,7 @@ use bdk_chain::{ collections::*, local_chain::LocalChain, tx_graph::{ChangeSet, TxGraph}, - Anchor, Append, BlockId, ChainPosition, ConfirmationHeightAnchor, + Anchor, Append, BlockId, ChainOracle, ChainPosition, ConfirmationHeightAnchor, }; use bitcoin::{ absolute, hashes::Hash, BlockHash, OutPoint, ScriptBuf, Transaction, TxIn, TxOut, Txid, @@ -496,6 +496,208 @@ fn test_calculate_fee_on_coinbase() { assert_eq!(graph.calculate_fee(&tx), Ok(0)); } +// `test_walk_ancestors` uses the following transaction structure: +// +// a0 +// / \ +// b0 b1 b2 +// / \ \ / +// c0 c1 c2 c3 +// / \ / +// d0 d1 +// \ +// e0 +// +// where b0 and b1 spend a0, c0 and c1 spend b0, d0 spends c1, etc. +#[test] +fn test_walk_ancestors() { + let local_chain: LocalChain = (0..=20) + .map(|ht| (ht, BlockHash::hash(format!("Block Hash {}", ht).as_bytes()))) + .collect::>() + .into(); + let tip = local_chain.tip().expect("must have tip"); + + let tx_a0 = Transaction { + input: vec![TxIn { + previous_output: OutPoint::new(h!("op0"), 0), + ..TxIn::default() + }], + output: vec![TxOut::default(), TxOut::default()], + ..common::new_tx(0) + }; + + // tx_b0 spends tx_a0 + let tx_b0 = Transaction { + input: vec![TxIn { + previous_output: OutPoint::new(tx_a0.txid(), 0), + ..TxIn::default() + }], + output: vec![TxOut::default(), TxOut::default()], + ..common::new_tx(0) + }; + + // tx_b1 spends tx_a0 + let tx_b1 = Transaction { + input: vec![TxIn { + previous_output: OutPoint::new(tx_a0.txid(), 1), + ..TxIn::default() + }], + output: vec![TxOut::default()], + ..common::new_tx(0) + }; + + let tx_b2 = Transaction { + input: vec![TxIn { + previous_output: OutPoint::new(h!("op1"), 0), + ..TxIn::default() + }], + output: vec![TxOut::default()], + ..common::new_tx(0) + }; + + // tx_c0 spends tx_b0 + let tx_c0 = Transaction { + input: vec![TxIn { + previous_output: OutPoint::new(tx_b0.txid(), 0), + ..TxIn::default() + }], + output: vec![TxOut::default()], + ..common::new_tx(0) + }; + + // tx_c1 spends tx_b0 + let tx_c1 = Transaction { + input: vec![TxIn { + previous_output: OutPoint::new(tx_b0.txid(), 1), + ..TxIn::default() + }], + output: vec![TxOut::default()], + ..common::new_tx(0) + }; + + // tx_c2 spends tx_b1 and tx_b2 + let tx_c2 = Transaction { + input: vec![ + TxIn { + previous_output: OutPoint::new(tx_b1.txid(), 0), + ..TxIn::default() + }, + TxIn { + previous_output: OutPoint::new(tx_b2.txid(), 0), + ..TxIn::default() + }, + ], + output: vec![TxOut::default()], + ..common::new_tx(0) + }; + + let tx_c3 = Transaction { + input: vec![TxIn { + previous_output: OutPoint::new(h!("op2"), 0), + ..TxIn::default() + }], + output: vec![TxOut::default()], + ..common::new_tx(0) + }; + + // tx_d0 spends tx_c1 + let tx_d0 = Transaction { + input: vec![TxIn { + previous_output: OutPoint::new(tx_c1.txid(), 0), + ..TxIn::default() + }], + output: vec![TxOut::default()], + ..common::new_tx(0) + }; + + // tx_d1 spends tx_c2 and tx_c3 + let tx_d1 = Transaction { + input: vec![ + TxIn { + previous_output: OutPoint::new(tx_c2.txid(), 0), + ..TxIn::default() + }, + TxIn { + previous_output: OutPoint::new(tx_c3.txid(), 0), + ..TxIn::default() + }, + ], + output: vec![TxOut::default()], + ..common::new_tx(0) + }; + + // tx_e0 spends tx_d1 + let tx_e0 = Transaction { + input: vec![TxIn { + previous_output: OutPoint::new(tx_d1.txid(), 0), + ..TxIn::default() + }], + output: vec![TxOut::default()], + ..common::new_tx(0) + }; + + let mut graph = TxGraph::::new(vec![ + tx_a0.clone(), + tx_b0.clone(), + tx_b1.clone(), + tx_b2.clone(), + tx_c0.clone(), + tx_c1.clone(), + tx_c2.clone(), + tx_c3.clone(), + tx_d0.clone(), + tx_d1.clone(), + tx_e0.clone(), + ]); + + [&tx_a0, &tx_b1].iter().for_each(|&tx| { + let _ = graph.insert_anchor(tx.txid(), tip.block_id()); + }); + + let ancestors = [ + graph + .walk_ancestors(&tx_c0, |depth, tx| Some((depth, tx))) + .collect::>(), + graph + .walk_ancestors(&tx_d0, |depth, tx| Some((depth, tx))) + .collect::>(), + graph + .walk_ancestors(&tx_e0, |depth, tx| Some((depth, tx))) + .collect::>(), + // Only traverse unconfirmed ancestors of tx_e0 this time + graph + .walk_ancestors(&tx_e0, |depth, tx| { + let tx_node = graph.get_tx_node(tx.txid())?; + for block in tx_node.anchors { + match local_chain.is_block_in_chain(block.anchor_block(), tip.block_id()) { + Ok(Some(true)) => return None, + _ => continue, + } + } + Some((depth, tx_node.tx)) + }) + .collect::>(), + ]; + + let expected_ancestors = [ + vec![(1, &tx_b0), (2, &tx_a0)], + vec![(1, &tx_c1), (2, &tx_b0), (3, &tx_a0)], + vec![ + (1, &tx_d1), + (2, &tx_c2), + (2, &tx_c3), + (3, &tx_b1), + (3, &tx_b2), + (4, &tx_a0), + ], + vec![(1, &tx_d1), (2, &tx_c2), (2, &tx_c3), (3, &tx_b2)], + ]; + + for (txids, expected_txids) in ancestors.iter().zip(expected_ancestors.iter()) { + assert_eq!(txids, expected_txids); + } +} + #[test] fn test_conflicting_descendants() { let previous_output = OutPoint::new(h!("op"), 2); @@ -610,7 +812,7 @@ fn test_descendants_no_repeat() { .collect::>(); let mut graph = TxGraph::<()>::default(); - let mut expected_txids = BTreeSet::new(); + let mut expected_txids = Vec::new(); // these are NOT descendants of `tx_a` for tx in txs_not_connected { @@ -625,19 +827,14 @@ fn test_descendants_no_repeat() { .chain(core::iter::once(&tx_e)) { let _ = graph.insert_tx(tx.clone()); - assert!(expected_txids.insert(tx.txid())); + expected_txids.push(tx.txid()); } let descendants = graph .walk_descendants(tx_a.txid(), |_, txid| Some(txid)) .collect::>(); - assert_eq!(descendants.len(), expected_txids.len()); - - for txid in descendants { - assert!(expected_txids.remove(&txid)); - } - assert!(expected_txids.is_empty()); + assert_eq!(descendants, expected_txids); } #[test] diff --git a/crates/chain/tests/test_tx_graph_conflicts.rs b/crates/chain/tests/test_tx_graph_conflicts.rs new file mode 100644 index 00000000..1794d845 --- /dev/null +++ b/crates/chain/tests/test_tx_graph_conflicts.rs @@ -0,0 +1,629 @@ +#[macro_use] +mod common; + +use std::collections::{BTreeSet, HashSet}; + +use bdk_chain::{keychain::Balance, BlockId}; +use bitcoin::{OutPoint, Script}; +use common::*; + +#[allow(dead_code)] +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_chain_txs` + exp_chain_txs: HashSet<&'a str>, + /// Outpoints that must exist in the output of `filter_chain_txouts` + exp_chain_txouts: HashSet<(&'a str, u32)>, + /// Outpoints of UTXOs that must exist in the output of `filter_chain_unspents` + exp_unspents: HashSet<(&'a str, u32)>, + /// Expected balances + exp_balance: Balance, +} + +/// This test ensures that [`TxGraph`] will reliably filter out irrelevant transactions when +/// presented with multiple conflicting transaction scenarios using the [`TxTemplate`] structure. +/// This test also checks that [`TxGraph::list_chain_txs`], [`TxGraph::filter_chain_txouts`], +/// [`TxGraph::filter_chain_unspents`], and [`TxGraph::balance`] return correct data. +#[test] +fn test_tx_conflict_handling() { + // Create Local chains + let local_chain = local_chain!( + (0, h!("A")), + (1, h!("B")), + (2, h!("C")), + (3, h!("D")), + (4, h!("E")), + (5, h!("F")), + (6, h!("G")) + ); + let chain_tip = local_chain + .tip() + .map(|cp| cp.block_id()) + .unwrap_or_default(); + + let scenarios = [ + Scenario { + name: "2 unconfirmed txs with same last_seens conflict", + tx_templates: &[ + TxTemplate { + tx_name: "tx1", + inputs: &[TxInTemplate::Bogus], + outputs: &[TxOutTemplate::new(40000, Some(0))], + anchors: &[block_id!(1, "B")], + last_seen: None, + }, + TxTemplate { + tx_name: "tx_conflict_1", + inputs: &[TxInTemplate::PrevTx("tx1", 0)], + outputs: &[TxOutTemplate::new(20000, Some(2))], + last_seen: Some(300), + ..Default::default() + }, + TxTemplate { + tx_name: "tx_conflict_2", + inputs: &[TxInTemplate::PrevTx("tx1", 0)], + outputs: &[TxOutTemplate::new(30000, Some(3))], + last_seen: Some(300), + ..Default::default() + }, + ], + // correct output if filtered by fee rate: tx1, tx_conflict_1 + exp_chain_txs: HashSet::from(["tx1", "tx_conflict_1", "tx_conflict_2"]), + exp_chain_txouts: HashSet::from([("tx1", 0), ("tx_conflict_1", 0), ("tx_conflict_2", 0)]), + // correct output if filtered by fee rate: tx_conflict_1 + exp_unspents: HashSet::from([("tx_conflict_1", 0), ("tx_conflict_2", 0)]), + exp_balance: Balance { + immature: 0, + trusted_pending: 50000, // correct output if filtered by fee rate: 20000 + untrusted_pending: 0, + confirmed: 0, + }, + }, + Scenario { + name: "2 unconfirmed txs with different last_seens conflict", + tx_templates: &[ + TxTemplate { + tx_name: "tx1", + inputs: &[TxInTemplate::Bogus], + outputs: &[TxOutTemplate::new(10000, Some(0)), TxOutTemplate::new(10000, Some(1))], + anchors: &[block_id!(1, "B")], + last_seen: None, + }, + TxTemplate { + tx_name: "tx_conflict_1", + inputs: &[TxInTemplate::PrevTx("tx1", 0), TxInTemplate::Bogus], + outputs: &[TxOutTemplate::new(20000, Some(2))], + last_seen: Some(200), + ..Default::default() + }, + TxTemplate { + tx_name: "tx_conflict_2", + inputs: &[TxInTemplate::PrevTx("tx1", 0), TxInTemplate::PrevTx("tx1", 1)], + outputs: &[TxOutTemplate::new(30000, Some(3))], + last_seen: Some(300), + ..Default::default() + }, + ], + exp_chain_txs: HashSet::from(["tx1", "tx_conflict_2"]), + exp_chain_txouts: HashSet::from([("tx1", 0), ("tx1", 1), ("tx_conflict_2", 0)]), + exp_unspents: HashSet::from([("tx_conflict_2", 0)]), + exp_balance: Balance { + immature: 0, + trusted_pending: 30000, + untrusted_pending: 0, + confirmed: 0, + }, + }, + Scenario { + name: "3 unconfirmed txs with different last_seens conflict", + tx_templates: &[ + TxTemplate { + tx_name: "tx1", + inputs: &[TxInTemplate::Bogus], + outputs: &[TxOutTemplate::new(10000, Some(0))], + anchors: &[block_id!(1, "B")], + last_seen: None, + }, + TxTemplate { + tx_name: "tx_conflict_1", + inputs: &[TxInTemplate::PrevTx("tx1", 0), TxInTemplate::Bogus], + outputs: &[TxOutTemplate::new(20000, Some(1))], + last_seen: Some(200), + ..Default::default() + }, + TxTemplate { + tx_name: "tx_conflict_2", + inputs: &[TxInTemplate::PrevTx("tx1", 0)], + outputs: &[TxOutTemplate::new(30000, Some(2))], + last_seen: Some(300), + ..Default::default() + }, + TxTemplate { + tx_name: "tx_conflict_3", + inputs: &[TxInTemplate::PrevTx("tx1", 0)], + outputs: &[TxOutTemplate::new(40000, Some(3))], + last_seen: Some(400), + ..Default::default() + }, + ], + exp_chain_txs: HashSet::from(["tx1", "tx_conflict_3"]), + exp_chain_txouts: HashSet::from([("tx1", 0), ("tx_conflict_3", 0)]), + exp_unspents: HashSet::from([("tx_conflict_3", 0)]), + exp_balance: Balance { + immature: 0, + trusted_pending: 40000, + untrusted_pending: 0, + confirmed: 0, + }, + }, + Scenario { + name: "unconfirmed tx conflicts with tx in orphaned block, orphaned higher last_seen", + tx_templates: &[ + TxTemplate { + tx_name: "tx1", + inputs: &[TxInTemplate::Bogus], + outputs: &[TxOutTemplate::new(10000, Some(0))], + anchors: &[block_id!(1, "B")], + last_seen: None, + }, + TxTemplate { + tx_name: "tx_conflict_1", + inputs: &[TxInTemplate::PrevTx("tx1", 0), TxInTemplate::Bogus], + outputs: &[TxOutTemplate::new(20000, Some(1))], + last_seen: Some(200), + ..Default::default() + }, + TxTemplate { + tx_name: "tx_orphaned_conflict", + inputs: &[TxInTemplate::PrevTx("tx1", 0)], + outputs: &[TxOutTemplate::new(30000, Some(2))], + anchors: &[block_id!(4, "Orphaned Block")], + last_seen: Some(300), + }, + ], + exp_chain_txs: HashSet::from(["tx1", "tx_orphaned_conflict"]), + exp_chain_txouts: HashSet::from([("tx1", 0), ("tx_orphaned_conflict", 0)]), + exp_unspents: HashSet::from([("tx_orphaned_conflict", 0)]), + exp_balance: Balance { + immature: 0, + trusted_pending: 30000, + untrusted_pending: 0, + confirmed: 0, + }, + }, + Scenario { + name: "unconfirmed tx conflicts with tx in orphaned block, orphaned lower last_seen", + tx_templates: &[ + TxTemplate { + tx_name: "tx1", + inputs: &[TxInTemplate::Bogus], + outputs: &[TxOutTemplate::new(10000, Some(0))], + anchors: &[block_id!(1, "B")], + last_seen: None, + }, + TxTemplate { + tx_name: "tx_conflict_1", + inputs: &[TxInTemplate::PrevTx("tx1", 0), TxInTemplate::Bogus], + outputs: &[TxOutTemplate::new(20000, Some(1))], + last_seen: Some(200), + ..Default::default() + }, + TxTemplate { + tx_name: "tx_orphaned_conflict", + inputs: &[TxInTemplate::PrevTx("tx1", 0)], + outputs: &[TxOutTemplate::new(30000, Some(2))], + anchors: &[block_id!(4, "Orphaned Block")], + last_seen: Some(100), + }, + ], + exp_chain_txs: HashSet::from(["tx1", "tx_conflict_1"]), + exp_chain_txouts: HashSet::from([("tx1", 0), ("tx_conflict_1", 0)]), + exp_unspents: HashSet::from([("tx_conflict_1", 0)]), + exp_balance: Balance { + immature: 0, + trusted_pending: 20000, + untrusted_pending: 0, + confirmed: 0, + }, + }, + Scenario { + name: "multiple unconfirmed txs conflict with a confirmed tx", + tx_templates: &[ + TxTemplate { + tx_name: "tx1", + inputs: &[TxInTemplate::Bogus], + outputs: &[TxOutTemplate::new(10000, Some(0))], + anchors: &[block_id!(1, "B")], + last_seen: None, + }, + TxTemplate { + tx_name: "tx_conflict_1", + inputs: &[TxInTemplate::PrevTx("tx1", 0), TxInTemplate::Bogus], + outputs: &[TxOutTemplate::new(20000, Some(1))], + last_seen: Some(200), + ..Default::default() + }, + TxTemplate { + tx_name: "tx_conflict_2", + inputs: &[TxInTemplate::PrevTx("tx1", 0)], + outputs: &[TxOutTemplate::new(30000, Some(2))], + last_seen: Some(300), + ..Default::default() + }, + TxTemplate { + tx_name: "tx_conflict_3", + inputs: &[TxInTemplate::PrevTx("tx1", 0)], + outputs: &[TxOutTemplate::new(40000, Some(3))], + last_seen: Some(400), + ..Default::default() + }, + TxTemplate { + tx_name: "tx_confirmed_conflict", + inputs: &[TxInTemplate::PrevTx("tx1", 0)], + outputs: &[TxOutTemplate::new(50000, Some(4))], + anchors: &[block_id!(1, "B")], + ..Default::default() + }, + ], + exp_chain_txs: HashSet::from(["tx1", "tx_confirmed_conflict"]), + exp_chain_txouts: HashSet::from([("tx1", 0), ("tx_confirmed_conflict", 0)]), + exp_unspents: HashSet::from([("tx_confirmed_conflict", 0)]), + exp_balance: Balance { + immature: 0, + trusted_pending: 0, + untrusted_pending: 0, + confirmed: 50000, + }, + }, + Scenario { + name: "B and B' spend A and conflict, C spends B, all the transactions are unconfirmed, B' has higher last_seen than B", + tx_templates: &[ + TxTemplate { + tx_name: "A", + inputs: &[TxInTemplate::Bogus], + outputs: &[TxOutTemplate::new(10000, Some(0))], + last_seen: Some(22), + ..Default::default() + }, + TxTemplate { + tx_name: "B", + inputs: &[TxInTemplate::PrevTx("A", 0)], + outputs: &[TxOutTemplate::new(20000, Some(1))], + last_seen: Some(23), + ..Default::default() + }, + TxTemplate { + tx_name: "B'", + inputs: &[TxInTemplate::PrevTx("A", 0)], + outputs: &[TxOutTemplate::new(20000, Some(2))], + last_seen: Some(24), + ..Default::default() + }, + TxTemplate { + tx_name: "C", + inputs: &[TxInTemplate::PrevTx("B", 0)], + outputs: &[TxOutTemplate::new(30000, Some(3))], + last_seen: Some(25), + ..Default::default() + }, + ], + // A, B, C will appear in the list methods + // This is because B' has a higher last seen than B, but C has a higher + // last seen than B', so B and C are considered canonical + exp_chain_txs: HashSet::from(["A", "B", "C"]), + exp_chain_txouts: HashSet::from([("A", 0), ("B", 0), ("C", 0)]), + exp_unspents: HashSet::from([("C", 0)]), + exp_balance: Balance { + immature: 0, + trusted_pending: 30000, + untrusted_pending: 0, + confirmed: 0, + }, + }, + Scenario { + name: "B and B' spend A and conflict, C spends B, A and B' are in best chain", + tx_templates: &[ + TxTemplate { + tx_name: "A", + inputs: &[TxInTemplate::Bogus], + outputs: &[TxOutTemplate::new(10000, Some(0))], + anchors: &[block_id!(1, "B")], + last_seen: None, + }, + TxTemplate { + tx_name: "B", + inputs: &[TxInTemplate::PrevTx("A", 0)], + outputs: &[TxOutTemplate::new(20000, Some(1))], + ..Default::default() + }, + TxTemplate { + tx_name: "B'", + inputs: &[TxInTemplate::PrevTx("A", 0)], + outputs: &[TxOutTemplate::new(20000, Some(2))], + anchors: &[block_id!(4, "E")], + ..Default::default() + }, + TxTemplate { + tx_name: "C", + inputs: &[TxInTemplate::PrevTx("B", 0)], + outputs: &[TxOutTemplate::new(30000, Some(3))], + ..Default::default() + }, + ], + // B and C should not appear in the list methods + exp_chain_txs: HashSet::from(["A", "B'"]), + exp_chain_txouts: HashSet::from([("A", 0), ("B'", 0)]), + exp_unspents: HashSet::from([("B'", 0)]), + exp_balance: Balance { + immature: 0, + trusted_pending: 0, + untrusted_pending: 0, + confirmed: 20000, + }, + }, + Scenario { + name: "B and B' spend A and conflict, C spends B', A and B' are in best chain", + tx_templates: &[ + TxTemplate { + tx_name: "A", + inputs: &[TxInTemplate::Bogus], + outputs: &[TxOutTemplate::new(10000, Some(0))], + anchors: &[block_id!(1, "B")], + last_seen: None, + }, + TxTemplate { + tx_name: "B", + inputs: &[TxInTemplate::PrevTx("A", 0)], + outputs: &[TxOutTemplate::new(20000, Some(1))], + ..Default::default() + }, + TxTemplate { + tx_name: "B'", + inputs: &[TxInTemplate::PrevTx("A", 0)], + outputs: &[TxOutTemplate::new(20000, Some(2))], + anchors: &[block_id!(4, "E")], + ..Default::default() + }, + TxTemplate { + tx_name: "C", + inputs: &[TxInTemplate::PrevTx("B'", 0)], + outputs: &[TxOutTemplate::new(30000, Some(3))], + ..Default::default() + }, + ], + // B should not appear in the list methods + exp_chain_txs: HashSet::from(["A", "B'", "C"]), + exp_chain_txouts: HashSet::from([ + ("A", 0), + ("B'", 0), + ("C", 0), + ]), + exp_unspents: HashSet::from([("C", 0)]), + exp_balance: Balance { + immature: 0, + trusted_pending: 30000, + untrusted_pending: 0, + confirmed: 0, + }, + }, + Scenario { + name: "B and B' spend A and conflict, C spends both B and B', A is in best chain", + tx_templates: &[ + TxTemplate { + tx_name: "A", + inputs: &[TxInTemplate::Bogus], + outputs: &[TxOutTemplate::new(10000, Some(0))], + anchors: &[block_id!(1, "B")], + last_seen: None, + }, + TxTemplate { + tx_name: "B", + inputs: &[TxInTemplate::PrevTx("A", 0), TxInTemplate::Bogus], + outputs: &[TxOutTemplate::new(20000, Some(1))], + last_seen: Some(200), + ..Default::default() + }, + TxTemplate { + tx_name: "B'", + inputs: &[TxInTemplate::PrevTx("A", 0)], + outputs: &[TxOutTemplate::new(30000, Some(2))], + last_seen: Some(300), + ..Default::default() + }, + TxTemplate { + tx_name: "C", + inputs: &[ + TxInTemplate::PrevTx("B", 0), + TxInTemplate::PrevTx("B'", 0), + ], + outputs: &[TxOutTemplate::new(20000, Some(3))], + ..Default::default() + }, + ], + // C should not appear in the list methods + exp_chain_txs: HashSet::from(["A", "B'"]), + exp_chain_txouts: HashSet::from([("A", 0), ("B'", 0)]), + exp_unspents: HashSet::from([("B'", 0)]), + exp_balance: Balance { + immature: 0, + trusted_pending: 30000, + untrusted_pending: 0, + confirmed: 0, + }, + }, + Scenario { + name: "B and B' spend A and conflict, B' is confirmed, C spends both B and B', A is in best chain", + tx_templates: &[ + TxTemplate { + tx_name: "A", + inputs: &[TxInTemplate::Bogus], + outputs: &[TxOutTemplate::new(10000, Some(0))], + anchors: &[block_id!(1, "B")], + last_seen: None, + }, + TxTemplate { + tx_name: "B", + inputs: &[TxInTemplate::PrevTx("A", 0), TxInTemplate::Bogus], + outputs: &[TxOutTemplate::new(20000, Some(1))], + last_seen: Some(200), + ..Default::default() + }, + TxTemplate { + tx_name: "B'", + inputs: &[TxInTemplate::PrevTx("A", 0)], + outputs: &[TxOutTemplate::new(50000, Some(4))], + anchors: &[block_id!(1, "B")], + ..Default::default() + }, + TxTemplate { + tx_name: "C", + inputs: &[ + TxInTemplate::PrevTx("B", 0), + TxInTemplate::PrevTx("B'", 0), + ], + outputs: &[TxOutTemplate::new(20000, Some(5))], + ..Default::default() + }, + ], + // C should not appear in the list methods + exp_chain_txs: HashSet::from(["A", "B'"]), + exp_chain_txouts: HashSet::from([("A", 0), ("B'", 0)]), + exp_unspents: HashSet::from([("B'", 0)]), + exp_balance: Balance { + immature: 0, + trusted_pending: 0, + untrusted_pending: 0, + confirmed: 50000, + }, + }, + Scenario { + name: "B and B' spend A and conflict, B' is confirmed, C spends both B and B', D spends C, A is in best chain", + tx_templates: &[ + TxTemplate { + tx_name: "A", + inputs: &[TxInTemplate::Bogus], + outputs: &[TxOutTemplate::new(10000, Some(0))], + anchors: &[block_id!(1, "B")], + last_seen: None, + }, + TxTemplate { + tx_name: "B", + inputs: &[TxInTemplate::PrevTx("A", 0), TxInTemplate::Bogus], + outputs: &[TxOutTemplate::new(20000, Some(1))], + last_seen: Some(200), + ..Default::default() + }, + TxTemplate { + tx_name: "B'", + inputs: &[TxInTemplate::PrevTx("A", 0)], + outputs: &[TxOutTemplate::new(50000, Some(4))], + anchors: &[block_id!(1, "B")], + ..Default::default() + }, + TxTemplate { + tx_name: "C", + inputs: &[ + TxInTemplate::PrevTx("B", 0), + TxInTemplate::PrevTx("B'", 0), + ], + outputs: &[TxOutTemplate::new(20000, Some(5))], + ..Default::default() + }, + TxTemplate { + tx_name: "D", + inputs: &[TxInTemplate::PrevTx("C", 0)], + outputs: &[TxOutTemplate::new(20000, Some(6))], + ..Default::default() + }, + ], + // D should not appear in the list methods + exp_chain_txs: HashSet::from(["A", "B'"]), + exp_chain_txouts: HashSet::from([("A", 0), ("B'", 0)]), + exp_unspents: HashSet::from([("B'", 0)]), + exp_balance: Balance { + immature: 0, + trusted_pending: 0, + untrusted_pending: 0, + confirmed: 50000, + }, + }, + ]; + + for scenario in scenarios { + let (tx_graph, spk_index, exp_tx_ids) = init_graph(scenario.tx_templates.iter()); + + let txs = tx_graph + .list_chain_txs(&local_chain, chain_tip) + .map(|tx| tx.tx_node.txid) + .collect::>(); + let exp_txs = scenario + .exp_chain_txs + .iter() + .map(|txid| *exp_tx_ids.get(txid).expect("txid must exist")) + .collect::>(); + assert_eq!( + txs, exp_txs, + "\n[{}] 'list_chain_txs' failed", + scenario.name + ); + + let txouts = tx_graph + .filter_chain_txouts( + &local_chain, + chain_tip, + spk_index.outpoints().iter().cloned(), + ) + .map(|(_, full_txout)| full_txout.outpoint) + .collect::>(); + let exp_txouts = scenario + .exp_chain_txouts + .iter() + .map(|(txid, vout)| OutPoint { + txid: *exp_tx_ids.get(txid).expect("txid must exist"), + vout: *vout, + }) + .collect::>(); + assert_eq!( + txouts, exp_txouts, + "\n[{}] 'filter_chain_txouts' failed", + scenario.name + ); + + let utxos = tx_graph + .filter_chain_unspents( + &local_chain, + chain_tip, + spk_index.outpoints().iter().cloned(), + ) + .map(|(_, full_txout)| full_txout.outpoint) + .collect::>(); + let exp_utxos = scenario + .exp_unspents + .iter() + .map(|(txid, vout)| OutPoint { + txid: *exp_tx_ids.get(txid).expect("txid must exist"), + vout: *vout, + }) + .collect::>(); + assert_eq!( + utxos, exp_utxos, + "\n[{}] 'filter_chain_unspents' failed", + scenario.name + ); + + let balance = tx_graph.balance( + &local_chain, + chain_tip, + spk_index.outpoints().iter().cloned(), + |_, spk: &Script| spk_index.index_of_spk(spk).is_some(), + ); + assert_eq!( + balance, scenario.exp_balance, + "\n[{}] 'balance' failed", + scenario.name + ); + } +}