From 8d77cfc8cf71268249917c41280730303d761790 Mon Sep 17 00:00:00 2001 From: "rearden-grok[bot]" <317016512+rearden-grok[bot]@users.noreply.github.com> Date: Sun, 4 Oct 2026 20:55:11 -0700 Subject: [PATCH 1/4] docs: name tests that only share a fixture A later beat that sees an earlier beat stays one test. Cases that only share a costly chain stay named, on one OnceLock pad, each with a private copy. A test whose body is a call list of other tests is neither. --- TESTING.md | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/TESTING.md b/TESTING.md index d2427b3b6..633c1f01f 100644 --- a/TESTING.md +++ b/TESTING.md @@ -14,9 +14,11 @@ ### True journeys -A journey is one setup and one story. A peer, a client, or an operator does a sequence of things to that same node. Each beat asserts what they would observe: a response, a push, a reject, bytes on disk, a tip after reopen. The story should read like this node meeting the real network or a real client, and it should run as much of that path as the arc needs. One chain, one server, one session, the beats in order. +A journey is one setup and one story. A peer, a client, or an operator does a sequence of things to that same node. Each beat asserts what they would observe: a response, a push, a reject, bytes on disk, a tip after reopen. The story should read like this node meeting the real network or a real client, and it should run as much of that path as the arc needs. One chain, one server, one session, the beats in order. That stays one `#[test]` when a later beat observes an earlier beat: a mined block, a spent coinbase, a grown chain, one mempool that is not reopened. -A `#[test]` whose body only calls other tests is not a journey. Each callee still opens its own store, hub, or socket. The suite gains one name and the same N boots. Delete the callees. The journey writes the asserts. Do not keep the old functions as private bodies the new test calls. +Distinct cases that only share a costly chain are separate `#[test]`s. Build that chain once in a `OnceLock`. Each test takes a private copy, or a fresh mempool on a query it does not modify. The fixture lives until the test binary exits. Deleting it while other tests run races the scheduler. Do not share a mutable hub, clock, or mempool across those tests. + +A `#[test]` whose body only calls other tests is neither shape. If each callee still opens its own store, the suite gains one name and the same N boots. If the callees are independent, give each its own name on the shared fixture. Do not keep the old functions as private bodies the new test calls. If no peer, client, or operator can cause the behavior, delete the behavior in the same change. A small test stays only when the function's return is the consensus or schema result and a session cannot reach it without an absurd chain: pure arithmetic, a codec with no socket, two networks that cannot be the same chain. Say which of those it is, next to the test. A small unit is not a cheaper substitute for a journey the session can already run. A tall chain the rest of the story never builds is a named second chapter on a second setup. Two setups only when the objects cannot be the same. From 690b310f41244d1e792bd6b5ca79a6ec0cea1f11 Mon Sep 17 00:00:00 2001 From: "rearden-grok[bot]" <317016512+rearden-grok[bot]@users.noreply.github.com> Date: Sun, 4 Oct 2026 21:00:23 -0700 Subject: [PATCH 2/4] sv2: name each listener and IBD case on the shared pad Each case already copies the OnceLock regtest pad, so restoring the seven listener names and the two IBD names does not reconnect the chain. The send-buffer pin is keyed by listener address so a parallel accept keeps autotune. --- crates/rbitcoin-sv2/src/lib.rs | 41 +++++++++++----- crates/rbitcoin-sv2/src/listener_tests.rs | 60 ++++++++++++----------- crates/rbitcoin-sv2/src/template_tests.rs | 26 ++++------ crates/rbitcoin-sv2/src/test_chain.rs | 8 --- 4 files changed, 68 insertions(+), 67 deletions(-) diff --git a/crates/rbitcoin-sv2/src/lib.rs b/crates/rbitcoin-sv2/src/lib.rs index 758209eda..570be397d 100644 --- a/crates/rbitcoin-sv2/src/lib.rs +++ b/crates/rbitcoin-sv2/src/lib.rs @@ -186,38 +186,53 @@ pub async fn run_sv2_tp(config: Sv2TpConfig) -> io::Result { }) } -/// Accepted-socket `SO_SNDBUF` for -/// `client_that_stops_reading_is_dropped_at_the_write_deadline`. +/// Accepted-socket `SO_SNDBUF` for one listener. /// /// `write()` returns as soon as the bytes fit in the send buffer. With /// autotune that is several MiB, so a client that never reads still looks -/// like a live writer and the deadline never starts. +/// like a live writer and the deadline never starts. The pin is keyed by +/// the listener address so another test's accept keeps autotune. #[cfg(all(test, target_os = "linux"))] mod test_send_buffer { + use std::collections::HashMap; + use std::net::SocketAddr; use std::os::fd::AsRawFd; - use std::sync::atomic::{AtomicU32, Ordering}; + use std::sync::{LazyLock, Mutex}; use tokio::net::TcpStream; - static BUF: AtomicU32 = AtomicU32::new(0); + static PINS: LazyLock>> = + LazyLock::new(|| Mutex::new(HashMap::new())); - pub(crate) struct Guard; + pub(crate) struct Guard { + addr: SocketAddr, + } - pub(crate) fn pin(bytes: u32) -> Guard { - BUF.store(bytes, Ordering::SeqCst); - Guard + pub(crate) fn pin(addr: SocketAddr, bytes: u32) -> Guard { + PINS.lock() + .unwrap_or_else(|e| e.into_inner()) + .insert(addr, bytes); + Guard { addr } } impl Drop for Guard { fn drop(&mut self) { - BUF.store(0, Ordering::SeqCst); + PINS.lock() + .unwrap_or_else(|e| e.into_inner()) + .remove(&self.addr); } } pub(super) fn apply(stream: &TcpStream) { - let n = BUF.load(Ordering::SeqCst); - if n == 0 { + let Ok(addr) = stream.local_addr() else { return; - } + }; + let n = { + let pins = PINS.lock().unwrap_or_else(|e| e.into_inner()); + let Some(n) = pins.get(&addr).copied() else { + return; + }; + n + }; unsafe extern "C" { fn setsockopt(fd: i32, level: i32, opt: i32, val: *const i32, len: u32) -> i32; fn getsockopt(fd: i32, level: i32, opt: i32, val: *mut i32, len: *mut u32) -> i32; diff --git a/crates/rbitcoin-sv2/src/listener_tests.rs b/crates/rbitcoin-sv2/src/listener_tests.rs index cd4a41725..b204e8b33 100644 --- a/crates/rbitcoin-sv2/src/listener_tests.rs +++ b/crates/rbitcoin-sv2/src/listener_tests.rs @@ -5,7 +5,6 @@ use common_messages_sv2::{ SetupConnectionError, SetupConnectionSuccess, MESSAGE_TYPE_SETUP_CONNECTION_ERROR, MESSAGE_TYPE_SETUP_CONNECTION_SUCCESS, }; -use rbitcoin_net::ChainHub; use std::io; use std::net::SocketAddr; use std::sync::Arc; @@ -25,24 +24,6 @@ async fn connect_when_free(addr: SocketAddr, pk: [u8; 32]) -> TpClient { } } -/// One stale regtest chain. Each beat is its own listener. The two setup -/// deadlines overlap. The write-deadline pin is process-wide, so that beat -/// runs after the others have accepted. -#[tokio::test] -async fn listener_rejects_bad_sessions_on_one_chain() { - let tc = shared_regtest(0); - let chain = Arc::clone(&tc.chain); - tokio::join!( - setup_connection_success_errors_and_session_cap(Arc::clone(&chain)), - authority_key_prints_in_key_utils_base58check(Arc::clone(&chain)), - silent_sockets_are_dropped_at_the_setup_deadline(Arc::clone(&chain)), - session_without_constraints_is_dropped_at_the_setup_deadline(Arc::clone(&chain)), - oversized_client_frame_closes_the_session(Arc::clone(&chain)), - out_of_range_cert_validity_or_stale_grace_refuses_to_start(Arc::clone(&chain)), - ); - client_that_stops_reading_is_dropped_at_the_write_deadline(chain).await; -} - async fn expect_error(c: &mut TpClient, flags: u32, code: &str) { let mut f = c.recv().await.expect("setup reply"); assert_eq!(f.msg_type, MESSAGE_TYPE_SETUP_CONNECTION_ERROR); @@ -56,7 +37,10 @@ async fn expect_error(c: &mut TpClient, flags: u32, code: &str) { ); } -async fn setup_connection_success_errors_and_session_cap(chain: Arc) { +#[tokio::test] +async fn setup_connection_success_errors_and_session_cap() { + let tc = shared_regtest(0); + let chain = Arc::clone(&tc.chain); let tp = run_sv2_tp(Sv2TpConfig { listen: "127.0.0.1:0".parse().unwrap(), chain, @@ -112,7 +96,10 @@ async fn setup_connection_success_errors_and_session_cap(chain: Arc) { /// key-utils 1.2.0 vector: SRI clients configure the TP authority key in /// this form, so the handle must print it, and a client must connect with it. -async fn authority_key_prints_in_key_utils_base58check(chain: Arc) { +#[tokio::test] +async fn authority_key_prints_in_key_utils_base58check() { + let tc = shared_regtest(0); + let chain = Arc::clone(&tc.chain); let secret = bitcoin::base58::decode_check("zmBEmPhqo3A92FkiLVvyCz6htc3e53ph3ZbD4ASqGaLjwnFLi") .expect("vector secret"); let tp = run_sv2_tp(Sv2TpConfig { @@ -143,7 +130,10 @@ async fn authority_key_prints_in_key_utils_base58check(chain: Arc) { /// Silent sockets take every slot at accept; the setup deadline must close /// them so a real client gets in. -async fn silent_sockets_are_dropped_at_the_setup_deadline(chain: Arc) { +#[tokio::test] +async fn silent_sockets_are_dropped_at_the_setup_deadline() { + let tc = shared_regtest(0); + let chain = Arc::clone(&tc.chain); use tokio::io::AsyncReadExt; let setup_timeout = Duration::from_millis(300); @@ -183,7 +173,10 @@ async fn silent_sockets_are_dropped_at_the_setup_deadline(chain: Arc) /// A TDP session without `CoinbaseOutputConstraints` never gets a template /// and never writes, so the setup deadline also covers the first constraints. /// Other frames before them do not reset it. -async fn session_without_constraints_is_dropped_at_the_setup_deadline(chain: Arc) { +#[tokio::test] +async fn session_without_constraints_is_dropped_at_the_setup_deadline() { + let tc = shared_regtest(0); + let chain = Arc::clone(&tc.chain); let setup_timeout = Duration::from_millis(300); let tp = run_sv2_tp(Sv2TpConfig { listen: "127.0.0.1:0".parse().unwrap(), @@ -235,11 +228,11 @@ async fn session_without_constraints_is_dropped_at_the_setup_deadline(chain: Arc /// A client that floods requests and never reads jams the TP's writes; the /// write deadline must close the session instead of stalling it forever. -async fn client_that_stops_reading_is_dropped_at_the_write_deadline(chain: Arc) { +#[tokio::test] +async fn client_that_stops_reading_is_dropped_at_the_write_deadline() { + let tc = shared_regtest(0); + let chain = Arc::clone(&tc.chain); let write_timeout = Duration::from_millis(200); - // Pin before accept. Drop clears it so other tests keep autotune. - #[cfg(target_os = "linux")] - let _send_buf = crate::test_send_buffer::pin(8 * 1024); let tp = run_sv2_tp(Sv2TpConfig { listen: "127.0.0.1:0".parse().unwrap(), chain, @@ -251,6 +244,9 @@ async fn client_that_stops_reading_is_dropped_at_the_write_deadline(chain: Arc) { +#[tokio::test] +async fn oversized_client_frame_closes_the_session() { + let tc = shared_regtest(0); + let chain = Arc::clone(&tc.chain); let tp = run_sv2_tp(Sv2TpConfig { listen: "127.0.0.1:0".parse().unwrap(), chain, @@ -332,7 +331,10 @@ async fn oversized_client_frame_closes_the_session(chain: Arc) { tp.shutdown().await; } -async fn out_of_range_cert_validity_or_stale_grace_refuses_to_start(chain: Arc) { +#[tokio::test] +async fn out_of_range_cert_validity_or_stale_grace_refuses_to_start() { + let tc = shared_regtest(0); + let chain = Arc::clone(&tc.chain); for (cert_validity, stale_grace) in [ (Duration::from_secs(u64::from(u32::MAX) + 1), Duration::ZERO), ( diff --git a/crates/rbitcoin-sv2/src/template_tests.rs b/crates/rbitcoin-sv2/src/template_tests.rs index 37bc7e4d4..1eef6fc87 100644 --- a/crates/rbitcoin-sv2/src/template_tests.rs +++ b/crates/rbitcoin-sv2/src/template_tests.rs @@ -1,4 +1,4 @@ -use crate::test_chain::{copy_chain, padded_chain_with, shared_regtest, TestChain}; +use crate::test_chain::{padded_chain_with, shared_regtest, TestChain}; use crate::testutil::TpClient; use crate::{run_sv2_tp, Sv2TpConfig, SETUP_TIMEOUT, WRITE_TIMEOUT}; use bitcoin::consensus::encode::serialize; @@ -30,18 +30,6 @@ fn mock_live_tip(tc: &TestChain) { tc.chain.clock.set_mock(i64::from(tip_time)); } -/// Stale tip, two hubs from one pad: an empty pool and a heavy pool. Each -/// leaves IBD on its own, so the latch cannot be shared. -#[tokio::test(flavor = "multi_thread")] -async fn ibd_holds_the_last_budget_and_an_empty_template() { - let heavy = shared_regtest(1); - let empty = copy_chain(&heavy, "sv2-ibd-empty"); - tokio::join!( - sync_gate_holds_constraints_until_a_fresh_tip(&empty), - constraints_while_ibd_keep_the_last_budget(&heavy), - ); -} - fn spend(coinbase: Txid, fee: u64, script_pubkey: ScriptBuf) -> Transaction { Transaction { version: Version::TWO, @@ -311,7 +299,9 @@ async fn template_constraint_rebuilds_are_rate_limited() { tp.shutdown().await; } -async fn sync_gate_holds_constraints_until_a_fresh_tip(tc: &TestChain) { +#[tokio::test(flavor = "multi_thread")] +async fn sync_gate_holds_constraints_until_a_fresh_tip() { + let tc = shared_regtest(1); let tp = run_sv2_tp(Sv2TpConfig { listen: "127.0.0.1:0".parse().unwrap(), chain: Arc::clone(&tc.chain), @@ -346,7 +336,7 @@ async fn sync_gate_holds_constraints_until_a_fresh_tip(tc: &TestChain) { .expect("template after the fresh tip") .expect("message") }; - check_template(&mut c, tc, first, &[], true).await; + check_template(&mut c, &tc, first, &[], true).await; tp.shutdown().await; } @@ -354,7 +344,9 @@ async fn sync_gate_holds_constraints_until_a_fresh_tip(tc: &TestChain) { /// A constraints flood while the node is in IBD must not close the session. /// Nothing is queued to build, so the post-template 8-replacement close does /// not apply. The tip that leaves IBD builds the last budget. -async fn constraints_while_ibd_keep_the_last_budget(tc: &TestChain) { +#[tokio::test(flavor = "multi_thread")] +async fn constraints_while_ibd_keep_the_last_budget() { + let tc = shared_regtest(1); // 16_000 sigop cost: excluded under a u16::MAX client reserve. let heavy = spend( tc.coinbases[0], @@ -404,7 +396,7 @@ async fn constraints_while_ibd_keep_the_last_budget(tc: &TestChain) { .expect("template after the fresh tip") .expect("message") }; - check_template(&mut c, tc, first, &[], true).await; + check_template(&mut c, &tc, first, &[], true).await; let extra = tokio::time::timeout(Duration::from_millis(500), c.recv()).await; assert!(extra.is_err(), "one template for the last budget"); diff --git a/crates/rbitcoin-sv2/src/test_chain.rs b/crates/rbitcoin-sv2/src/test_chain.rs index c151c112a..352323311 100644 --- a/crates/rbitcoin-sv2/src/test_chain.rs +++ b/crates/rbitcoin-sv2/src/test_chain.rs @@ -71,14 +71,6 @@ pub(crate) fn shared_regtest(spendable: u32) -> TestChain { copy_quiescent(src, "sv2-shared") } -/// Second hub on a copy of `src`'s store. The mempool is empty either way. -/// `ChainHub::in_ibd` latches per hub, so a chapter that leaves IBD cannot -/// share the hub with a chapter that must start stale. -pub(crate) fn copy_chain(src: &TestChain, label: &str) -> TestChain { - src.chain.query.flush().expect("flush before copy"); - copy_quiescent(src, label) -} - fn copy_quiescent(src: &TestChain, label: &str) -> TestChain { let dir = TempDir::labeled(label).expect("temp"); copy_tree_except(src._dir.path(), dir.path(), "mempool"); From 5cfcf41d589800f6f997b0f1571add39c2f4f5f7 Mon Sep 17 00:00:00 2001 From: "rearden-grok[bot]" <317016512+rearden-grok[bot]@users.noreply.github.com> Date: Sun, 4 Oct 2026 21:07:27 -0700 Subject: [PATCH 3/4] test: name tx_relay cases on one maturity pad The 3-coinbase and 12-coinbase connects stay process fixtures. Each mempool case is its own test on a private pool. The coinbase connect copies the store so it does not move the shared tip. --- crates/rbitcoin-net/src/tx_relay.rs | 1161 +++++++++++++++------------ 1 file changed, 627 insertions(+), 534 deletions(-) diff --git a/crates/rbitcoin-net/src/tx_relay.rs b/crates/rbitcoin-net/src/tx_relay.rs index e4b23f0fd..7499597e1 100644 --- a/crates/rbitcoin-net/src/tx_relay.rs +++ b/crates/rbitcoin-net/src/tx_relay.rs @@ -3803,7 +3803,11 @@ mod tests { let _ = std::fs::remove_dir_all(&mdir); } - fn expire_stale_drops_old_tx_without_a_new_accept(q: &Arc, cbs: &[Txid]) { + #[test] + fn expire_stale_drops_old_tx_without_a_new_accept() { + let (_store, owned_q, owned_cbs) = pad_cbs(3); + let q = &owned_q; + let cbs = owned_cbs.as_slice(); let dir = tmp(); let hub = MempoolHub::open(&dir, Arc::clone(q)).unwrap(); hub.set_relay_enabled(true); @@ -3856,7 +3860,11 @@ mod tests { let _ = std::fs::remove_dir_all(&store_dir); } - fn try_expire_stale_skips_when_the_order_lock_is_held(q: &Arc, cbs: &[Txid]) { + #[test] + fn try_expire_stale_skips_when_the_order_lock_is_held() { + let (_store, owned_q, owned_cbs) = pad_cbs(3); + let q = &owned_q; + let cbs = owned_cbs.as_slice(); let dir = tmp(); let hub = MempoolHub::open(&dir, Arc::clone(q)).unwrap(); hub.set_relay_enabled(true); @@ -3991,532 +3999,539 @@ mod tests { assert!(idx.txs_for(&[3u8; 32]).next().is_none()); } - /// One 12-coinbase pad covers reorg-reaccept, unbroadcast persist and the - /// confirm-before-broadcast log, local-origin isolation, SH reopen, live - /// accept/fee/package, recent accepts and rejects, 1p1c admit and - /// rollback, unknown-SH delta, accept-stage meters, expiry, and a - /// 16_004-sigop admit over Core's standard cap. - #[allow(clippy::cognitive_complexity)] // one fixture, many mempool journey arms #[test] - fn hub_live_journey() { - use rbitcoin_consensus::{accept_and_connect_block, ChainParams, Milestone}; - use rbitcoin_primitives::Height; - use rbitcoin_store::script_hash; - - let store_dir = tmp(); - let q = Query::open_or_create_tiny(&store_dir).unwrap(); - let params = ChainParams::regtest(); - let genesis = bitcoin::blockdata::constants::genesis_block(bitcoin::Network::Regtest); - accept_and_connect_block(&q, ¶ms, Height::GENESIS, &genesis, Milestone::NONE).unwrap(); - const N_CB: u32 = 12; - let (_tip, _tip_time, cbs) = rbitcoin_consensus::pad_empty_from( - &q, - ¶ms, - genesis.block_hash(), - genesis.header.time, - 1, - 100 + N_CB, - N_CB, + fn reorg_reaccept_then_unindex_drops_relay() { + let (_store, q, cbs) = pad_cbs(12); + let spk = ScriptBuf::from_bytes(vec![0x51]); + let mp = tmp(); + let hub = MempoolHub::open(&mp, Arc::clone(&q)).unwrap(); + hub.set_relay_enabled(true); + let tx = spend_true(cbs[0], 1_000, spk.clone()); + assert!(!hub.is_reorg_servable(&tx.compute_wtxid())); + assert_eq!(hub.reorg_reaccept(std::slice::from_ref(&tx)), 1); + let w = tx.compute_wtxid(); + assert!(hub.is_reorg_servable(&w)); + assert!(hub.get_tx_by_wtxid(&w).is_some()); + assert!(hub.remove_for_block(&[tx.compute_txid()]) >= 1); + assert!(hub.get_tx_by_wtxid(&w).is_none()); + assert!( + !hub.is_relay_servable(&w, u64::MAX), + "unindex must drop relay maps with the live graph entry" ); - let q = Arc::new(q); + let _ = std::fs::remove_dir_all(&mp); + } + + #[test] + fn unbroadcast_survives_reopen_and_logs_confirm_before_broadcast() { + use rbitcoin_store::script_hash; + let (_store, q, cbs) = pad_cbs(12); let spk = ScriptBuf::from_bytes(vec![0x51]); let sh = script_hash(spk.as_bytes()); + let mp = tmp(); + let hub = MempoolHub::open(&mp, Arc::clone(&q)).unwrap(); + hub.set_relay_enabled(true); + let tx = spend_true(cbs[1], 1_000, spk.clone()); + hub.accept_tx(&tx).expect("accept"); + let extra = Transaction { + version: Version::TWO, + lock_time: LockTime::ZERO, + input: vec![], + output: vec![], + }; + let (pres, skip) = hub.tip_script_pres(&[tx.clone(), extra]); + let live_id = tx.compute_txid().to_byte_array(); + assert_eq!(skip.len(), 1); + assert!(skip.contains(&live_id)); + assert!( + pres[0].sha_prevouts.is_none(), + "live graph tx must skip sighash midstates" + ); + assert!( + pres[1].sha_prevouts.is_some(), + "non-live must fill midstates after connect ids" + ); + let wire = spend_true(cbs[8], 1_000, spk.clone()); + hub.accept_tx(&wire).expect("peer tx"); + hub.mark_local_origin(tx.compute_txid()); + hub.note_unbroadcast(tx.compute_txid()); + assert_eq!(hub.unbroadcast_count(), 1); + assert!(hub.is_local_origin(&tx.compute_txid())); + assert!(!hub.is_local_origin(&wire.compute_txid())); + assert!( + !hub.skip_standing_inv(&tx.compute_txid()), + "without --proxy/--onion, local-origin still uses standing INV" + ); + hub.set_isolated_broadcast(true); + assert!(hub.skip_standing_inv(&tx.compute_txid())); + assert!(!hub.skip_standing_inv(&wire.compute_txid())); + hub.flush().expect("shutdown flush"); + drop(hub); + let hub2 = MempoolHub::open(&mp, Arc::clone(&q)).unwrap(); + let reopen = hub2.sample_reset_perf(); + assert_eq!( + reopen.get_coin, 0, + "SH reindex must use stored vin aux, not get_txout" + ); + assert!( + !hub2.scripthash_mempool(&sh).is_empty(), + "reopen SH index from stored hashes" + ); + hub2.set_relay_enabled(true); + assert_eq!(hub2.unbroadcast_count(), 1); + let mut rx = hub2.subscribe_announces(); + hub2.rebroadcast_unbroadcast(); + let got = rx.try_recv().expect("mockscheduler rebroadcast"); + assert_eq!(got.txid, tx.compute_txid()); + rbitcoin_log::capture_logs(true); + assert_eq!(hub2.remove_for_block(&[tx.compute_txid()]), 1); + let logs = rbitcoin_log::take_logs(); + rbitcoin_log::capture_logs(false); + let needle = format!( + "p2p: Removed {} from set of unbroadcast txns before confirmation that txn was sent out", + tx.compute_txid() + ); + assert!(logs.iter().any(|(_, m)| *m == needle), "{logs:?}"); + assert_eq!(hub2.unbroadcast_count(), 0); + let _ = std::fs::remove_dir_all(&mp); + } - { - let mp = tmp(); - let hub = MempoolHub::open(&mp, Arc::clone(&q)).unwrap(); - hub.set_relay_enabled(true); - let tx = spend_true(cbs[0], 1_000, spk.clone()); - assert!(!hub.is_reorg_servable(&tx.compute_wtxid())); - assert_eq!(hub.reorg_reaccept(std::slice::from_ref(&tx)), 1); - let w = tx.compute_wtxid(); - assert!(hub.is_reorg_servable(&w)); - assert!(hub.get_tx_by_wtxid(&w).is_some()); - assert!(hub.remove_for_block(&[tx.compute_txid()]) >= 1); - assert!(hub.get_tx_by_wtxid(&w).is_none()); - assert!( - !hub.is_relay_servable(&w, u64::MAX), - "unindex must drop relay maps with the live graph entry" - ); - let _ = std::fs::remove_dir_all(&mp); - } - - { - let mp = tmp(); - let hub = MempoolHub::open(&mp, Arc::clone(&q)).unwrap(); - hub.set_relay_enabled(true); - let tx = spend_true(cbs[1], 1_000, spk.clone()); - hub.accept_tx(&tx).expect("accept"); - let extra = Transaction { - version: Version::TWO, - lock_time: LockTime::ZERO, - input: vec![], - output: vec![], - }; - let (pres, skip) = hub.tip_script_pres(&[tx.clone(), extra]); - let live_id = tx.compute_txid().to_byte_array(); - assert_eq!(skip.len(), 1); - assert!(skip.contains(&live_id)); - assert!( - pres[0].sha_prevouts.is_none(), - "live graph tx must skip sighash midstates" - ); - assert!( - pres[1].sha_prevouts.is_some(), - "non-live must fill midstates after connect ids" - ); - let wire = spend_true(cbs[8], 1_000, spk.clone()); - hub.accept_tx(&wire).expect("peer tx"); - hub.mark_local_origin(tx.compute_txid()); - hub.note_unbroadcast(tx.compute_txid()); - assert_eq!(hub.unbroadcast_count(), 1); - assert!(hub.is_local_origin(&tx.compute_txid())); - assert!(!hub.is_local_origin(&wire.compute_txid())); - assert!( - !hub.skip_standing_inv(&tx.compute_txid()), - "without --proxy/--onion, local-origin still uses standing INV" - ); - hub.set_isolated_broadcast(true); - assert!(hub.skip_standing_inv(&tx.compute_txid())); - assert!(!hub.skip_standing_inv(&wire.compute_txid())); - hub.flush().expect("shutdown flush"); - drop(hub); - let hub2 = MempoolHub::open(&mp, Arc::clone(&q)).unwrap(); - let reopen = hub2.sample_reset_perf(); - assert_eq!( - reopen.get_coin, 0, - "SH reindex must use stored vin aux, not get_txout" - ); - assert!( - !hub2.scripthash_mempool(&sh).is_empty(), - "reopen SH index from stored hashes" - ); - hub2.set_relay_enabled(true); - assert_eq!(hub2.unbroadcast_count(), 1); - let mut rx = hub2.subscribe_announces(); - hub2.rebroadcast_unbroadcast(); - let got = rx.try_recv().expect("mockscheduler rebroadcast"); - assert_eq!(got.txid, tx.compute_txid()); - rbitcoin_log::capture_logs(true); - assert_eq!(hub2.remove_for_block(&[tx.compute_txid()]), 1); - let logs = rbitcoin_log::take_logs(); - rbitcoin_log::capture_logs(false); - let needle = format!( - "p2p: Removed {} from set of unbroadcast txns before confirmation that txn was sent out", - tx.compute_txid() - ); - assert!(logs.iter().any(|(_, m)| *m == needle), "{logs:?}"); - assert_eq!(hub2.unbroadcast_count(), 0); - let _ = std::fs::remove_dir_all(&mp); - } + #[test] + fn scripthash_reopen_drops_parent_then_child() { + use rbitcoin_store::script_hash; + let (_store, q, cbs) = pad_cbs(12); + let spk = ScriptBuf::from_bytes(vec![0x51]); + let sh = script_hash(spk.as_bytes()); + let mp = tmp(); + let hub = MempoolHub::open(&mp, Arc::clone(&q)).unwrap(); + hub.set_relay_enabled(true); + let mut rx = hub.subscribe_announces(); + let parent = spend_true(cbs[2], 1_000, spk.clone()); + hub.accept_tx(&parent).expect("accept"); + let ann = rx.try_recv().expect("announce"); + assert!(ann.scripthashes.contains(&sh)); + let child = Transaction { + version: bitcoin::transaction::Version::TWO, + lock_time: LockTime::ZERO, + input: vec![TxIn { + previous_output: OutPoint { + txid: parent.compute_txid(), + vout: 0, + }, + script_sig: ScriptBuf::new(), + sequence: Sequence::ENABLE_RBF_NO_LOCKTIME, + witness: Witness::new(), + }], + output: vec![TxOut { + value: Amount::from_sat(49_9998_0000), + script_pubkey: ScriptBuf::from_bytes(vec![0x51]), + }], + }; + hub.accept_tx(&child).expect("child"); + assert!(hub.scripthash_mempool(&sh).len() >= 2); + hub.flush().unwrap(); + drop(hub); + let hub = MempoolHub::open(&mp, Arc::clone(&q)).unwrap(); + hub.set_relay_enabled(true); + assert!(hub.scripthash_mempool(&sh).len() >= 2); + assert!(hub.remove_for_block(&[parent.compute_txid()]) >= 1); + assert!(!hub.scripthash_mempool(&sh).is_empty()); + assert!(hub.remove_for_block(&[child.compute_txid()]) >= 1); + assert!(hub.scripthash_mempool(&sh).is_empty()); + let _ = std::fs::remove_dir_all(&mp); + } - { - let mp = tmp(); - let hub = MempoolHub::open(&mp, Arc::clone(&q)).unwrap(); - hub.set_relay_enabled(true); - let mut rx = hub.subscribe_announces(); - let parent = spend_true(cbs[2], 1_000, spk.clone()); - hub.accept_tx(&parent).expect("accept"); - let ann = rx.try_recv().expect("announce"); - assert!(ann.scripthashes.contains(&sh)); - let child = Transaction { - version: bitcoin::transaction::Version::TWO, - lock_time: LockTime::ZERO, - input: vec![TxIn { - previous_output: OutPoint { - txid: parent.compute_txid(), - vout: 0, - }, - script_sig: ScriptBuf::new(), - sequence: Sequence::ENABLE_RBF_NO_LOCKTIME, - witness: Witness::new(), - }], - output: vec![TxOut { - value: Amount::from_sat(49_9998_0000), - script_pubkey: ScriptBuf::from_bytes(vec![0x51]), - }], - }; - hub.accept_tx(&child).expect("child"); - assert!(hub.scripthash_mempool(&sh).len() >= 2); - hub.flush().unwrap(); - drop(hub); - let hub = MempoolHub::open(&mp, Arc::clone(&q)).unwrap(); - hub.set_relay_enabled(true); - assert!(hub.scripthash_mempool(&sh).len() >= 2); - assert!(hub.remove_for_block(&[parent.compute_txid()]) >= 1); - assert!(!hub.scripthash_mempool(&sh).is_empty()); - assert!(hub.remove_for_block(&[child.compute_txid()]) >= 1); - assert!(hub.scripthash_mempool(&sh).is_empty()); - let _ = std::fs::remove_dir_all(&mp); - } + #[allow(clippy::cognitive_complexity)] // one mempool session: later beats see earlier accepts + #[test] + fn live_pool_recent_accepts_and_1p1c_rollback() { + use rbitcoin_store::script_hash; + let (_store, q, cbs) = pad_cbs(12); + let spk = ScriptBuf::from_bytes(vec![0x51]); + let sh = script_hash(spk.as_bytes()); + let mp = tmp(); + let hub = MempoolHub::open(&mp, Arc::clone(&q)).unwrap(); + hub.set_relay_enabled(true); + let mut ann_rx = hub.subscribe_announces(); + let provider = QueryUtxoProvider::new(q.as_ref()); + let op0 = OutPoint { + txid: cbs[3], + vout: 0, + }; + assert!(provider.get_txout(&op0).is_some()); + let cheap = spend_true(cbs[9], 1, spk.clone()); + assert!(matches!( + hub.accept_tx(&cheap), + Err(AcceptError::Policy("min relay fee")) + )); + assert!( + !hub.try_recent_reject(&cheap.compute_wtxid()), + "min-relay is reconsiderable; must not skip a later ATMP" + ); + let coinbase = Transaction { + version: Version::ONE, + lock_time: LockTime::ZERO, + input: vec![TxIn { + previous_output: OutPoint::null(), + script_sig: ScriptBuf::from_bytes(vec![0x00, 0x01]), + sequence: Sequence::MAX, + witness: Witness::new(), + }], + output: vec![TxOut { + value: Amount::from_sat(50), + script_pubkey: spk.clone(), + }], + }; + assert!(matches!( + hub.accept_tx(&coinbase), + Err(AcceptError::Coinbase) + )); + assert!(hub.try_recent_reject(&coinbase.compute_wtxid())); + hub.note_recent_confirmed(&[]); + assert!( + !hub.try_recent_reject(&coinbase.compute_wtxid()), + "tip connect must forget recent_rejects" + ); + assert!(hub.recent_accepts().is_empty()); + let parent = spend_true(cbs[3], 1_000, spk.clone()); + let pr = hub.accept_tx(&parent).expect("accept parent"); + assert_eq!(pr.txid, parent.compute_txid()); + assert!(ann_rx.try_recv().is_ok()); + let recent = hub.recent_accepts(); + assert_eq!(recent.len(), 1); + assert_eq!(recent[0].txid, parent.compute_txid()); + assert_eq!(recent[0].fee_sat, 1_000); + let child = Transaction { + version: bitcoin::transaction::Version::TWO, + lock_time: LockTime::ZERO, + input: vec![TxIn { + previous_output: OutPoint { + txid: parent.compute_txid(), + vout: 0, + }, + script_sig: ScriptBuf::new(), + sequence: Sequence::ENABLE_RBF_NO_LOCKTIME, + witness: Witness::new(), + }], + output: vec![TxOut { + value: Amount::from_sat(50_0000_0000 - 2_000), + script_pubkey: spk.clone(), + }], + }; + let second = spend_true(cbs[4], 5_000, ScriptBuf::from_bytes(vec![0x52])); + let second_id = second.compute_txid(); + hub.accept_tx(&child).expect("child"); + let pkg = hub.accept_package(&[second]).expect("package"); + assert_eq!(pkg.len(), 1); + assert_eq!(hub.live_count(), 3); + let newest_first: Vec = hub.recent_accepts().iter().map(|r| r.txid).collect(); + assert_eq!( + newest_first, + [second_id, child.compute_txid(), parent.compute_txid()] + ); + assert!(hub.contains(&parent.compute_txid())); + let wtxid = parent.compute_wtxid(); + assert!(hub.contains_wtxid(&wtxid)); + assert!(hub.get_tx_by_wtxid(&wtxid).is_some()); + assert!(!hub.fee_histogram().is_empty()); + let e1 = hub.estimate_fee_btc_per_kb(1); + let e5 = hub.estimate_fee_btc_per_kb(5); + let e144 = hub.estimate_fee_btc_per_kb(144); + // Flow is cold on a fresh hub and the chain has no fee history: + // a thin live pool alone does not set a guess. + assert!( + e1 < 0.0 && e5 < 0.0 && e144 < 0.0, + "cold flow without history: e1={e1} e5={e5} e144={e144}" + ); + let spent = hub.spent_outpoints(); + assert!(spent.contains(&op0)); + let rows = hub.scripthash_mempool(&sh); + assert!(rows.len() >= 2); + assert!(rows.iter().any(|r| r.height == -1)); + let delta = hub.scripthash_unconfirmed_delta(&sh).unwrap(); + assert_eq!(delta, 50_0000_0000 - 2_000 - 50_0000_0000 - 50_0000_0000); + assert!(hub.is_relay_servable(&wtxid, hub.current_relay_seq())); + assert!(hub.remove_for_block(&[parent.compute_txid()]) >= 1); + hub.mark_fee_dirty(); + let e1b = hub.estimate_fee_btc_per_kb(1); + let e144b = hub.estimate_fee_btc_per_kb(144); + assert!( + e1b < 0.0 && e144b < 0.0, + "a confirm does not warm flow or add history: e1={e1b} e144={e144b}" + ); + assert!( + !hub.contains_wtxid(&wtxid), + "wtxid index must drop with the live entry" + ); + assert!(hub.get_tx_by_wtxid(&wtxid).is_none()); + assert!( + !hub.is_relay_servable(&wtxid, u64::MAX), + "unindex must drop relay maps with the live graph entry" + ); + assert!(hub.list_live().len() < 3); - { - let mp = tmp(); - let hub = MempoolHub::open(&mp, Arc::clone(&q)).unwrap(); - hub.set_relay_enabled(true); - let mut ann_rx = hub.subscribe_announces(); - let provider = QueryUtxoProvider::new(q.as_ref()); - let op0 = OutPoint { - txid: cbs[3], - vout: 0, - }; - assert!(provider.get_txout(&op0).is_some()); - let cheap = spend_true(cbs[9], 1, spk.clone()); - assert!(matches!( - hub.accept_tx(&cheap), - Err(AcceptError::Policy("min relay fee")) - )); - assert!( - !hub.try_recent_reject(&cheap.compute_wtxid()), - "min-relay is reconsiderable; must not skip a later ATMP" - ); - let coinbase = Transaction { - version: Version::ONE, - lock_time: LockTime::ZERO, - input: vec![TxIn { - previous_output: OutPoint::null(), - script_sig: ScriptBuf::from_bytes(vec![0x00, 0x01]), - sequence: Sequence::MAX, - witness: Witness::new(), - }], - output: vec![TxOut { - value: Amount::from_sat(50), + hub.set_min_relay_sat_kvb(50_000); + let lp = Transaction { + output: vec![ + TxOut { + value: Amount::from_sat(25_0000_0000), script_pubkey: spk.clone(), - }], - }; - assert!(matches!( - hub.accept_tx(&coinbase), - Err(AcceptError::Coinbase) - )); - assert!(hub.try_recent_reject(&coinbase.compute_wtxid())); - hub.note_recent_confirmed(&[]); - assert!( - !hub.try_recent_reject(&coinbase.compute_wtxid()), - "tip connect must forget recent_rejects" - ); - assert!(hub.recent_accepts().is_empty()); - let parent = spend_true(cbs[3], 1_000, spk.clone()); - let pr = hub.accept_tx(&parent).expect("accept parent"); - assert_eq!(pr.txid, parent.compute_txid()); - assert!(ann_rx.try_recv().is_ok()); - let recent = hub.recent_accepts(); - assert_eq!(recent.len(), 1); - assert_eq!(recent[0].txid, parent.compute_txid()); - assert_eq!(recent[0].fee_sat, 1_000); - let child = Transaction { - version: bitcoin::transaction::Version::TWO, - lock_time: LockTime::ZERO, - input: vec![TxIn { - previous_output: OutPoint { - txid: parent.compute_txid(), - vout: 0, - }, - script_sig: ScriptBuf::new(), - sequence: Sequence::ENABLE_RBF_NO_LOCKTIME, - witness: Witness::new(), - }], - output: vec![TxOut { - value: Amount::from_sat(50_0000_0000 - 2_000), + }, + TxOut { + value: Amount::from_sat(25_0000_0000 - 200), script_pubkey: spk.clone(), - }], - }; - let second = spend_true(cbs[4], 5_000, ScriptBuf::from_bytes(vec![0x52])); - let second_id = second.compute_txid(); - hub.accept_tx(&child).expect("child"); - let pkg = hub.accept_package(&[second]).expect("package"); - assert_eq!(pkg.len(), 1); - assert_eq!(hub.live_count(), 3); - let newest_first: Vec = hub.recent_accepts().iter().map(|r| r.txid).collect(); - assert_eq!( - newest_first, - [second_id, child.compute_txid(), parent.compute_txid()] - ); - assert!(hub.contains(&parent.compute_txid())); - let wtxid = parent.compute_wtxid(); - assert!(hub.contains_wtxid(&wtxid)); - assert!(hub.get_tx_by_wtxid(&wtxid).is_some()); - assert!(!hub.fee_histogram().is_empty()); - let e1 = hub.estimate_fee_btc_per_kb(1); - let e5 = hub.estimate_fee_btc_per_kb(5); - let e144 = hub.estimate_fee_btc_per_kb(144); - // Flow is cold on a fresh hub and the chain has no fee history: - // a thin live pool alone does not set a guess. - assert!( - e1 < 0.0 && e5 < 0.0 && e144 < 0.0, - "cold flow without history: e1={e1} e5={e5} e144={e144}" - ); - let spent = hub.spent_outpoints(); - assert!(spent.contains(&op0)); - let rows = hub.scripthash_mempool(&sh); - assert!(rows.len() >= 2); - assert!(rows.iter().any(|r| r.height == -1)); - let delta = hub.scripthash_unconfirmed_delta(&sh).unwrap(); - assert_eq!(delta, 50_0000_0000 - 2_000 - 50_0000_0000 - 50_0000_0000); - assert!(hub.is_relay_servable(&wtxid, hub.current_relay_seq())); - assert!(hub.remove_for_block(&[parent.compute_txid()]) >= 1); - hub.mark_fee_dirty(); - let e1b = hub.estimate_fee_btc_per_kb(1); - let e144b = hub.estimate_fee_btc_per_kb(144); - assert!( - e1b < 0.0 && e144b < 0.0, - "a confirm does not warm flow or add history: e1={e1b} e144={e144b}" - ); - assert!( - !hub.contains_wtxid(&wtxid), - "wtxid index must drop with the live entry" - ); - assert!(hub.get_tx_by_wtxid(&wtxid).is_none()); - assert!( - !hub.is_relay_servable(&wtxid, u64::MAX), - "unindex must drop relay maps with the live graph entry" - ); - assert!(hub.list_live().len() < 3); - - hub.set_min_relay_sat_kvb(50_000); - let lp = Transaction { - output: vec![ - TxOut { - value: Amount::from_sat(25_0000_0000), - script_pubkey: spk.clone(), - }, - TxOut { - value: Amount::from_sat(25_0000_0000 - 200), - script_pubkey: spk.clone(), - }, - ], - ..spend_true(cbs[9], 0, spk.clone()) - }; - let lpid = lp.compute_txid(); - assert!(matches!( - hub.accept_tx(&lp), - Err(AcceptError::Policy("min relay fee")) - )); - let one_sat = spend_vout(OutPoint::new(lpid, 0), 25_0000_0000 - 1); - assert!(matches!( - hub.accept_tx(&one_sat), - Err(AcceptError::Orphaned { .. }) - )); - assert_eq!(hub.orphan_count(), 1); - let payer = spend_vout(OutPoint::new(lpid, 1), 1_000); - hub.accept_tx(&payer) - .expect("hub 1p1c must admit parent+child"); - assert!(hub.contains(&lpid)); - assert!(hub.contains(&payer.compute_txid())); - assert!( - !hub.contains(&one_sat.compute_txid()), - "1-sat sibling must not ride 1p1c promote at floor 0" - ); - assert_eq!(hub.orphan_count(), 0); - let sib = spend_vout(OutPoint::new(lpid, 0), 25_0000_0000 - 10_000); - let (sib_id, sib_w) = (sib.compute_txid(), sib.compute_wtxid()); - hub.accept_tx(&sib).expect("paying sibling of live parent"); - assert!(hub.relay_seq_of(&sib_w).is_some()); - assert!(hub.accept_time_txid(&sib_id).is_some()); - let tmpl = hub.template_updates(); - hub.rollback_1p1c_parent(&lpid); - assert!(!hub.contains(&lpid)); - assert!(!hub.contains(&sib_id)); - assert!( - hub.relay_seq_of(&sib_w).is_none(), - "published spender must leave wtxid/relay maps" - ); - assert!(hub.accept_time_txid(&sib_id).is_none()); - assert!( - hub.template_updates() > tmpl, - "template must bump like remove_for_block_spent" - ); - let _ = std::fs::remove_dir_all(&mp); - } + }, + ], + ..spend_true(cbs[9], 0, spk.clone()) + }; + let lpid = lp.compute_txid(); + assert!(matches!( + hub.accept_tx(&lp), + Err(AcceptError::Policy("min relay fee")) + )); + let one_sat = spend_vout(OutPoint::new(lpid, 0), 25_0000_0000 - 1); + assert!(matches!( + hub.accept_tx(&one_sat), + Err(AcceptError::Orphaned { .. }) + )); + assert_eq!(hub.orphan_count(), 1); + let payer = spend_vout(OutPoint::new(lpid, 1), 1_000); + hub.accept_tx(&payer) + .expect("hub 1p1c must admit parent+child"); + assert!(hub.contains(&lpid)); + assert!(hub.contains(&payer.compute_txid())); + assert!( + !hub.contains(&one_sat.compute_txid()), + "1-sat sibling must not ride 1p1c promote at floor 0" + ); + assert_eq!(hub.orphan_count(), 0); + let sib = spend_vout(OutPoint::new(lpid, 0), 25_0000_0000 - 10_000); + let (sib_id, sib_w) = (sib.compute_txid(), sib.compute_wtxid()); + hub.accept_tx(&sib).expect("paying sibling of live parent"); + assert!(hub.relay_seq_of(&sib_w).is_some()); + assert!(hub.accept_time_txid(&sib_id).is_some()); + let tmpl = hub.template_updates(); + hub.rollback_1p1c_parent(&lpid); + assert!(!hub.contains(&lpid)); + assert!(!hub.contains(&sib_id)); + assert!( + hub.relay_seq_of(&sib_w).is_none(), + "published spender must leave wtxid/relay maps" + ); + assert!(hub.accept_time_txid(&sib_id).is_none()); + assert!( + hub.template_updates() > tmpl, + "template must bump like remove_for_block_spent" + ); + let _ = std::fs::remove_dir_all(&mp); + } + #[test] + fn accept_stage_meters_then_expiry_drops_stale_child() { + use rbitcoin_store::script_hash; + let (_store, q, cbs) = pad_cbs(12); + let spk = ScriptBuf::from_bytes(vec![0x51]); + let sh = script_hash(spk.as_bytes()); + let mp = tmp(); + let hub = MempoolHub::open(&mp, Arc::clone(&q)).unwrap(); + hub.set_relay_enabled(true); + let _ = hub.sample_reset_perf(); + let mut fee_sum = 0i64; + let mut spends = Vec::new(); + for (i, cbtxid) in cbs[5..8].iter().enumerate() { + let fee = 1_000u64 + i as u64; + fee_sum += fee as i64; + let tx = spend_true(*cbtxid, fee, spk.clone()); + hub.accept_tx(&tx).expect("accept spend"); + spends.push(tx.compute_txid()); + } + let n = 3u64; + let s = hub.sample_reset_perf(); + assert_eq!(s.accepts, n); + assert_eq!(s.tip_mtp, 1, "same tip must compute MTP once"); + assert_eq!( + s.expire_full_scans, 0, + "young pool must not walk accept_at for expiry" + ); + assert!(s.accept_us > 0); + assert!(s.accept_lock_us > 0); + assert!(s.accept_utxo_us > 0); + assert!(s.accept_script_us > 0); + assert!(s.accept_durable_us > 0); + assert!( + s.accept_lock_us >= s.accept_durable_us, + "lock_us={} durable_us={}", + s.accept_lock_us, + s.accept_durable_us + ); + assert!( + s.accept_us >= s.accept_script_us, + "wall={} script={}", + s.accept_us, + s.accept_script_us + ); + let z = hub.sample_reset_perf(); + assert_eq!(z.accepts, 0); + let unused = script_hash(&[0x00]); + assert_eq!(hub.scripthash_unconfirmed_delta(&unused).unwrap(), 0); + let s = hub.sample_reset_perf(); + assert_eq!(s.delta_prevouts, 0); + assert_eq!(hub.scripthash_unconfirmed_delta(&sh).unwrap(), -fee_sum); + + // `mempool_expiry.py`: a new tx past -mempoolexpiry drops every + // older tx with its child, and keeps prioritisetransaction. + let child = spend_true(spends[0], 2_000, spk.clone()); + hub.accept_tx(&child).expect("child"); + hub.prioritise_tx(spends[0], 50_000); + hub.set_expiry_hours(1); + hub.note_mock_now(hub.relay_now_secs() + 3600 + 5); + let trigger = spend_true(cbs[10], 3_000, spk.clone()); + hub.accept_tx(&trigger).expect("trigger expires stale"); + assert_eq!(hub.live_count(), 1); + assert!(hub.contains(&trigger.compute_txid())); + assert_eq!(hub.fee_delta(&spends[0]), 50_000); + assert_eq!(hub.sample_reset_perf().expire_full_scans, 1); + let _ = std::fs::remove_dir_all(&mp); + } + + #[test] + fn missing_vin_aux_batches_class_a_on_open() { + use rbitcoin_store::script_hash; + let (_store, q, cbs) = pad_cbs(12); + let spk = ScriptBuf::from_bytes(vec![0x51]); + let sh = script_hash(spk.as_bytes()); + let mp = tmp(); + let tx = spend_true(cbs[0], 1_000, spk.clone()); + let tid = tx.compute_txid(); + let wtxid = tx.compute_wtxid(); { - let mp = tmp(); - let hub = MempoolHub::open(&mp, Arc::clone(&q)).unwrap(); - hub.set_relay_enabled(true); - let _ = hub.sample_reset_perf(); - let mut fee_sum = 0i64; - let mut spends = Vec::new(); - for (i, cbtxid) in cbs[5..8].iter().enumerate() { - let fee = 1_000u64 + i as u64; - fee_sum += fee as i64; - let tx = spend_true(*cbtxid, fee, spk.clone()); - hub.accept_tx(&tx).expect("accept spend"); - spends.push(tx.compute_txid()); - } - let n = 3u64; - let s = hub.sample_reset_perf(); - assert_eq!(s.accepts, n); - assert_eq!(s.tip_mtp, 1, "same tip must compute MTP once"); - assert_eq!( - s.expire_full_scans, 0, - "young pool must not walk accept_at for expiry" - ); - assert!(s.accept_us > 0); - assert!(s.accept_lock_us > 0); - assert!(s.accept_utxo_us > 0); - assert!(s.accept_script_us > 0); - assert!(s.accept_durable_us > 0); - assert!( - s.accept_lock_us >= s.accept_durable_us, - "lock_us={} durable_us={}", - s.accept_lock_us, - s.accept_durable_us - ); - assert!( - s.accept_us >= s.accept_script_us, - "wall={} script={}", - s.accept_us, - s.accept_script_us - ); - let z = hub.sample_reset_perf(); - assert_eq!(z.accepts, 0); - let unused = script_hash(&[0x00]); - assert_eq!(hub.scripthash_unconfirmed_delta(&unused).unwrap(), 0); - let s = hub.sample_reset_perf(); - assert_eq!(s.delta_prevouts, 0); - assert_eq!(hub.scripthash_unconfirmed_delta(&sh).unwrap(), -fee_sum); - - // `mempool_expiry.py`: a new tx past -mempoolexpiry drops every - // older tx with its child, and keeps prioritisetransaction. - let child = spend_true(spends[0], 2_000, spk.clone()); - hub.accept_tx(&child).expect("child"); - hub.prioritise_tx(spends[0], 50_000); - hub.set_expiry_hours(1); - hub.note_mock_now(hub.relay_now_secs() + 3600 + 5); - let trigger = spend_true(cbs[10], 3_000, spk.clone()); - hub.accept_tx(&trigger).expect("trigger expires stale"); - assert_eq!(hub.live_count(), 1); - assert!(hub.contains(&trigger.compute_txid())); - assert_eq!(hub.fee_delta(&spends[0]), 50_000); - assert_eq!(hub.sample_reset_perf().expire_full_scans, 1); - let _ = std::fs::remove_dir_all(&mp); + let mut store = rbitcoin_mempool::Mempool::open_or_create(&mp).unwrap(); + store + .append_live_tx(&tx, &tid, &wtxid, 1_000, 400, 0, &[]) + .unwrap(); + store.flush().unwrap(); } + let hub = MempoolHub::open(&mp, Arc::clone(&q)).unwrap(); + let s = hub.sample_reset_perf(); + assert_eq!( + s.get_coin, 0, + "missing-aux fill must batch Class A, not get_txout" + ); + assert!( + !hub.scripthash_mempool(&sh).is_empty(), + "batch-fill the vin that lacked aux" + ); + let _ = std::fs::remove_dir_all(&mp); + } + #[test] + fn open_recomputes_unknown_sigops_and_drops_unresolvable() { + let (_store, q, cbs) = pad_cbs(12); + let spk = ScriptBuf::from_bytes(vec![0x51]); + // Unknown sigop cost (schema-2 migrate): open recomputes it from + // chain coins and drops the entry whose input is not a coin. + let mp = tmp(); + // OP_CHECKSIG output: one legacy sigop, cost 4. + let ok = spend_true(cbs[0], 1_000, ScriptBuf::from_bytes(vec![0xac])); + let gone = spend_true(Txid::from_byte_array([0xee; 32]), 1_000, spk.clone()); { - let mp = tmp(); - let tx = spend_true(cbs[0], 1_000, spk.clone()); - let tid = tx.compute_txid(); - let wtxid = tx.compute_wtxid(); - { - let mut store = rbitcoin_mempool::Mempool::open_or_create(&mp).unwrap(); + let mut store = rbitcoin_mempool::Mempool::open_or_create(&mp).unwrap(); + for tx in [&ok, &gone] { + let (tid, wtxid) = (tx.compute_txid(), tx.compute_wtxid()); store - .append_live_tx(&tx, &tid, &wtxid, 1_000, 400, 0, &[]) + .append_live_tx(tx, &tid, &wtxid, 1_000, 400, u64::MAX, &[]) .unwrap(); - store.flush().unwrap(); } - let hub = MempoolHub::open(&mp, Arc::clone(&q)).unwrap(); - let s = hub.sample_reset_perf(); - assert_eq!( - s.get_coin, 0, - "missing-aux fill must batch Class A, not get_txout" - ); - assert!( - !hub.scripthash_mempool(&sh).is_empty(), - "batch-fill the vin that lacked aux" - ); - let _ = std::fs::remove_dir_all(&mp); - } - - { - // Unknown sigop cost (schema-2 migrate): open recomputes it from - // chain coins and drops the entry whose input is not a coin. - let mp = tmp(); - // OP_CHECKSIG output: one legacy sigop, cost 4. - let ok = spend_true(cbs[0], 1_000, ScriptBuf::from_bytes(vec![0xac])); - let gone = spend_true(Txid::from_byte_array([0xee; 32]), 1_000, spk.clone()); - { - let mut store = rbitcoin_mempool::Mempool::open_or_create(&mp).unwrap(); - for tx in [&ok, &gone] { - let (tid, wtxid) = (tx.compute_txid(), tx.compute_wtxid()); - store - .append_live_tx(tx, &tid, &wtxid, 1_000, 400, u64::MAX, &[]) - .unwrap(); - } - store.flush().unwrap(); - } - let hub = MempoolHub::open(&mp, Arc::clone(&q)).unwrap(); - let picked: Vec<_> = hub - .select_block_template(hub.template_budget(0)) - .into_iter() - .map(|(_, s)| (s.txid, s.sigop_cost)) - .collect(); - assert_eq!(picked, vec![(ok.compute_txid(), 4)]); - assert!( - !hub.contains(&gone.compute_txid()), - "unresolvable input evicted" - ); - let _ = std::fs::remove_dir_all(&mp); + store.flush().unwrap(); } + let hub = MempoolHub::open(&mp, Arc::clone(&q)).unwrap(); + let picked: Vec<_> = hub + .select_block_template(hub.template_budget(0)) + .into_iter() + .map(|(_, s)| (s.txid, s.sigop_cost)) + .collect(); + assert_eq!(picked, vec![(ok.compute_txid(), 4)]); + assert!( + !hub.contains(&gone.compute_txid()), + "unresolvable input evicted" + ); + let _ = std::fs::remove_dir_all(&mp); + } + #[test] + fn sigop_adjusted_weight_feeds_cluster_and_confirm_memory() { + let (_store, q, cbs) = pad_cbs(12); + let spk = ScriptBuf::from_bytes(vec![0x51]); + // Sigop-adjusted weight: max(400, 100 * 20) until bps is 0. + let mp = tmp(); + let tx = spend_true(cbs[0], 1_000, spk.clone()); + let (tid, wtxid) = (tx.compute_txid(), tx.compute_wtxid()); { - // Sigop-adjusted weight: max(400, 100 * 20) until bps is 0. - let mp = tmp(); - let tx = spend_true(cbs[0], 1_000, spk.clone()); - let (tid, wtxid) = (tx.compute_txid(), tx.compute_wtxid()); - { - let mut store = rbitcoin_mempool::Mempool::open_or_create(&mp).unwrap(); - store - .append_live_tx(&tx, &tid, &wtxid, 1_000, 400, 100, &[]) - .unwrap(); - store.flush().unwrap(); - } - let hub = MempoolHub::open(&mp, Arc::clone(&q)).unwrap(); - assert_eq!(hub.get_live_adjusted_weight(&tid), Some(2_000)); - // Core `getmempoolcluster` `clusterweight` is sigop-adjusted. - assert_eq!(hub.cluster_rpc(&tid).unwrap().0, 2_000); - // Feefilter announce gate: Core `txinfo.vsize` is sigop-adjusted. - assert_eq!(hub.try_get_live_meta(&tid), Some((1_000, 2_000))); - hub.set_bytes_per_sigop(0); - assert_eq!(hub.get_live_adjusted_weight(&tid), Some(400)); - assert_eq!(hub.cluster_rpc(&tid).unwrap().0, 400); - assert_eq!(hub.get_live_adjusted_weight(&Txid::all_zeros()), None); - hub.set_bytes_per_sigop(20); - // Confirmed feerate memory: 1_000 sat on 500 adjusted vB (raw 100). - hub.set_relay_enabled(true); - assert_eq!(hub.remove_for_block(&[tid]), 1); - assert_eq!(hub.confirm_memory_floor_sat_per_kvb(), Some(2_000)); - let _ = std::fs::remove_dir_all(&mp); + let mut store = rbitcoin_mempool::Mempool::open_or_create(&mp).unwrap(); + store + .append_live_tx(&tx, &tid, &wtxid, 1_000, 400, 100, &[]) + .unwrap(); + store.flush().unwrap(); } + let hub = MempoolHub::open(&mp, Arc::clone(&q)).unwrap(); + assert_eq!(hub.get_live_adjusted_weight(&tid), Some(2_000)); + // Core `getmempoolcluster` `clusterweight` is sigop-adjusted. + assert_eq!(hub.cluster_rpc(&tid).unwrap().0, 2_000); + // Feefilter announce gate: Core `txinfo.vsize` is sigop-adjusted. + assert_eq!(hub.try_get_live_meta(&tid), Some((1_000, 2_000))); + hub.set_bytes_per_sigop(0); + assert_eq!(hub.get_live_adjusted_weight(&tid), Some(400)); + assert_eq!(hub.cluster_rpc(&tid).unwrap().0, 400); + assert_eq!(hub.get_live_adjusted_weight(&Txid::all_zeros()), None); + hub.set_bytes_per_sigop(20); + // Confirmed feerate memory: 1_000 sat on 500 adjusted vB (raw 100). + hub.set_relay_enabled(true); + assert_eq!(hub.remove_for_block(&[tid]), 1); + assert_eq!(hub.confirm_memory_floor_sat_per_kvb(), Some(2_000)); + let _ = std::fs::remove_dir_all(&mp); + } + #[test] + fn over_standard_sigops_still_fit_a_block() { + let (_store, q, cbs) = pad_cbs(12); // 4001 legacy CHECKSIG × 4 = 16004: over Core's standard cap, under // the block limit, so Libre policy admits it. - { - let mp = tmp(); - let hub = MempoolHub::open(&mp, Arc::clone(&q)).unwrap(); - hub.set_relay_enabled(true); - let tx = Transaction { - version: Version::TWO, - lock_time: LockTime::ZERO, - input: vec![TxIn { - previous_output: OutPoint { - txid: cbs[11], - vout: 0, - }, - script_sig: ScriptBuf::new(), - sequence: Sequence::ENABLE_RBF_NO_LOCKTIME, - witness: Witness::new(), - }], - output: vec![TxOut { - value: Amount::from_sat(50_0000_0000 - 100_000), - script_pubkey: ScriptBuf::from_bytes(vec![0xac; 4_001]), - }], - }; - hub.accept_tx(&tx).expect("16004 sigop cost fits a block"); - let picked = hub.select_block_template(hub.template_budget(0)); - assert_eq!(picked.len(), 1); - assert_eq!(picked[0].0, tx); - assert_eq!( - (picked[0].1.fee_sat, picked[0].1.sigop_cost), - (100_000, 16_004) - ); - let _ = std::fs::remove_dir_all(&mp); - } - - let _ = std::fs::remove_dir_all(&store_dir); + let mp = tmp(); + let hub = MempoolHub::open(&mp, Arc::clone(&q)).unwrap(); + hub.set_relay_enabled(true); + let tx = Transaction { + version: Version::TWO, + lock_time: LockTime::ZERO, + input: vec![TxIn { + previous_output: OutPoint { + txid: cbs[11], + vout: 0, + }, + script_sig: ScriptBuf::new(), + sequence: Sequence::ENABLE_RBF_NO_LOCKTIME, + witness: Witness::new(), + }], + output: vec![TxOut { + value: Amount::from_sat(50_0000_0000 - 100_000), + script_pubkey: ScriptBuf::from_bytes(vec![0xac; 4_001]), + }], + }; + hub.accept_tx(&tx).expect("16004 sigop cost fits a block"); + let picked = hub.select_block_template(hub.template_budget(0)); + assert_eq!(picked.len(), 1); + assert_eq!(picked[0].0, tx); + assert_eq!( + (picked[0].1.fee_sat, picked[0].1.sigop_cost), + (100_000, 16_004) + ); + let _ = std::fs::remove_dir_all(&mp); } /// Non-coinbase, no BIP68 time-lock: no `block_tx_fks` and no create MTP. /// A satisfied time-lock spend must survive `evict_after_reorg`. - fn get_coin_skips_block_tx_fks_and_mtp_without_time_lock(q: &Arc, cbs: &[Txid]) { + #[test] + fn get_coin_skips_block_tx_fks_and_mtp_without_time_lock() { + let (store, owned_q, owned_cbs) = copy_maturity_pad(3); + let q = &owned_q; + let cbs = owned_cbs.as_slice(); use rbitcoin_consensus::{accept_and_connect_block, ChainParams, Milestone}; use rbitcoin_primitives::Height; use rbitcoin_store::script_hash; @@ -4685,11 +4700,16 @@ mod tests { "evict_after_reorg must not drop a still-valid BIP68 time lock" ); let _ = std::fs::remove_dir_all(&mp); + let _ = std::fs::remove_dir_all(&store); } /// Confirm/RBF unindex must drop `relay_seq` / `accept_at` for the gone /// wtxid and leave a still-live sibling indexed. - fn unindex_drops_relay_seq_and_accept_at(q: &Arc, cbs: &[Txid]) { + #[test] + fn unindex_drops_relay_seq_and_accept_at() { + let (_store, owned_q, owned_cbs) = pad_cbs(3); + let q = &owned_q; + let cbs = owned_cbs.as_slice(); let spk = ScriptBuf::from_bytes(vec![0x51]); let mp = tmp(); let hub = MempoolHub::open(&mp, Arc::clone(q)).unwrap(); @@ -4715,7 +4735,11 @@ mod tests { /// Without `setmocktime`, INV age must still elapse on wall clock /// (`mempool_accept_wtxid` wait_for_broadcast; mock_now==0 must not freeze). - fn tx_inv_due_uses_wall_clock_when_mocktime_unset(q: &Arc, cbs: &[Txid]) { + #[test] + fn tx_inv_due_uses_wall_clock_when_mocktime_unset() { + let (_store, owned_q, owned_cbs) = pad_cbs(3); + let q = &owned_q; + let cbs = owned_cbs.as_slice(); use bitcoin::script::ScriptBuf; let mp = tmp(); @@ -4970,7 +4994,11 @@ mod tests { let _ = std::fs::remove_dir_all(&store_dir); } - fn startup_recomputes_unknown_sigops_with_configured_reserve(q: &Arc, cbs: &[Txid]) { + #[test] + fn startup_recomputes_unknown_sigops_with_configured_reserve() { + let (_store, owned_q, owned_cbs) = pad_cbs(3); + let q = &owned_q; + let cbs = owned_cbs.as_slice(); let tx = Transaction { version: Version::TWO, lock_time: LockTime::ZERO, @@ -5360,33 +5388,41 @@ mod tests { let _ = std::fs::remove_dir_all(&store_dir); } - /// One 3-coinbase maturity pad. Each chapter opens its own mempool. - /// The last chapter connects a block on this chain. - #[test] - fn shared_maturity_pad_journey() { - let (store_dir, q, cbs) = pad_cbs(3); - expire_stale_drops_old_tx_without_a_new_accept(&q, &cbs); - try_expire_stale_skips_when_the_order_lock_is_held(&q, &cbs); - unindex_drops_relay_seq_and_accept_at(&q, &cbs); - tx_inv_due_uses_wall_clock_when_mocktime_unset(&q, &cbs); - startup_recomputes_unknown_sigops_with_configured_reserve(&q, &cbs); - submit_package_rpc_admits_cpfp_below_minrelay(&q, &cbs); - mempool_under_pressure(&q, &cbs); - accept_package_child_fail_restores_rbf_victims(&q, &cbs); - fee_snapshot_live_totals_match_list_live_meta(&q, &cbs); - accept_commit_does_not_query_under_write(&q, &cbs); - mempool_tx_snapshot_two_live_and_accept_while_held(&q, &cbs); - mempool_tx_snapshot_refresh_reuses_tx_arc(&q, &cbs); - get_coin_skips_block_tx_fks_and_mtp_without_time_lock(&q, &cbs); - let _ = std::fs::remove_dir_all(&store_dir); - } - /// Coinbases at heights `1..=n`, each with 100 confirmations. + /// + /// `n == 3` and `n == 12` return the process pad. Callers that connect + /// or disconnect take [`copy_maturity_pad`] and leave this store in place. fn pad_cbs(n: u32) -> (std::path::PathBuf, Arc, Vec) { + if n == 3 || n == 12 { + let p = maturity_pad(n); + return (p.dir.clone(), Arc::clone(&p.q), p.cbs.clone()); + } + let p = build_maturity_pad(n); + (p.dir, p.q, p.cbs) + } + + struct MaturityPad { + dir: std::path::PathBuf, + q: Arc, + cbs: Vec, + } + + fn maturity_pad(n: u32) -> &'static MaturityPad { + use std::sync::OnceLock; + static PAD3: OnceLock = OnceLock::new(); + static PAD12: OnceLock = OnceLock::new(); + match n { + 3 => PAD3.get_or_init(|| build_maturity_pad(3)), + 12 => PAD12.get_or_init(|| build_maturity_pad(12)), + _ => panic!("maturity pad {n} is built per test"), + } + } + + fn build_maturity_pad(n: u32) -> MaturityPad { use rbitcoin_consensus::{accept_and_connect_block, ChainParams, Milestone}; use rbitcoin_primitives::Height; - let store_dir = tmp(); - let q = Query::open_or_create_tiny(&store_dir).unwrap(); + let dir = tmp(); + let q = Query::open_or_create_tiny(&dir).unwrap(); let params = ChainParams::regtest(); let genesis = bitcoin::blockdata::constants::genesis_block(bitcoin::Network::Regtest); accept_and_connect_block(&q, ¶ms, Height::GENESIS, &genesis, Milestone::NONE).unwrap(); @@ -5401,10 +5437,43 @@ mod tests { n, ); assert_eq!(cbs.len(), n as usize); - (store_dir, Arc::new(q), cbs) + // Quiescent after init. Later tests only read this store. + q.flush().expect("flush maturity pad"); + MaturityPad { + dir, + q: Arc::new(q), + cbs, + } } - fn submit_package_rpc_admits_cpfp_below_minrelay(q: &Arc, cbs: &[Txid]) { + fn copy_maturity_pad(n: u32) -> (std::path::PathBuf, Arc, Vec) { + let src = maturity_pad(n); + let dir = tmp(); + std::fs::create_dir_all(&dir).unwrap(); + copy_store_tree(&src.dir, &dir); + let q = Query::open_or_create_tiny(&dir).expect("open maturity copy"); + (dir, Arc::new(q), src.cbs.clone()) + } + + fn copy_store_tree(src: &std::path::Path, dst: &std::path::Path) { + for ent in std::fs::read_dir(src).expect("read store") { + let ent = ent.expect("store entry"); + let to = dst.join(ent.file_name()); + let ty = ent.file_type().expect("file type"); + if ty.is_dir() { + std::fs::create_dir_all(&to).unwrap(); + copy_store_tree(&ent.path(), &to); + } else { + std::fs::copy(ent.path(), to).unwrap(); + } + } + } + + #[test] + fn submit_package_rpc_admits_cpfp_below_minrelay() { + let (_store, owned_q, owned_cbs) = pad_cbs(3); + let q = &owned_q; + let cbs = owned_cbs.as_slice(); let dir = tmp(); let hub = MempoolHub::open(&dir, Arc::clone(q)).unwrap(); hub.set_relay_enabled(true); @@ -5443,7 +5512,11 @@ mod tests { let _ = std::fs::remove_dir_all(&dir); } - fn mempool_under_pressure(q: &Arc, cbs: &[Txid]) { + #[test] + fn mempool_under_pressure() { + let (_store, owned_q, owned_cbs) = pad_cbs(3); + let q = &owned_q; + let cbs = owned_cbs.as_slice(); let dir = tmp(); let hub = MempoolHub::open(&dir, Arc::clone(q)).unwrap(); hub.set_relay_enabled(true); @@ -5553,7 +5626,11 @@ mod tests { let _ = std::fs::remove_dir_all(&dir); } - fn accept_package_child_fail_restores_rbf_victims(q: &Arc, cbs: &[Txid]) { + #[test] + fn accept_package_child_fail_restores_rbf_victims() { + let (_store, owned_q, owned_cbs) = pad_cbs(3); + let q = &owned_q; + let cbs = owned_cbs.as_slice(); let spk = ScriptBuf::from_bytes(vec![0x51]); let mp = tmp(); let hub = MempoolHub::open(&mp, Arc::clone(q)).unwrap(); @@ -5949,7 +6026,11 @@ mod tests { } /// Fee-snapshot refresh publishes live count/vsize/total_fee (GET /mempool). - fn fee_snapshot_live_totals_match_list_live_meta(q: &Arc, cbs: &[Txid]) { + #[test] + fn fee_snapshot_live_totals_match_list_live_meta() { + let (_store, owned_q, owned_cbs) = pad_cbs(3); + let q = &owned_q; + let cbs = owned_cbs.as_slice(); let mp_dir = tmp(); let hub = MempoolHub::open(&mp_dir, Arc::clone(q)).unwrap(); hub.set_relay_enabled(true); @@ -6238,7 +6319,11 @@ mod tests { let _ = std::fs::remove_dir_all(&store_dir); } - fn accept_commit_does_not_query_under_write(q: &Arc, cbs: &[Txid]) { + #[test] + fn accept_commit_does_not_query_under_write() { + let (_store, owned_q, owned_cbs) = pad_cbs(3); + let q = &owned_q; + let cbs = owned_cbs.as_slice(); use rbitcoin_mempool::UtxoProvider; use std::sync::atomic::{AtomicU64, Ordering}; use std::thread; @@ -6363,7 +6448,11 @@ mod tests { let _ = std::fs::remove_dir_all(&store_dir); } - fn mempool_tx_snapshot_two_live_and_accept_while_held(q: &Arc, cbs: &[Txid]) { + #[test] + fn mempool_tx_snapshot_two_live_and_accept_while_held() { + let (_store, owned_q, owned_cbs) = pad_cbs(3); + let q = &owned_q; + let cbs = owned_cbs.as_slice(); use std::thread; let mp_dir = tmp(); @@ -6406,7 +6495,11 @@ mod tests { let _ = std::fs::remove_dir_all(&mp_dir); } - fn mempool_tx_snapshot_refresh_reuses_tx_arc(q: &Arc, cbs: &[Txid]) { + #[test] + fn mempool_tx_snapshot_refresh_reuses_tx_arc() { + let (_store, owned_q, owned_cbs) = pad_cbs(3); + let q = &owned_q; + let cbs = owned_cbs.as_slice(); let mp_dir = tmp(); let hub = MempoolHub::open(&mp_dir, Arc::clone(q)).unwrap(); hub.set_relay_enabled(true); From e590a16f599c75055eb7b44eff7ff1d6ad6aca17 Mon Sep 17 00:00:00 2001 From: "rearden-grok[bot]" <317016512+rearden-grok[bot]@users.noreply.github.com> Date: Sun, 4 Oct 2026 21:09:43 -0700 Subject: [PATCH 4/4] test: build the electrum maturity pad once Four protocol tests each connected a height-101 chain before mining on a private store. They now copy one process pad, and each test keeps its own name. --- .../rbitcoin-test/tests/electrum_protocol.rs | 123 +++++++++++------- 1 file changed, 73 insertions(+), 50 deletions(-) diff --git a/crates/rbitcoin-test/tests/electrum_protocol.rs b/crates/rbitcoin-test/tests/electrum_protocol.rs index 2d8028311..7cd598b58 100644 --- a/crates/rbitcoin-test/tests/electrum_protocol.rs +++ b/crates/rbitcoin-test/tests/electrum_protocol.rs @@ -27,6 +27,75 @@ async fn read_line_timeout(reader: &mut BufReader<&mut TcpStream>, buf: &mut Str const SP_SCAN: &str = "0f694e068028a717f8af6b9411f9a133dd3565258714cc226594b34db90c1f2c"; const SP_SPEND: &str = "025cc9856d6f8375350e123978daac200c260cb5b5ae83106cab90484dcd8fcf36"; +struct ElectrumMaturity { + keep: TempDir, + tip: bitcoin::BlockHash, + tip_time: u32, + coinbases: Vec, +} + +/// Height-101 regtest with one mature coinbase. Built once per process. +fn electrum_maturity() -> &'static ElectrumMaturity { + use std::sync::OnceLock; + static PAD: OnceLock = OnceLock::new(); + PAD.get_or_init(|| { + let keep = TempDir::new().unwrap(); + let store = keep.path().join("store"); + std::fs::create_dir_all(&store).unwrap(); + let q = Query::open_or_create_tiny(&store).unwrap(); + let params = ChainParams::regtest(); + let genesis = bitcoin::blockdata::constants::genesis_block(bitcoin::Network::Regtest); + rbitcoin_consensus::accept_and_connect_block( + &q, + ¶ms, + rbitcoin_primitives::Height::GENESIS, + &genesis, + Milestone::NONE, + ) + .unwrap(); + let (tip, tip_time, coinbases) = rbitcoin_consensus::pad_empty_from( + &q, + ¶ms, + genesis.block_hash(), + genesis.header.time, + 1, + 101, + 1, + ); + // Quiescent after init. Each test copies this store. + q.flush().expect("flush electrum maturity pad"); + ElectrumMaturity { + keep, + tip, + tip_time, + coinbases, + } + }) +} + +fn open_electrum_maturity(dir: &TempDir) -> (Query, bitcoin::BlockHash, u32, Vec) { + let pad = electrum_maturity(); + let store = dir.path().join("store"); + std::fs::create_dir_all(&store).unwrap(); + copy_store(pad.keep.path().join("store").as_path(), &store); + let q = Query::open_or_create_tiny(&store).unwrap(); + (q, pad.tip, pad.tip_time, pad.coinbases.clone()) +} + +fn copy_store(src: &std::path::Path, dst: &std::path::Path) { + for ent in std::fs::read_dir(src).expect("read store") { + let ent = ent.expect("store entry"); + let to = dst.join(ent.file_name()); + let ty = ent.file_type().expect("file type"); + if ty.is_dir() { + std::fs::create_dir_all(&to).unwrap(); + copy_store(&ent.path(), &to); + } else { + std::fs::copy(ent.path(), to).unwrap(); + } + } +} + async fn pin_wallet_protocol_1_6(stream: &mut TcpStream) { let ver = rpc(stream, 40, "server.version", json!(["test", "1.6"])).await; assert_eq!(ver["result"][1].as_str(), Some("1.6"), "{ver}"); @@ -977,19 +1046,8 @@ fn block_touch_sees_spend_only_heights() { use rbitcoin_store::script_hash; let dir = TempDir::new().unwrap(); - let q = Query::open_or_create_tiny(dir.path().join("store")).unwrap(); let params = ChainParams::regtest(); - let genesis = bitcoin::blockdata::constants::genesis_block(bitcoin::Network::Regtest); - accept_and_connect_block(&q, ¶ms, Height::GENESIS, &genesis, Milestone::NONE).unwrap(); - let (tip, tip_time, coinbase_txids) = rbitcoin_consensus::pad_empty_from( - &q, - ¶ms, - genesis.block_hash(), - genesis.header.time, - 1, - 101, - 1, - ); + let (q, tip, tip_time, coinbase_txids) = open_electrum_maturity(&dir); let spend = Transaction { version: TxVersion::TWO, lock_time: LockTime::ZERO, @@ -1073,24 +1131,11 @@ async fn electrum_mempool_notify_follows_funding_and_rbf() { use bitcoin::script::ScriptBuf; use bitcoin::transaction::Version as TxVersion; use bitcoin::{Amount, OutPoint, Sequence, Transaction, TxIn, TxOut, Witness}; - use rbitcoin_consensus::accept_and_connect_block; use rbitcoin_net::MempoolHub; - use rbitcoin_primitives::Height; let dir = TempDir::new().unwrap(); - let q = Query::open_or_create_tiny(dir.path().join("store")).unwrap(); let params = ChainParams::regtest(); - let genesis = bitcoin::blockdata::constants::genesis_block(bitcoin::Network::Regtest); - accept_and_connect_block(&q, ¶ms, Height::GENESIS, &genesis, Milestone::NONE).unwrap(); - let (_tip, _tip_time, coinbase_txids) = rbitcoin_consensus::pad_empty_from( - &q, - ¶ms, - genesis.block_hash(), - genesis.header.time, - 1, - 101, - 1, - ); + let (q, _tip, _tip_time, coinbase_txids) = open_electrum_maturity(&dir); let q = Arc::new(q); let mp = Arc::new(MempoolHub::open(dir.path().join("mempool"), Arc::clone(&q)).unwrap()); mp.set_relay_enabled(true); @@ -1344,19 +1389,8 @@ async fn electrum_leftover_mempool_does_not_double_count() { use rbitcoin_primitives::Height; let dir = TempDir::new().unwrap(); - let q = Query::open_or_create_tiny(dir.path().join("store")).unwrap(); let params = ChainParams::regtest(); - let genesis = bitcoin::blockdata::constants::genesis_block(bitcoin::Network::Regtest); - accept_and_connect_block(&q, ¶ms, Height::GENESIS, &genesis, Milestone::NONE).unwrap(); - let (tip, tip_time, coinbase_txids) = rbitcoin_consensus::pad_empty_from( - &q, - ¶ms, - genesis.block_hash(), - genesis.header.time, - 1, - 101, - 1, - ); + let (q, tip, tip_time, coinbase_txids) = open_electrum_maturity(&dir); let q_arc = Arc::new(q); let mp = MempoolHub::open(dir.path().join("mempool"), Arc::clone(&q_arc)).unwrap(); mp.set_relay_enabled(true); @@ -1643,19 +1677,8 @@ async fn electrum_and_esplora_asof_hides_later_spend() { const ASOF: &str = "1.4.2-asof"; let dir = TempDir::new().unwrap(); - let q = Query::open_or_create_tiny(dir.path().join("store")).unwrap(); let params = ChainParams::regtest(); - let genesis = bitcoin::blockdata::constants::genesis_block(bitcoin::Network::Regtest); - accept_and_connect_block(&q, ¶ms, Height::GENESIS, &genesis, Milestone::NONE).unwrap(); - let (tip, tip_time, coinbase_txids) = rbitcoin_consensus::pad_empty_from( - &q, - ¶ms, - genesis.block_hash(), - genesis.header.time, - 1, - 101, - 1, - ); + let (q, tip, tip_time, coinbase_txids) = open_electrum_maturity(&dir); let create_spk = ScriptBuf::from_bytes(vec![0x52]); let p2wpkh_sats = 50_000u64; let value = 50_0000_0000 - 1_000 - p2wpkh_sats;