From 70d010c2caa2c52798a87ebe06b7f2375c02a030 Mon Sep 17 00:00:00 2001 From: Wei Chen Date: Mon, 7 Aug 2023 16:14:33 +0800 Subject: [PATCH] feat: implement better tests for transaction conflict handling --- crates/chain/src/indexed_tx_graph.rs | 2 +- crates/chain/tests/common/mod.rs | 3 + crates/chain/tests/common/tx_template.rs | 138 +++++ crates/chain/tests/test_indexed_tx_graph.rs | 655 +++++++++++++++++++- 4 files changed, 792 insertions(+), 6 deletions(-) create mode 100644 crates/chain/tests/common/tx_template.rs diff --git a/crates/chain/src/indexed_tx_graph.rs b/crates/chain/src/indexed_tx_graph.rs index 730b04340c..b9205b2194 100644 --- a/crates/chain/src/indexed_tx_graph.rs +++ b/crates/chain/src/indexed_tx_graph.rs @@ -14,7 +14,7 @@ use crate::{ /// A struct that combines [`TxGraph`] and an [`Indexer`] implementation. /// /// This structure ensures that [`TxGraph`] and [`Indexer`] are updated atomically. -#[derive(Debug)] +#[derive(Clone, Debug)] pub struct IndexedTxGraph { /// Transaction index. pub index: I, diff --git a/crates/chain/tests/common/mod.rs b/crates/chain/tests/common/mod.rs index 2573fd9618..dbe457ff1a 100644 --- a/crates/chain/tests/common/mod.rs +++ b/crates/chain/tests/common/mod.rs @@ -1,3 +1,6 @@ +mod tx_template; +pub use tx_template::*; + #[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 0000000000..cb3eaaec52 --- /dev/null +++ b/crates/chain/tests/common/tx_template.rs @@ -0,0 +1,138 @@ +use std::collections::HashMap; + +use bdk_chain::{ + indexed_tx_graph::IndexedTxGraph, keychain::KeychainTxOutIndex, ConfirmationHeightAnchor, +}; +use bitcoin::{ + hashes::Hash, locktime::absolute::LockTime, OutPoint, ScriptBuf, Sequence, Transaction, TxIn, + TxOut, Txid, Witness, +}; + +/// Transaction template. +pub struct TxTemplate<'a, K, 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, +} + +impl<'a, K, A> Default for TxTemplate<'a, K, A> { + fn default() -> Self { + Self { + tx_name: Default::default(), + inputs: Default::default(), + outputs: Default::default(), + anchors: Default::default(), + last_seen: Default::default(), + } + } +} + +impl<'a, K, A> Copy for TxTemplate<'a, K, A> {} + +impl<'a, K, A> Clone for TxTemplate<'a, K, A> { + fn clone(&self) -> Self { + *self + } +} + +#[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 + /// a previous transaction. + PrevTx(&'a str, usize), +} + +pub struct TxOutTemplate { + pub value: u64, + pub owned_spk: Option<(K, u32)>, // some = derive a spk from K, none = random spk +} + +#[allow(dead_code)] +pub fn init_graph<'a>( + graph: IndexedTxGraph>, + tx_templates: impl IntoIterator>, +) -> ( + IndexedTxGraph>, + HashMap<&'a str, Txid>, +) { + let mut tx_ids = HashMap::<&'a str, Txid>::new(); + let mut graph = graph; + + 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(|spend_tmp| match spend_tmp { + TxInTemplate::Bogus => TxIn { + // #TODO have actual random data + previous_output: OutPoint::new(Txid::all_zeros(), 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_tmp| match &output_tmp.owned_spk { + None => TxOut { + value: output_tmp.value, + script_pubkey: ScriptBuf::new(), + }, + Some((keychain, index)) => { + let descriptor = graph + .index + .keychains() + .get(keychain) + .expect("keychain must exist"); + TxOut { + value: output_tmp.value, + script_pubkey: descriptor + .at_derivation_index(*index) + .unwrap() + .script_pubkey(), + } + } + }) + .collect(), + }; + + tx_ids.insert(tx_tmp.tx_name, tx.clone().txid()); + let _ = graph.insert_relevant_txs( + [&tx].iter().map(|tx| (*tx, tx_tmp.anchors.iter().cloned())), + tx_tmp.last_seen, + ); + } + + (graph, tx_ids) +} diff --git a/crates/chain/tests/test_indexed_tx_graph.rs b/crates/chain/tests/test_indexed_tx_graph.rs index 59032e65db..8f8008f2b4 100644 --- a/crates/chain/tests/test_indexed_tx_graph.rs +++ b/crates/chain/tests/test_indexed_tx_graph.rs @@ -1,19 +1,363 @@ #[macro_use] mod common; -use std::collections::{BTreeMap, BTreeSet}; +use std::collections::{BTreeMap, BTreeSet, HashSet}; use bdk_chain::{ indexed_tx_graph::{IndexedAdditions, IndexedTxGraph}, keychain::{Balance, DerivationAdditions, KeychainTxOutIndex}, local_chain::LocalChain, tx_graph::Additions, - BlockId, ChainPosition, ConfirmationHeightAnchor, + BlockId, ChainPosition, ConfirmationHeightAnchor, SpkIterator, }; use bitcoin::{ - secp256k1::Secp256k1, BlockHash, OutPoint, Script, ScriptBuf, Transaction, TxIn, TxOut, + hashes::Hash, secp256k1::Secp256k1, BlockHash, OutPoint, Script, ScriptBuf, Transaction, TxIn, + TxOut, }; -use miniscript::Descriptor; +use common::*; +use miniscript::{Descriptor, DescriptorPublicKey}; + +#[allow(dead_code)] +struct Scenario<'a> { + /// Name of the test scenario + name: &'a str, + /// Transaction templates + tx_templates: &'a [TxTemplate<'a, (), ConfirmationHeightAnchor>], + /// Names of txs that must exist in the output of `list_chain_txs` + exp_chain_txs: 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 [`IndexedTxGraph`] will reliably filter out irrelevant transactions when +/// presented with multiple conflicting transaction scenarios using the [`TxTemplate`] structure. +/// This test also checks that [`TxGraph::filter_chain_txouts`], [`TxGraph::filter_chain_unspents`], +/// and [`TxGraph::balance`] return correct data. +/// +/// #TODO remove placeholder [`test_list_owned_txouts`] scenario and implement the following test +/// transactions: +/// - B and B' conflict. C spends B. B' is anchored in best chain. B and C should not appear in +/// the list methods. +/// - B and B' conflict. C spends B. B is anchored in best chain. B' should not appear in the +/// list methods. +/// - B and B' conflict. C spends both B and B'. C is impossible. Try this with only B confirmed +/// vs only B' confirmed vs none confirmed. +#[test] +fn test_tx_conflict_handling() { + // Create Local chains + let local_chain = LocalChain::from( + (0..150) + .map(|i| (i as u32, h!("random"))) + .collect::>(), + ); + let chain_tip = local_chain + .tip() + .map(|cp| cp.block_id()) + .unwrap_or_default(); + + // Initiate IndexedTxGraph + let (desc, _) = Descriptor::parse_descriptor(&Secp256k1::signing_only(), "tr(tprv8ZgxMBicQKsPd3krDUsBAmtnRsK3rb8u5yi1zhQgMhF1tR8MW7xfE4rnrbbsrbPR52e7rKapu6ztw1jXveJSCGHEriUGZV7mCe88duLp5pj/86'/1'/0'/0/*)").unwrap(); + let mut graph = IndexedTxGraph::>::default(); + graph.index.add_keychain((), desc); + graph.index.set_lookahead_for_all(10); + + // Get trusted addresses + let trusted_spks: Vec = (0..10) + .map(|_| { + let ((_, script), _) = graph.index.reveal_next_spk(&()); + script.to_owned() + }) + .collect(); + + // Create test transactions + + // Confirmed parent transaction at Block 1. + let tx1: TxTemplate<'_, _, ConfirmationHeightAnchor> = TxTemplate { + tx_name: "tx1", + outputs: &[TxOutTemplate { + value: 10000, + owned_spk: Some(((), 0)), + }], + anchors: &[ConfirmationHeightAnchor { + anchor_block: BlockId { + height: 1, + hash: *local_chain.blocks().get(&1).unwrap(), + }, + confirmation_height: 1, + }], + last_seen: None, + ..Default::default() + }; + + let tx_conflict_1: TxTemplate<'_, _, ConfirmationHeightAnchor> = TxTemplate { + tx_name: "tx_conflict_1", + inputs: &[TxInTemplate::PrevTx("tx1", 0)], + outputs: &[TxOutTemplate { + value: 20000, + owned_spk: Some(((), 1)), + }], + last_seen: Some(200), + ..Default::default() + }; + + let tx_conflict_2: TxTemplate<'_, _, ConfirmationHeightAnchor> = TxTemplate { + tx_name: "tx_conflict_2", + inputs: &[TxInTemplate::PrevTx("tx1", 0)], + outputs: &[TxOutTemplate { + value: 30000, + owned_spk: Some(((), 2)), + }], + last_seen: Some(300), + ..Default::default() + }; + + let tx_conflict_3: TxTemplate<'_, _, ConfirmationHeightAnchor> = TxTemplate { + tx_name: "tx_conflict_3", + inputs: &[TxInTemplate::PrevTx("tx1", 0)], + outputs: &[TxOutTemplate { + value: 40000, + owned_spk: Some(((), 3)), + }], + last_seen: Some(400), + ..Default::default() + }; + + let tx_orphaned_conflict_1: TxTemplate<'_, _, ConfirmationHeightAnchor> = TxTemplate { + tx_name: "tx_orphaned_conflict_1", + inputs: &[TxInTemplate::PrevTx("tx1", 0)], + outputs: &[TxOutTemplate { + value: 30000, + owned_spk: Some(((), 2)), + }], + anchors: &[ConfirmationHeightAnchor { + anchor_block: BlockId { + hash: h!("Orphaned Block"), + height: 5, + }, + confirmation_height: 5, + }], + last_seen: Some(300), + }; + + let tx_orphaned_conflict_2: TxTemplate<'_, _, ConfirmationHeightAnchor> = TxTemplate { + tx_name: "tx_orphaned_conflict_2", + inputs: &[TxInTemplate::PrevTx("tx1", 0)], + outputs: &[TxOutTemplate { + value: 30000, + owned_spk: Some(((), 2)), + }], + anchors: &[ConfirmationHeightAnchor { + anchor_block: BlockId { + hash: h!("Orphaned Block"), + height: 5, + }, + confirmation_height: 5, + }], + last_seen: Some(100), + }; + + let tx_confirmed_conflict: TxTemplate<'_, _, ConfirmationHeightAnchor> = TxTemplate { + tx_name: "tx_confirmed_conflict", + inputs: &[TxInTemplate::PrevTx("tx1", 0)], + outputs: &[TxOutTemplate { + value: 50000, + owned_spk: Some(((), 4)), + }], + anchors: &[ConfirmationHeightAnchor { + anchor_block: BlockId { + height: 1, + hash: *local_chain.blocks().get(&1).unwrap(), + }, + confirmation_height: 1, + }], + ..Default::default() + }; + + let scenarios = [ + Scenario { + name: "2 unconfirmed txs with different last_seens conflict", + tx_templates: &[tx1, tx_conflict_1, tx_conflict_2], + exp_chain_txs: HashSet::from([("tx1", 0), ("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: &[tx1, tx_conflict_1, tx_conflict_2, tx_conflict_3], + exp_chain_txs: 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 unseen", + tx_templates: &[tx1, tx_conflict_1, tx_orphaned_conflict_1], + exp_chain_txs: HashSet::from([("tx1", 0), ("tx_orphaned_conflict_1", 0)]), + exp_unspents: HashSet::from([("tx_orphaned_conflict_1", 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 unseen", + tx_templates: &[tx1, tx_conflict_1, tx_orphaned_conflict_2], + exp_chain_txs: 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: &[ + tx1, + tx_conflict_1, + tx_conflict_2, + tx_conflict_3, + tx_confirmed_conflict, + ], + exp_chain_txs: 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, + }, + }, + ]; + + for scenario in scenarios { + let (tx_graph, exp_tx_ids) = init_graph(graph.clone(), scenario.tx_templates.iter()); + + let exp_txouts = scenario + .exp_chain_txs + .iter() + .map(|(txid, vout)| OutPoint { + txid: *exp_tx_ids.get(txid).expect("txid must exist"), + vout: *vout, + }) + .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::>(); + + let txouts = tx_graph + .graph() + .filter_chain_txouts( + &local_chain, + chain_tip, + tx_graph.index.outpoints().iter().cloned(), + ) + .collect::>() + .iter() + .map(|(_, full_txout)| full_txout.outpoint) + .collect::>(); + let utxos = tx_graph + .graph() + .filter_chain_unspents( + &local_chain, + chain_tip, + tx_graph.index.outpoints().iter().cloned(), + ) + .collect::>() + .iter() + .map(|(_, full_txout)| full_txout.outpoint) + .collect::>(); + let balance = tx_graph.graph().balance( + &local_chain, + chain_tip, + tx_graph.index.outpoints().iter().cloned(), + |_, spk: &Script| trusted_spks.contains(&spk.to_owned()), + ); + + assert_eq!(txouts, exp_txouts); + assert_eq!(utxos, exp_utxos); + assert_eq!(balance, scenario.exp_balance); + } +} + +#[allow(unused)] +pub fn single_descriptor_setup() -> ( + LocalChain, + IndexedTxGraph>, + Descriptor, +) { + let local_chain = (0..10) + .map(|i| (i as u32, BlockHash::hash(format!("Block {}", i).as_bytes()))) + .collect::>(); + let local_chain = LocalChain::from(local_chain); + + let (desc_1, _) = Descriptor::parse_descriptor(&Secp256k1::signing_only(), "tr(tprv8ZgxMBicQKsPd3krDUsBAmtnRsK3rb8u5yi1zhQgMhF1tR8MW7xfE4rnrbbsrbPR52e7rKapu6ztw1jXveJSCGHEriUGZV7mCe88duLp5pj/86'/1'/0'/0/*)").unwrap(); + + let mut graph = IndexedTxGraph::>::default(); + + graph.index.add_keychain((), desc_1.clone()); + graph.index.set_lookahead_for_all(100); + + (local_chain, graph, desc_1) +} + +#[allow(unused)] +pub fn setup_conflicts( + spk_iter: &mut SpkIterator<&Descriptor>, +) -> (Transaction, Transaction, Transaction) { + let tx1 = Transaction { + output: vec![TxOut { + script_pubkey: spk_iter.next().unwrap().1, + value: 10000, + }], + ..new_tx(0) + }; + + let tx_conflict_1 = Transaction { + input: vec![TxIn { + previous_output: OutPoint::new(tx1.txid(), 0), + ..Default::default() + }], + output: vec![TxOut { + script_pubkey: spk_iter.next().unwrap().1, + value: 20000, + }], + ..new_tx(0) + }; + + let tx_conflict_2 = Transaction { + input: vec![TxIn { + previous_output: OutPoint::new(tx1.txid(), 0), + ..Default::default() + }], + output: vec![TxOut { + script_pubkey: spk_iter.next().unwrap().1, + value: 30000, + }], + ..new_tx(0) + }; + + (tx1, tx_conflict_1, tx_conflict_2) +} /// Ensure [`IndexedTxGraph::insert_relevant_txs`] can successfully index transactions NOT presented /// in topological order. @@ -202,7 +546,7 @@ fn test_list_owned_txouts() { ..common::new_tx(0) }; - // tx6 is an unrelated transaction confirmed at 3. + // tx6 is an irrelevant transaction confirmed at Block 3. let tx6 = common::new_tx(0); // Insert transactions into graph with respective anchors @@ -468,3 +812,304 @@ fn test_list_owned_txouts() { ); } } + +/// Check conflicts between two in mempool transactions. Tx with older `seen_at` is filtered out. +#[test] +fn test_unconfirmed_conflicts() { + let (local_chain, mut graph, desc) = single_descriptor_setup(); + let mut spk_iter = SpkIterator::new(&desc); + let (parent_tx, tx_conflict_1, tx_conflict_2) = setup_conflicts(&mut spk_iter); + + // Parent tx is confirmed at height 2. + let _ = graph.insert_relevant_txs( + [&parent_tx].iter().map(|tx| { + ( + *tx, + [ConfirmationHeightAnchor { + anchor_block: (2, *local_chain.blocks().get(&2).unwrap()).into(), + confirmation_height: 2, + }], + ) + }), + None, + ); + + // Conflict 1 is seen at 100 + let _ = graph.insert_relevant_txs([&tx_conflict_1].iter().map(|tx| (*tx, None)), Some(100)); + + // Conflict 2 is seen at 200 + let _ = graph.insert_relevant_txs([&tx_conflict_2].iter().map(|tx| (*tx, None)), Some(200)); + + let txout_confirmations = graph + .graph() + .filter_chain_txouts( + &local_chain, + local_chain.tip().unwrap().block_id(), + graph.index.outpoints().iter().cloned(), + ) + .map(|(_, txout)| txout.chain_position) + .collect::>(); + + let mut utxos = graph + .graph() + .filter_chain_unspents( + &local_chain, + local_chain.tip().unwrap().block_id(), + graph.index.outpoints().iter().cloned(), + ) + .collect::>(); + + // We only have 2 txouts. The confirmed `tx_1` and latest `tx_conflict_2` + assert_eq!(txout_confirmations.len(), 2); + assert_eq!( + txout_confirmations, + [ + ChainPosition::Confirmed(ConfirmationHeightAnchor { + anchor_block: (2u32, *local_chain.blocks().get(&2).unwrap()).into(), + confirmation_height: 2 + }), + ChainPosition::Unconfirmed(200) + ] + .into() + ); + + // We only have one utxo. The latest `tx_conflict_2`. + assert_eq!( + utxos.pop().unwrap().1.chain_position, + ChainPosition::Unconfirmed(200) + ); +} + +/// Check conflict between mempool and orphaned block. Tx in orphaned block is filtered out. +#[test] +fn test_orphaned_conflicts() { + let (local_chain, mut graph, desc) = single_descriptor_setup(); + let mut spk_iter = SpkIterator::new(&desc); + let (parent_tx, tx_conflict_1, tx_conflict_2) = setup_conflicts(&mut spk_iter); + + // Parent tx confirmed at height 2. + let _ = graph.insert_relevant_txs( + [&parent_tx].iter().map(|tx| { + ( + *tx, + [ConfirmationHeightAnchor { + anchor_block: (2, *local_chain.blocks().get(&2).unwrap()).into(), + confirmation_height: 2, + }], + ) + }), + None, + ); + + // Ophaned block at height 5. + let orphaned_block = BlockId { + hash: h!("Orphaned Block"), + height: 5, + }; + + // 1st conflicting tx is in mempool. + let _ = graph.insert_relevant_txs([&tx_conflict_1].iter().map(|tx| (*tx, None)), Some(100)); + + // Second conflicting tx is in orphaned block. + let _ = graph.insert_relevant_txs( + [&tx_conflict_2].iter().map(|tx| { + ( + *tx, + [ConfirmationHeightAnchor { + anchor_block: orphaned_block, + confirmation_height: 5, + }], + ) + }), + None, + ); + + let txout_confirmations = graph + .graph() + .filter_chain_txouts( + &local_chain, + local_chain.tip().unwrap().block_id(), + graph.index.outpoints().iter().cloned(), + ) + .map(|(_, txout)| txout.chain_position) + .collect::>(); + + let mut utxos = graph + .graph() + .filter_chain_unspents( + &local_chain, + local_chain.tip().unwrap().block_id(), + graph.index.outpoints().iter().cloned(), + ) + .collect::>(); + + // We only have the mempool tx. Conflicting orphaned is ignored. + assert_eq!(txout_confirmations.len(), 2); + assert_eq!( + txout_confirmations, + [ + ChainPosition::Confirmed(ConfirmationHeightAnchor { + anchor_block: (2u32, *local_chain.blocks().get(&2).unwrap()).into(), + confirmation_height: 2 + }), + ChainPosition::Unconfirmed(100) + ] + .into() + ); + + // We only have one utxo and its in mempool. + assert_eq!( + utxos.pop().unwrap().1.chain_position, + ChainPosition::Unconfirmed(100) + ); +} + +/// Check conflicts between mempool and confirmed tx. Mempool tx is filtered out. +#[test] +fn test_confirmed_conflicts() { + let (local_chain, mut graph, desc) = single_descriptor_setup(); + let mut spk_iter = SpkIterator::new(&desc); + let (parent_tx, tx_conflict_1, tx_conflict_2) = setup_conflicts(&mut spk_iter); + + // Parent confirms at height 2. + let _ = graph.insert_relevant_txs( + [&parent_tx].iter().map(|tx| { + ( + *tx, + [ConfirmationHeightAnchor { + anchor_block: (2, *local_chain.blocks().get(&2).unwrap()).into(), + confirmation_height: 2, + }], + ) + }), + None, + ); + + // `tx_conflict_1` is in mempool. + let _ = graph.insert_relevant_txs([&tx_conflict_1].iter().map(|tx| (*tx, None)), Some(100)); + + // `tx_conflict_2` is in orphaned block at height 5. + let _ = graph.insert_relevant_txs( + [&tx_conflict_2].iter().map(|tx| { + ( + *tx, + [ConfirmationHeightAnchor { + anchor_block: (2, *local_chain.blocks().get(&2).unwrap()).into(), + confirmation_height: 2, + }], + ) + }), + None, + ); + + let txout_confirmations = graph + .graph() + .filter_chain_txouts( + &local_chain, + local_chain.tip().unwrap().block_id(), + graph.index.outpoints().iter().cloned(), + ) + .map(|(_, txout)| txout.chain_position) + .collect::>(); + + let mut utxos = graph + .graph() + .filter_chain_unspents( + &local_chain, + local_chain.tip().unwrap().block_id(), + graph.index.outpoints().iter().cloned(), + ) + .collect::>(); + + // We only have 1 txout. Confirmed at block 2. + assert_eq!(txout_confirmations.len(), 1); + assert_eq!( + txout_confirmations, + [ChainPosition::Confirmed(ConfirmationHeightAnchor { + anchor_block: (2, *local_chain.blocks().get(&2).unwrap()).into(), + confirmation_height: 2 + })] + .into() + ); + + // We only have one utxo, confirmed at block 2. + assert_eq!( + utxos.pop().unwrap().1.chain_position, + ChainPosition::Confirmed(ConfirmationHeightAnchor { + anchor_block: (2u32, *local_chain.blocks().get(&2).unwrap()).into(), + confirmation_height: 2 + }), + ); +} + +/// Test conflicts for two mempool tx, with same `seen_at` time. +#[test] +fn test_unconfirmed_conflicts_at_same_last_seen() { + let (local_chain, mut graph, desc) = single_descriptor_setup(); + let mut spk_iter = SpkIterator::new(&desc); + let (parent_tx, tx_conflict_1, tx_conflict_2) = setup_conflicts(&mut spk_iter); + + // Parent confirms at height 2. + let _ = graph.insert_relevant_txs( + [&parent_tx].iter().map(|tx| { + ( + *tx, + [ConfirmationHeightAnchor { + anchor_block: (2, *local_chain.blocks().get(&2).unwrap()).into(), + confirmation_height: 2, + }], + ) + }), + None, + ); + + // Both conflicts are in mempool at same `seen_at` + let _ = graph.insert_relevant_txs( + [&tx_conflict_1, &tx_conflict_2] + .iter() + .map(|tx| (*tx, None)), + Some(100), + ); + + let txouts = graph + .graph() + .filter_chain_txouts( + &local_chain, + local_chain.tip().unwrap().block_id(), + graph.index.outpoints().iter().cloned(), + ) + .collect::>(); + + let utxos = graph + .graph() + .filter_chain_unspents( + &local_chain, + local_chain.tip().unwrap().block_id(), + graph.index.outpoints().iter().cloned(), + ) + .collect::>(); + + // FIXME: Currently both the mempool tx are indexed and listed out. This can happen in case of RBF fee bumps, + // when both the txs are observed at a single sync time. This can be resolved by checking the input's nSequence. + // Additionally in case of non RBF conflicts at same `seen_at`, conflicting txids can be reported back for filtering + // out in higher layers. This is similar to what core rpc does in case of unresolvable conflicts. + + // We have two in mempool txouts. Both at same chain position. + assert_eq!(txouts.len(), 3); + assert_eq!( + txouts + .iter() + .filter(|(_, txout)| matches!(txout.chain_position, ChainPosition::Unconfirmed(100))) + .count(), + 2 + ); + + // We have two mempool utxos both at same chain position. + assert_eq!( + utxos + .iter() + .filter(|(_, txout)| matches!(txout.chain_position, ChainPosition::Unconfirmed(100))) + .count(), + 2 + ); +}