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