From 6d601a7e885bfc627594b794b1a460be47799eea Mon Sep 17 00:00:00 2001 From: Daniela Brozzoni Date: Fri, 29 Sep 2023 15:43:58 +0200 Subject: [PATCH] test(chain): Add test for conflicting transactions Co-authored-by: Wei Chen --- crates/chain/tests/test_tx_graph_conflicts.rs | 629 ++++++++++++++++++ 1 file changed, 629 insertions(+) create mode 100644 crates/chain/tests/test_tx_graph_conflicts.rs 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 + ); + } +}