diff --git a/crates/chain/src/canonical_task.rs b/crates/chain/src/canonical_task.rs index 0cb8ffbf4a..a7cb9230a2 100644 --- a/crates/chain/src/canonical_task.rs +++ b/crates/chain/src/canonical_task.rs @@ -119,7 +119,12 @@ impl<'g, A: Anchor> ChainQuery for CanonicalTask<'g, A> { } CanonicalStage::LeftOverTxs => { if let Some((txid, tx, height)) = self.unprocessed_leftover_txs.pop_front() { - if !self.is_canonicalized(txid) && !tx.is_coinbase() { + let is_evicted = self + .tx_graph + .get_tx_node(txid) + .expect("leftover transaction must exist") + .is_evicted(); + if !self.is_canonicalized(txid) && !tx.is_coinbase() && !is_evicted { let observed_in = ObservedIn::Block(height); self.mark_canonical( txid, diff --git a/crates/chain/src/tx_graph.rs b/crates/chain/src/tx_graph.rs index 560217b3b6..df0fb01d72 100644 --- a/crates/chain/src/tx_graph.rs +++ b/crates/chain/src/tx_graph.rs @@ -225,6 +225,20 @@ pub struct TxNode<'a, T, A> { pub last_evicted: Option, } +impl TxNode<'_, T, A> { + /// Whether the transaction has been evicted from the mempool. + /// + /// Only mempool observation timestamps are considered; anchors have no effect. A transaction + /// is evicted when its last-evicted timestamp is at least as recent as its last-seen timestamp. + pub fn is_evicted(&self) -> bool { + match (self.last_seen, self.last_evicted) { + (_, None) => false, + (Some(last_seen), Some(last_evicted)) => last_evicted >= last_seen, + (None, Some(_)) => true, + } + } +} + impl Deref for TxNode<'_, T, A> { type Target = T; diff --git a/crates/chain/tests/test_canonical_view.rs b/crates/chain/tests/test_canonical_view.rs index 2cbe021033..09693c3288 100644 --- a/crates/chain/tests/test_canonical_view.rs +++ b/crates/chain/tests/test_canonical_view.rs @@ -2,7 +2,7 @@ use std::collections::BTreeMap; -use bdk_chain::{local_chain::LocalChain, ConfirmationBlockTime, TxGraph}; +use bdk_chain::{local_chain::LocalChain, BlockId, ConfirmationBlockTime, TxGraph}; use bdk_testenv::{hash, utils::new_tx}; use bitcoin::{Amount, BlockHash, OutPoint, ScriptBuf, Transaction, TxIn, TxOut}; @@ -294,3 +294,47 @@ fn test_min_confirmations_multiple_transactions() { ); assert_eq!(balance_high.untrusted_pending, Amount::ZERO); } + +/// Txs with stale anchor and that have `last_evicted >= last_seen` should be excluded from +/// canonicalization. +#[test] +fn test_evicted_stale_anchored_tx_not_canonical() { + let blocks: BTreeMap = + [(0, hash!("genesis")), (1, hash!("b1")), (2, hash!("tip"))] + .into_iter() + .collect(); + let chain = LocalChain::from_blocks(blocks).unwrap(); + + let mut tx_graph = TxGraph::default(); + let tx = Transaction { + input: vec![TxIn { + previous_output: OutPoint::new(hash!("parent"), 0), + ..Default::default() + }], + output: vec![TxOut { + value: Amount::from_sat(50_000), + script_pubkey: ScriptBuf::new(), + }], + ..new_tx(1) + }; + let txid = tx.compute_txid(); + let _ = tx_graph.insert_tx(tx); + let _ = tx_graph.insert_anchor( + txid, + ConfirmationBlockTime { + block_id: BlockId { + height: 1, + hash: hash!("stale"), + }, + confirmation_time: 123456, + }, + ); + let _ = tx_graph.insert_seen_at(txid, 100); + let _ = tx_graph.insert_evicted_at(txid, 200); + + let view = chain.canonical_view(&tx_graph, chain.tip().block_id(), Default::default()); + assert!( + !view.txs().any(|tx| tx.txid == txid), + "evicted leftover tx must not be canonical" + ); +} diff --git a/crates/chain/tests/test_tx_graph.rs b/crates/chain/tests/test_tx_graph.rs index 621bd67067..24b7b947de 100644 --- a/crates/chain/tests/test_tx_graph.rs +++ b/crates/chain/tests/test_tx_graph.rs @@ -1455,6 +1455,32 @@ fn tx_graph_update_conversion() { } } +#[test] +fn tx_node_is_evicted() { + let anchors = [block_id!(1, "A")].into(); + let test_cases = [ + (None, None, false), + (Some(10), None, false), + (None, Some(10), true), + (Some(10), Some(9), false), + (Some(10), Some(10), true), + (Some(10), Some(11), true), + ]; + + for (last_seen, last_evicted, expected) in test_cases { + let node = tx_graph::TxNode { + txid: hash!("tx"), + tx: (), + anchors: &anchors, + first_seen: None, + last_seen, + last_evicted, + }; + + assert_eq!(node.is_evicted(), expected); + } +} + #[test] fn test_seen_at_updates() { // Update both first_seen and last_seen