Skip to content

Commit 1a70384

Browse files
ValuedMammalLagginTimes
authored andcommitted
test(rpc): add test_expect_tx_evicted
1 parent b707586 commit 1a70384

File tree

2 files changed

+197
-5
lines changed

2 files changed

+197
-5
lines changed

crates/bitcoind_rpc/src/lib.rs

Lines changed: 90 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,10 @@ pub struct Emitter<'c, C> {
4343
/// The last emitted block during our last mempool emission. This is used to determine whether
4444
/// there has been a reorg since our last mempool emission.
4545
last_mempool_tip: Option<u32>,
46+
47+
/// Unconfirmed txids that are expected to appear in mempool. This is used to determine if any
48+
/// known txids have been evicted.
49+
expected_mempool_txids: HashSet<Txid>,
4650
}
4751

4852
impl<'c, C: bitcoincore_rpc::RpcApi> Emitter<'c, C> {
@@ -53,7 +57,15 @@ impl<'c, C: bitcoincore_rpc::RpcApi> Emitter<'c, C> {
5357
///
5458
/// `start_height` starts emission from a given height (if there are no conflicts with the
5559
/// original chain).
56-
pub fn new(client: &'c C, last_cp: CheckPoint, start_height: u32) -> Self {
60+
///
61+
/// `expected_mempool_txids` is the initial set of unconfirmed txids. Once at tip, any that are
62+
/// no longer in mempool are marked evicted.
63+
pub fn new(
64+
client: &'c C,
65+
last_cp: CheckPoint,
66+
start_height: u32,
67+
expected_mempool_txids: HashSet<Txid>,
68+
) -> Self {
5769
Self {
5870
client,
5971
start_height,
@@ -64,7 +76,14 @@ impl<'c, C: bitcoincore_rpc::RpcApi> Emitter<'c, C> {
6476
}
6577
}
6678

67-
/// Emit mempool transactions, alongside their first-seen unix timestamps.
79+
/// Emit mempool transactions and any evicted [`Txid`]s. Returns a `latest_update_time` which is
80+
/// used for setting the timestamp for evicted transactions.
81+
///
82+
/// This method returns a [`MempoolEvent`] containing the full transactions (with their
83+
/// first-seen unix timestamps) that were emitted, and [`MempoolEvent::evicted_txids`] which are
84+
/// any [`Txid`]s which were previously expected and are now missing from the mempool. Note that
85+
/// [`Txid`]s are only evicted if the emitter is at the chain tip with the same height and hash
86+
/// as the best block from the RPC.
6887
///
6988
/// This method emits each transaction only once, unless we cannot guarantee the transaction's
7089
/// ancestors are already emitted.
@@ -84,6 +103,32 @@ impl<'c, C: bitcoincore_rpc::RpcApi> Emitter<'c, C> {
84103
// `start_height` has been emitted.
85104
.unwrap_or(self.start_height.saturating_sub(1));
86105

106+
// Get the raw mempool result from the RPC client which will be used to determine if any
107+
// transactions have been evicted.
108+
let raw_mempool = client.get_raw_mempool_verbose()?;
109+
let raw_mempool_txids: HashSet<Txid> = raw_mempool.keys().copied().collect();
110+
111+
// Determine if height and hash matches the best block from the RPC. Evictions are deferred
112+
// if we are not at the best block.
113+
let height = client.get_block_count()?;
114+
let at_tip = if height != self.last_cp.height() as u64 {
115+
false
116+
} else {
117+
// Verify if block hash matches in case of re-org.
118+
client.get_block_hash(height)? == self.last_cp.hash()
119+
};
120+
121+
// If at tip, any expected txid missing from raw mempool is considered evicted;
122+
// if not at tip, we don't evict anything.
123+
let mut evicted_txids: HashSet<Txid> = if at_tip {
124+
self.expected_mempool_txids
125+
.difference(&raw_mempool_txids)
126+
.copied()
127+
.collect()
128+
} else {
129+
HashSet::new()
130+
};
131+
87132
// Mempool txs come with a timestamp of when the tx is introduced to the mempool. We keep
88133
// track of the latest mempool tx's timestamp to determine whether we have seen a tx
89134
// before. `prev_mempool_time` is the previous timestamp and `last_time` records what will
@@ -128,7 +173,20 @@ impl<'c, C: bitcoincore_rpc::RpcApi> Emitter<'c, C> {
128173
self.last_mempool_time = latest_time;
129174
self.last_mempool_tip = Some(self.last_cp.height());
130175

131-
Ok(txs_to_emit)
176+
// If at tip, we replace `expected_mempool_txids` with just the new txids. Otherwise, we’re
177+
// still catching up to the tip and keep accumulating.
178+
if at_tip {
179+
self.expected_mempool_txids = new_txs.iter().map(|(tx, _)| tx.compute_txid()).collect();
180+
} else {
181+
self.expected_mempool_txids
182+
.extend(new_txs.iter().map(|(tx, _)| tx.compute_txid()));
183+
}
184+
185+
Ok(MempoolEvent {
186+
new_txs,
187+
evicted_txids,
188+
latest_update_time: latest_time as u64,
189+
})
132190
}
133191

134192
/// Emit the next block height and header (if any).
@@ -139,11 +197,38 @@ impl<'c, C: bitcoincore_rpc::RpcApi> Emitter<'c, C> {
139197

140198
/// Emit the next block height and block (if any).
141199
pub fn next_block(&mut self) -> Result<Option<BlockEvent<Block>>, bitcoincore_rpc::Error> {
142-
Ok(poll(self, |hash| self.client.get_block(hash))?
143-
.map(|(checkpoint, block)| BlockEvent { block, checkpoint }))
200+
if let Some((checkpoint, block)) = poll(self, |hash| self.client.get_block(hash))? {
201+
// Stop tracking unconfirmed transactions that have been confirmed in this block.
202+
for tx in &block.txdata {
203+
self.expected_mempool_txids.remove(&tx.compute_txid());
204+
}
205+
return Ok(Some(BlockEvent { block, checkpoint }));
206+
}
207+
Ok(None)
144208
}
145209
}
146210

211+
/// A new emission from mempool.
212+
#[derive(Debug)]
213+
pub struct MempoolEvent {
214+
/// Unemitted transactions or transactions with ancestors that are unseen by the receiver.
215+
///
216+
/// To understand the second condition, consider a receiver which filters transactions based on
217+
/// whether it alters the UTXO set of tracked script pubkeys. If an emitted mempool transaction
218+
/// spends a tracked UTXO which is confirmed at height `h`, but the receiver has only seen up to
219+
/// block of height `h-1`, we want to re-emit this transaction until the receiver has seen the
220+
/// block at height `h`.
221+
pub new_txs: Vec<(Transaction, u64)>,
222+
223+
/// [`Txid`]s of all transactions that have been evicted from mempool.
224+
pub evicted_txids: HashSet<Txid>,
225+
226+
/// The latest timestamp of when a transaction entered the mempool.
227+
///
228+
/// This is useful for setting the timestamp for evicted transactions.
229+
pub latest_update_time: u64,
230+
}
231+
147232
/// A newly emitted block from [`Emitter`].
148233
#[derive(Debug)]
149234
pub struct BlockEvent<B> {

crates/bitcoind_rpc/tests/test_emitter.rs

Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -731,3 +731,110 @@ fn no_agreement_point() -> anyhow::Result<()> {
731731

732732
Ok(())
733733
}
734+
735+
/// Validates that when an unconfirmed transaction is double-spent (and thus evicted from the
736+
/// mempool), the emitter reports it in `evicted_txids`, and after inserting that eviction into the
737+
/// graph it no longer appears in the set of canonical transactions.
738+
///
739+
/// 1. Broadcast a first tx (tx1) and confirm it arrives in unconfirmed set.
740+
/// 2. Double-spend tx1 with tx1b and verify `mempool()` reports tx1 as evicted.
741+
/// 3. Insert the eviction into the graph and assert tx1 is no longer canonical.
742+
#[test]
743+
fn test_expect_tx_evicted() -> anyhow::Result<()> {
744+
use bdk_bitcoind_rpc::bitcoincore_rpc::bitcoin;
745+
use bdk_bitcoind_rpc::bitcoincore_rpc::bitcoincore_rpc_json::CreateRawTransactionInput;
746+
use bdk_chain::miniscript;
747+
use bdk_chain::spk_txout::SpkTxOutIndex;
748+
use bitcoin::constants::genesis_block;
749+
use bitcoin::secp256k1::Secp256k1;
750+
use bitcoin::Network;
751+
use std::collections::HashMap;
752+
let env = TestEnv::new()?;
753+
754+
let s = bdk_testenv::utils::DESCRIPTORS[0];
755+
let desc = miniscript::Descriptor::parse_descriptor(&Secp256k1::new(), s)
756+
.unwrap()
757+
.0;
758+
let spk = desc.at_derivation_index(0)?.script_pubkey();
759+
760+
let mut chain = LocalChain::from_genesis_hash(genesis_block(Network::Regtest).block_hash()).0;
761+
let chain_tip = chain.tip().block_id();
762+
763+
let mut index = SpkTxOutIndex::default();
764+
index.insert_spk((), spk.clone());
765+
let mut graph = IndexedTxGraph::<BlockId, _>::new(index);
766+
767+
// Receive tx1.
768+
let _ = env.mine_blocks(100, None)?;
769+
let txid_1 = env.send(
770+
&Address::from_script(&spk, Network::Regtest)?,
771+
Amount::ONE_BTC,
772+
)?;
773+
774+
let mut emitter = Emitter::new(env.rpc_client(), chain.tip(), 1, HashSet::from([txid_1]));
775+
while let Some(emission) = emitter.next_block()? {
776+
let height = emission.block_height();
777+
chain.apply_update(CheckPoint::from_header(&emission.block.header, height))?;
778+
}
779+
780+
let changeset = graph.batch_insert_unconfirmed(emitter.mempool()?.new_txs);
781+
assert!(changeset
782+
.tx_graph
783+
.txs
784+
.iter()
785+
.any(|tx| tx.compute_txid() == txid_1));
786+
787+
// Double spend tx1.
788+
789+
// Get `prevout` from core.
790+
let core = env.rpc_client();
791+
let tx1 = &core.get_raw_transaction(&txid_1, None)?;
792+
let txin = &tx1.input[0];
793+
let op = txin.previous_output;
794+
795+
// Create `tx1b` using the previous output from tx1.
796+
let utxo = CreateRawTransactionInput {
797+
txid: op.txid,
798+
vout: op.vout,
799+
sequence: None,
800+
};
801+
let addr = core.get_new_address(None, None)?.assume_checked();
802+
let tx = core.create_raw_transaction(
803+
&[utxo],
804+
&HashMap::from([(addr.to_string(), Amount::from_btc(49.99)?)]),
805+
None,
806+
None,
807+
)?;
808+
let res = core.sign_raw_transaction_with_wallet(&tx, None, None)?;
809+
let tx1b = res.transaction()?;
810+
811+
// Send the tx.
812+
let _txid_2 = core.send_raw_transaction(&tx1b)?;
813+
814+
// Retrieve the expected unconfirmed txids and spks from the graph.
815+
let exp_spk_txids = graph
816+
.list_expected_spk_txids(&chain, chain_tip, ..)
817+
.collect::<Vec<_>>();
818+
assert_eq!(exp_spk_txids, vec![(spk, txid_1)]);
819+
820+
// Check that mempool emission contains evicted txid.
821+
let mempool_event = emitter.mempool()?;
822+
assert!(mempool_event.evicted_txids.contains(&txid_1));
823+
824+
// Update graph with evicted tx.
825+
for txid in mempool_event.evicted_txids {
826+
if graph.graph().get_tx_node(txid).is_some() {
827+
let _ = graph.insert_evicted_at(txid, mempool_event.latest_update_time);
828+
}
829+
}
830+
831+
let canonical_txids = graph
832+
.graph()
833+
.list_canonical_txs(&chain, chain_tip)
834+
.map(|tx| tx.tx_node.compute_txid())
835+
.collect::<Vec<_>>();
836+
// tx1 should no longer be canonical.
837+
assert!(!canonical_txids.contains(&txid_1));
838+
839+
Ok(())
840+
}

0 commit comments

Comments
 (0)