From 55570d0f4bbe0d12a123f1820e4fc98035fb3102 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:52:29 -0700 Subject: [PATCH 1/5] net: name each tip-announce case on one height-102 chain The post-pad chapters each open their own peer and do not observe each other. Prefill connects a spend of the height-1 coinbase before the high-bandwidth relay case builds its own spend of that coinbase. One OnceLock chain, a private hub per case. Genesis IBD and the synthetic header/inv case stay off that chain. --- .../src/peer_tip_announce_journey.rs | 191 +++++++++++++----- 1 file changed, 140 insertions(+), 51 deletions(-) diff --git a/crates/rbitcoin-net/src/peer_tip_announce_journey.rs b/crates/rbitcoin-net/src/peer_tip_announce_journey.rs index 634cefdba..7ce476511 100644 --- a/crates/rbitcoin-net/src/peer_tip_announce_journey.rs +++ b/crates/rbitcoin-net/src/peer_tip_announce_journey.rs @@ -13,6 +13,78 @@ fn op_true() -> ScriptBuf { ScriptBuf::from_bytes(vec![0x51]) } +struct TipAnnouncePad { + store: rbitcoin_query::testutil::TempDir, + // Confirmed-set seed still reads the builder store after that hub drops. + _built: rbitcoin_query::testutil::TempDir, +} + +/// Height-102 regtest, built once. Each announce case opens a private copy. +fn tip_announce_pad() -> &'static TipAnnouncePad { + use std::sync::OnceLock; + static PAD: OnceLock = OnceLock::new(); + PAD.get_or_init(|| { + let (built, hub) = crate::chain::tiny_regtest_hub_labeled("tip-announce-build"); + hub.ensure_genesis().unwrap(); + hub.generate_to_script(102, op_true(), vec![]).unwrap(); + hub.query.flush().expect("flush tip-announce pad"); + let store = rbitcoin_query::testutil::TempDir::labeled("tip-announce-pad") + .expect("tip pad"); + copy_store_tree(built.path(), store.path()); + TipAnnouncePad { + store, + _built: built, + } + }) +} + +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(); + } + } +} + +fn open_tip_announce_hub( + label: &str, +) -> (rbitcoin_query::testutil::TempDir, crate::chain::ChainHub) { + let src = tip_announce_pad(); + let dir = rbitcoin_query::testutil::TempDir::labeled(label).expect("tip copy"); + copy_store_tree(src.store.path(), dir.path()); + let q = rbitcoin_query::Query::open_or_create_tiny(dir.path()).expect("open tip copy"); + let hub = crate::chain::ChainHub::new( + q, + rbitcoin_consensus::ChainParams::regtest(), + rbitcoin_consensus::Milestone::NONE, + ); + let mp = crate::tx_relay::MempoolHub::open(dir.join("mp"), std::sync::Arc::clone(&hub.query)) + .unwrap(); + mp.set_relay_enabled(true); + assert!(hub.attach_mempool(mp).is_ok()); + (dir, hub) +} + +fn open_genesis_announce( + label: &str, + relay: bool, +) -> (rbitcoin_query::testutil::TempDir, crate::chain::ChainHub) { + let (dir, hub) = crate::chain::tiny_regtest_hub_labeled(label); + hub.ensure_genesis().unwrap(); + let mp = crate::tx_relay::MempoolHub::open(dir.join("mp"), std::sync::Arc::clone(&hub.query)) + .unwrap(); + mp.set_relay_enabled(relay); + assert!(hub.attach_mempool(mp).is_ok()); + (dir, hub) +} + + fn live_peer( peers: &std::sync::Arc, port: u16, @@ -147,7 +219,10 @@ fn cmpct_of(msgs: &[NetworkMessage]) -> Vec { /// Genesis is still IBD and relay is off. Fee filter is the IBD amount, and /// `getblocks` of the tip itself must not invent an empty inv. -fn tip_announce_ibd_feefilter_and_empty_inv(hub: &crate::chain::ChainHub) { +#[test] +fn tip_announce_ibd_feefilter_and_empty_inv() { + let (_dir, hub) = open_genesis_announce("tip-ibd", false); + let hub = &hub; assert!(hub.in_ibd(), "genesis tip is older than 24h"); assert_eq!( outbound_feefilter_sats(hub, None), @@ -169,7 +244,10 @@ fn tip_announce_ibd_feefilter_and_empty_inv(hub: &crate::chain::ChainHub) { } /// Headers peer gets one header. Inv peer gets `MSG_BLOCK`, not a witness inv. -fn tip_announce_headers_versus_inv(hub: &crate::chain::ChainHub) { +#[test] +fn tip_announce_headers_versus_inv() { + let (_dir, hub) = open_genesis_announce("tip-hdr-inv", true); + let hub = &hub; use bitcoin::block::{Header, Version}; use bitcoin::{CompactTarget, TxMerkleNode}; let header = Header { @@ -204,7 +282,10 @@ fn tip_announce_headers_versus_inv(hub: &crate::chain::ChainHub) { /// `Lagged` and a queued tip whose hash is no longer the hub tip both /// announce the current tip. -fn tip_announce_recv_coalesces_to_tip(hub: &crate::chain::ChainHub) { +#[test] +fn tip_announce_recv_coalesces_to_tip() { + let (_dir, hub) = open_tip_announce_hub("tip-recv-coalesces-to-tip"); + let hub = &hub; use rbitcoin_primitives::Height; let want = hub.tip_hash().unwrap(); let height = hub.tip_height().unwrap(); @@ -233,7 +314,10 @@ fn tip_announce_recv_coalesces_to_tip(hub: &crate::chain::ChainHub) { } /// A stale `TipEvent` compact-announces the current tip, not the lagged hash. -async fn tip_announce_compact_is_current_tip_only(hub: &crate::chain::ChainHub) { +#[tokio::test] +async fn tip_announce_compact_is_current_tip_only() { + let (_dir, hub) = open_tip_announce_hub("tip-compact-is-current-tip-only"); + let hub = &hub; use rbitcoin_primitives::Height; let mid = hub.query.wire_header_at_height(Height(5)).unwrap(); let tip_hash = hub.tip_hash().unwrap(); @@ -275,7 +359,10 @@ async fn tip_announce_compact_is_current_tip_only(hub: &crate::chain::ChainHub) } /// Compact is one-block tip relay: the peer must already have `pprev`. -async fn tip_announce_compact_requires_parent(hub: &crate::chain::ChainHub) { +#[tokio::test] +async fn tip_announce_compact_requires_parent() { + let (_dir, hub) = open_tip_announce_hub("tip-compact-requires-parent"); + let hub = &hub; use rbitcoin_primitives::Height; let behind = hub .query @@ -312,7 +399,10 @@ async fn tip_announce_compact_requires_parent(hub: &crate::chain::ChainHub) { /// A peer within eight headers gets headers. The same hash is not sent twice. /// A peer who only has the parent gets one header. -fn tip_announce_near_marks_are_headers(hub: &crate::chain::ChainHub) { +#[test] +fn tip_announce_near_marks_are_headers() { + let (_dir, hub) = open_tip_announce_hub("tip-near-marks-are-headers"); + let hub = &hub; let parent = hub.tip_header().unwrap().prev_blockhash; let ev = tip_event(hub, 0); assert_ann( @@ -347,7 +437,10 @@ fn tip_announce_near_marks_are_headers(hub: &crate::chain::ChainHub) { /// Depth 5 is still compact. One deeper is a full block. A full block then /// releases the pending compact-fill slot. -async fn tip_announce_depth_and_fill_slot(hub: &crate::chain::ChainHub) { +#[tokio::test] +async fn tip_announce_depth_and_fill_slot() { + let (_dir, hub) = open_tip_announce_hub("tip-depth-and-fill-slot"); + let hub = &hub; use rbitcoin_primitives::Height; let tip_h = hub.tip_height().unwrap(); let near = hub @@ -412,7 +505,10 @@ async fn tip_announce_depth_and_fill_slot(hub: &crate::chain::ChainHub) { /// Tip announces do not `fetch_add` or `fetch_sub` `serve_inflight`. The /// writer still saturating-subs every `cmpctblock`, so an unpaired decrement /// must stay at zero and a burst must not fill the reconstruct cap. -async fn tip_announce_serve_inflight_untouched(hub: &crate::chain::ChainHub) { +#[tokio::test] +async fn tip_announce_serve_inflight_untouched() { + let (_dir, hub) = open_tip_announce_hub("tip-serve-inflight-untouched"); + let hub = &hub; use std::sync::atomic::Ordering; let hash = hub.tip_hash().unwrap(); let peers = crate::peers::PeerHub::new(); @@ -485,7 +581,10 @@ fn hb_follow() -> PeerFollowState { /// `-prefillcompact` packs the remembered indexes on announce and on getdata. /// A bad index falls back to the coinbase. The send is logged. -async fn tip_announce_prefill_knob(hub: &crate::chain::ChainHub) { +#[tokio::test] +async fn tip_announce_prefill_knob() { + let (_dir, hub) = open_tip_announce_hub("tip-prefill-knob"); + let hub = &hub; use bitcoin::absolute::LockTime; use bitcoin::{Amount, OutPoint, Sequence, Transaction, TxIn, TxOut, Witness}; use rbitcoin_primitives::Height; @@ -569,7 +668,10 @@ async fn tip_announce_prefill_knob(hub: &crate::chain::ChainHub) { /// PoW-valid compact is relayed to other HB peers before connect. An invalid /// body does not become tip, and the sender is not announced back to. -async fn tip_announce_hb_relays_before_connect(hub: &crate::chain::ChainHub) { +#[tokio::test] +async fn tip_announce_hb_relays_before_connect() { + let (_dir, hub) = open_tip_announce_hub("tip-hb-relays-before-connect"); + let hub = &hub; use bitcoin::absolute::LockTime; use bitcoin::bip152::HeaderAndShortIds; use bitcoin::{Amount, OutPoint, Sequence, Transaction, TxIn, TxOut, Witness}; @@ -633,7 +735,10 @@ async fn tip_announce_hb_relays_before_connect(hub: &crate::chain::ChainHub) { } /// Compact prefills must not feed `extra_compact`. `blocktxn` bodies do. -async fn tip_announce_blocktxn_feeds_extra(hub: &crate::chain::ChainHub) { +#[tokio::test] +async fn tip_announce_blocktxn_feeds_extra() { + let (_dir, hub) = open_tip_announce_hub("tip-blocktxn-feeds-extra"); + let hub = &hub; use bitcoin::absolute::LockTime; use bitcoin::bip152::{BlockTransactions, HeaderAndShortIds}; use bitcoin::block::{Header, Version}; @@ -752,7 +857,10 @@ async fn tip_announce_blocktxn_feeds_extra(hub: &crate::chain::ChainHub) { /// After `sendcmpct` version 2, a header whose parent is the tip is /// `MSG_CMPCT_BLOCK` getdata. -async fn tip_announce_header_getdata_is_compact(hub: &crate::chain::ChainHub) { +#[tokio::test] +async fn tip_announce_header_getdata_is_compact() { + let (_dir, hub) = open_tip_announce_hub("tip-header-getdata-is-compact"); + let hub = &hub; let block = mine_child(hub, 50, vec![]); let hash = block.block_hash(); let (out_tx, mut out_rx) = mpsc::unbounded_channel(); @@ -779,7 +887,10 @@ async fn tip_announce_header_getdata_is_compact(hub: &crate::chain::ChainHub) { /// A headers-only parent is already known. The peer's child header is /// getdata, not another getheaders. -async fn tip_announce_submitheader_child_getdata(hub: &crate::chain::ChainHub) { +#[tokio::test] +async fn tip_announce_submitheader_child_getdata() { + let (_dir, hub) = open_tip_announce_hub("tip-submitheader-child-getdata"); + let hub = &hub; let parent = mine_child(hub, 70, vec![]); hub.process_submitted_header(&parent.header).unwrap(); assert!(hub.knows_header(&parent.block_hash())); @@ -824,7 +935,10 @@ async fn tip_announce_submitheader_child_getdata(hub: &crate::chain::ChainHub) { /// Unsolicited compact more than two above the validated tip is a header /// announcement: no reconstruct, no `getblocktxn`. -async fn tip_announce_far_compact_is_header_only(hub: &crate::chain::ChainHub) { +#[tokio::test] +async fn tip_announce_far_compact_is_header_only() { + let (_dir, hub) = open_tip_announce_hub("tip-far-compact-is-header-only"); + let hub = &hub; use bitcoin::bip152::HeaderAndShortIds; let tip_h = hub.tip_height().unwrap(); let mut prev = hub.tip_hash().unwrap(); @@ -873,7 +987,10 @@ async fn tip_announce_far_compact_is_header_only(hub: &crate::chain::ChainHub) { /// Same-hash cached invalid stays connected. A child of a cached-invalid /// parent disconnects. An out-of-range prefilled index disconnects. -async fn tip_announce_invalid_compact_disconnects(hub: &crate::chain::ChainHub) { +#[tokio::test] +async fn tip_announce_invalid_compact_disconnects() { + let (_dir, hub) = open_tip_announce_hub("tip-invalid-compact-disconnects"); + let hub = &hub; use bitcoin::bip152::{HeaderAndShortIds, PrefilledTransaction}; use rbitcoin_primitives::Height; let tip = hub.tip_hash().unwrap(); @@ -948,7 +1065,10 @@ async fn tip_announce_invalid_compact_disconnects(hub: &crate::chain::ChainHub) /// First merkle-mutated unique fill asks for the block and does not /// `BLOCK_FAILED` the header. The second disconnects. -async fn tip_announce_merkle_second_cmpct_disconnects(hub: &crate::chain::ChainHub) { +#[tokio::test] +async fn tip_announce_merkle_second_cmpct_disconnects() { + let (_dir, hub) = open_tip_announce_hub("tip-merkle-second-cmpct-disconnects"); + let hub = &hub; use bitcoin::absolute::LockTime; use bitcoin::bip152::HeaderAndShortIds; use bitcoin::{Amount, OutPoint, Sequence, Transaction, TxIn, TxOut, Witness}; @@ -1004,7 +1124,10 @@ async fn tip_announce_merkle_second_cmpct_disconnects(hub: &crate::chain::ChainH /// After a reorg longer than eight blocks, announce inv until the peer's /// best header is on the new chain. -fn tip_announce_reorg_inv_until_caught_up(hub: &crate::chain::ChainHub) { +#[test] +fn tip_announce_reorg_inv_until_caught_up() { + let (_dir, hub) = open_tip_announce_hub("tip-reorg-inv-until-caught-up"); + let hub = &hub; use rbitcoin_primitives::Height; let sent_tip = hub.tip_hash().unwrap(); let fork_h = hub.tip_height().unwrap() - 4; @@ -1053,37 +1176,3 @@ fn tip_announce_reorg_inv_until_caught_up(hub: &crate::chain::ChainHub) { ); } -/// One peer at the tip. Header/inv announce, compact relay, and HB compact -/// before connect share this hub so a catch-up reorg pad is not paid again. -#[tokio::test] -async fn peer_tip_announce() { - let (dir, hub) = crate::chain::tiny_regtest_hub_labeled("tip-announce"); - hub.ensure_genesis().unwrap(); - let mp = crate::tx_relay::MempoolHub::open(dir.join("mp"), std::sync::Arc::clone(&hub.query)) - .unwrap(); - mp.set_relay_enabled(false); - assert!(hub.attach_mempool(mp).is_ok()); - - tip_announce_ibd_feefilter_and_empty_inv(&hub); - hub.mempool().unwrap().set_relay_enabled(true); - tip_announce_headers_versus_inv(&hub); - - hub.generate_to_script(102, op_true(), vec![]).unwrap(); - tip_announce_recv_coalesces_to_tip(&hub); - tip_announce_compact_is_current_tip_only(&hub).await; - tip_announce_compact_requires_parent(&hub).await; - tip_announce_near_marks_are_headers(&hub); - tip_announce_depth_and_fill_slot(&hub).await; - tip_announce_serve_inflight_untouched(&hub).await; - tip_announce_prefill_knob(&hub).await; - tip_announce_hb_relays_before_connect(&hub).await; - tip_announce_blocktxn_feeds_extra(&hub).await; - tip_announce_header_getdata_is_compact(&hub).await; - tip_announce_submitheader_child_getdata(&hub).await; - tip_announce_far_compact_is_header_only(&hub).await; - tip_announce_invalid_compact_disconnects(&hub).await; - tip_announce_merkle_second_cmpct_disconnects(&hub).await; - tip_announce_reorg_inv_until_caught_up(&hub); - - let _ = std::fs::remove_dir_all(dir); -} From 947ddae9b5e4b7fd8e5a085973e010f93c41f619 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:52:32 -0700 Subject: [PATCH 2/5] net: peel self-announce off the header-dos chain Clearnet externalip and the onion/i2p follow-up share an address book. They never read the header-dos chain. --- .../src/peer_header_dos_journey.rs | 18 +++++++++++++----- 1 file changed, 13 insertions(+), 5 deletions(-) diff --git a/crates/rbitcoin-net/src/peer_header_dos_journey.rs b/crates/rbitcoin-net/src/peer_header_dos_journey.rs index 1923abd10..ed6a80886 100644 --- a/crates/rbitcoin-net/src/peer_header_dos_journey.rs +++ b/crates/rbitcoin-net/src/peer_header_dos_journey.rs @@ -1128,6 +1128,16 @@ async fn ancient_weaker_fork_disconnects( ); } +/// Clearnet externalip, then onion and i2p on the same address book. +/// `--no-discover` and the external address set here are what the overlay +/// beat checks, and the onion address stays set for the i2p beat. +#[test] +fn self_announce_clearnet_then_overlay() { + let peers = crate::peers::PeerHub::new(); + self_announce_clearnet(&peers); + self_announce_overlay(&peers); +} + /// externalip is sent once, again after a day, and not when it is loopback /// or `--no-discover` is set. fn self_announce_clearnet(peers: &std::sync::Arc) { @@ -1234,9 +1244,9 @@ fn self_announce_overlay(peers: &std::sync::Arc) { ); } -/// One peer. Verack order, unknown parents, header floods, minchainwork, -/// and self-announce share this hub. CLTV activates at 111 so a rejected -/// header can log Core's reason on the same chain. +/// One peer. Verack order, unknown parents, header floods, and minchainwork +/// share this hub. CLTV activates at 111 so a rejected header can log Core's +/// reason on the same chain. #[tokio::test] async fn peer_header_dos_and_self_announce() { let (dir, q) = rbitcoin_query::testutil::tiny_query_labeled("hdr-dos"); @@ -1277,8 +1287,6 @@ async fn peer_header_dos_and_self_announce() { } ancient_weaker_fork_disconnects(&hub, &peers).await; empty_locator_needs_a_body(&hub); - self_announce_clearnet(&peers); - self_announce_overlay(&peers); let _ = std::fs::remove_dir_all(src_dir); let _ = std::fs::remove_dir_all(dir); From eecb1d5a7e612a61f8115b9b2205c0565283f33e 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:52:36 -0700 Subject: [PATCH 3/5] mempool: run sigop cases apart from accept life Each sigop beat restores an empty pool. The accept-life arc spends coins those beats do not create. --- .../src/accept_life_journey.rs | 26 +++++++++++++++---- 1 file changed, 21 insertions(+), 5 deletions(-) diff --git a/crates/rbitcoin-mempool/src/accept_life_journey.rs b/crates/rbitcoin-mempool/src/accept_life_journey.rs index a7bf79a14..b225ef01a 100644 --- a/crates/rbitcoin-mempool/src/accept_life_journey.rs +++ b/crates/rbitcoin-mempool/src/accept_life_journey.rs @@ -708,12 +708,11 @@ fn sigop_reopen_and_compact(life: &mut Life, dir: &rbitcoin_store::testutil::Tem assert_eq!(life.mp.live_count(), 0); } -/// One mempool from the first orphan to a full pool: relay order, RBF, -/// cluster caps, packages, a block, a reorg, a raised `-minrelaytxfee`, and -/// eviction, against one chain view. Sigop-adjusted size, the shared block -/// sigop budget, and overlay reopen/compact run first on that same pool. +/// Sigop-adjusted size, the block sigop budget, and reopen/compact. Each +/// beat restores an empty pool. This is not the accept-life story: nothing +/// here is a coin that story later spends. #[test] -fn mempool_accept_life() { +fn sigop_adjusted_size_budget_and_reopen() { let dir = tmp_dir(); let mut life = Life { mp: ActiveMempool::open_or_create(&dir).unwrap(), @@ -728,6 +727,23 @@ fn mempool_accept_life() { sigop_rbf_package_and_cluster(&mut life); sigop_block_budget(&mut life); sigop_reopen_and_compact(&mut life, &dir); + let _ = std::fs::remove_dir_all(&dir); +} + +/// One mempool from the first orphan to a full pool: relay order, RBF, +/// cluster caps, packages, a block, a reorg, a raised `-minrelaytxfee`, and +/// eviction, against one chain view. +#[test] +fn mempool_accept_life() { + let dir = tmp_dir(); + let mut life = Life { + mp: ActiveMempool::open_or_create(&dir).unwrap(), + chain: LifeChain::default(), + tip: ChainTipCtx { + height: 200, + mtp: u32::MAX, + }, + }; orphan_parks_then_promotes(&mut life); invalid_or_spent_parent_does_not_park(&mut life); let (rbf_coin, rbf_winner) = full_rbf_and_staged_commit(&mut life); From 7f29724781e4b01f07a31b7a08e2e152cb52cea8 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:52:39 -0700 Subject: [PATCH 4/5] rpc: open mature chain ops on their own hub Genesis rejects and the post-maturity wallet are different chains. The mature hub mines itself to height 130, inside the 121..144 window the hashps beat requires. --- .../src/regtest_chain_ops_journey.rs | 20 ++++++++++++++----- 1 file changed, 15 insertions(+), 5 deletions(-) diff --git a/crates/rbitcoin-rpc/src/regtest_chain_ops_journey.rs b/crates/rbitcoin-rpc/src/regtest_chain_ops_journey.rs index 6e788a55e..e0ae7bb69 100644 --- a/crates/rbitcoin-rpc/src/regtest_chain_ops_journey.rs +++ b/crates/rbitcoin-rpc/src/regtest_chain_ops_journey.rs @@ -67,11 +67,11 @@ fn regrind(block: &mut Block) { } } -/// One regtest hub an operator drives over RPC from genesis: mine, submit, -/// headers, templates, mocktime, fee caps, invalidate and precious. +/// One regtest hub from genesis through the first submits: mine, headers, +/// mocktime, and the consensus rejects that do not need a mature coinbase. #[test] -fn rpc_regtest_chain_ops() { - let (mut ctx, dir, hub) = ctx_regtest_hub(); +fn rpc_regtest_from_genesis() { + let (ctx, dir, hub) = ctx_regtest_hub(); let (addr, p2wpkh) = p2wpkh_regtest(); let store = dir.join("store"); chain_ops_at_genesis(&ctx, &hub, &store); @@ -83,9 +83,19 @@ fn rpc_regtest_chain_ops() { chain_ops_empty_template_and_proposals(&ctx); chain_ops_header_rejects(&ctx, &hub, &p2wpkh); chain_ops_submit_rejects(&ctx, &hub, &p2wpkh); + let _ = std::fs::remove_dir_all(&dir); +} +/// One regtest hub past maturity: templates, sigops, packages, invalidate, +/// and a mainnet view of that same hub. The genesis rejects are a separate +/// chain; this one mines its own pad inside 121..144. +#[test] +fn rpc_regtest_mature_chain_ops() { + let (mut ctx, dir, hub) = ctx_regtest_hub(); + let (addr, p2wpkh) = p2wpkh_regtest(); + let store = dir.join("store"); let mut cbs = TrueCoinbases(tip_count(&ctx) as u32 + 1); - dispatch(&ctx, "generate", vec![json!(120)]).unwrap(); + dispatch(&ctx, "generate", vec![json!(130)]).unwrap(); chain_ops_mature_pad_work(&ctx); chain_ops_template_sigops_and_script_reject(&ctx, &mut cbs); chain_ops_sigop_adjusted_entry_and_min_fee(&ctx, &mut cbs); From df28b47340d579cc1e4cbda3e47f2b2c8ebdc8af 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:52:41 -0700 Subject: [PATCH 5/5] docs: name the split journey stories The catalog and the finding citations follow the new test names. --- TESTING.md | 9 ++++++--- docs/external_findings/021-regtest-activation-heights.md | 2 +- docs/external_findings/085-64-byte-tx-mutation.md | 2 +- docs/external_findings/README.md | 2 +- docs/sv2-template-provider.md | 6 +++--- 5 files changed, 12 insertions(+), 9 deletions(-) diff --git a/TESTING.md b/TESTING.md index 233f2e35c..cc010b8d1 100644 --- a/TESTING.md +++ b/TESTING.md @@ -343,8 +343,10 @@ A cell that names a crate-local test describes the witness that exists today. It | `node_cli_and_surface_smoke` | Lifecycle/CLI | Networks, `run_node`, config errors, CLI flags (incl. `--conf`, `--peer-timeout=0` refuse / `=1` smoke, unknown conf key ignored, `min_relay_tx_fee=-1` and `network=nope` conf fail), dropped Core `--rpcuser`/`--rpcpassword`/`--rpcport`/`--rpcconnect`/`-rpcport` refuse, help/version (`rbitcoin-cli` help and version do not dial). Usage errors exit 2 before a datadir is touched: unknown flags, missing or bad values, and every concatenated, one-dash, or Core spelling of a native kebab flag. A missing conf, a bad line, a bad `log_level`, conf `rpcuser`/`rpcpassword`, and `max_outbound=0` exit 2 and open no store. Non-hex `--min-chain-work`, a signet challenge or block time off a custom signet, and `--sp-tweaks` with `--prune-seqsigwit` exit 1. One smoke takes the native flag set together; bare network conf lines parse and CLI `--datadir` wins over conf `datadir`; `--datadir-cold` puts seqsigwit on the cold store; a custom signet smokes. Signet: genesis header plus height-1 BIP325 connect. The assembled `NodeConfig` values and help text no surface reports are the node-crate `operator_conf_and_argv` (`--bytes-per-sigop`, `--block-reserved-sigops`, conf `sp_tweaks` together with `prune_seqsigwit`, conf `sp_tweaks=1` at the default dust and `sp_tweaks_dust` of 546, 0, or a non-numeric value, and Electrum or Esplora with and without `--sh-index` plus `--sh-index` alone) | | `three_stage_confirm_and_parent_pin_surface` | Consensus+query | Split load→scripts→write of pad+spend from genesis (header-plan BIP68 MTP); parent pin; load ready timeout/cancel; instance-owned `last_write` / `last_pin` / `take_window` meters (a second engine's window stays empty); txstat fee / size / weight from the load assemble, restamp and its refuses; same-run create then spend; 546-shaped 2-vout merge + same-block chain + cross-batch head resolve; an already-at-height retry finishes a spend annotate | | `mempool_under_pressure` | Mempool + RPC (crate) | One entry in `orphanage`, `accept`, `tx_relay`, and `methods_tests`: orphan reserve and expiry, sigops before script, rolling fee floor, cluster cap, parked min-relay orphan, and the package RPC rejects (unsorted, missing inputs, conflict, min-relay parent with maxfeerate child). | -| `mempool_accept_life` | Mempool (crate) | One `ActiveMempool` against one chain view that blocks move. Sigop-adjusted vsize (boundary, min relay, full-pool floor, RBF, package and 1p1c), the raw-weight cluster limit, the shared block sigop budget, and sigop cost plus bytes-per-sigop and reserve overlays across reopen and compact. Orphan parks and re-announce, dry run not parked, parent promotes the child; missing vout, invalid parent, and block-spent coin reject without parking. Full RBF and no return, the staged commit failing closed on a conflict that landed after prepare, pure RBFR unpinning a child, replaced txs out of the cluster count; a ~30 kvB single tx under the vsize cap and the ten-way merge over it. Package order, CPFP, child fail restoring the RBF victim. A block evicts double-spent txs with descendants; a reorg readmits the parent and evicts the BIP68 and coinbase-maturity spends. Raised `-minrelaytxfee`: 1p1c needs a paying child, an unrelated tx does not ride the waiver, child fail takes a promoted spender down. Full pool: a protected lone worst chunk evicts nothing and leaves the floor, the next arrival evicts the CPFP pair together. | -| `rpc_regtest_chain_ops` | RPC (crate) | One regtest hub from genesis through `dispatch`. At genesis: `size_on_disk` is the store walk, IBD comes from the hub and not the stale atomic, buried deployments, `generateblock submit=false` connects nothing, and the priority, mocktime, mockscheduler, submitheader decode, and not-found refuses. The first block pays a p2wpkh address: display-order hashes and txids, raw `getblock` and header, a headers-only child at progress 0.5. Mocktime stamps `generate` and makes a far block `time-too-new`. Coinbase-only blocks: verbosity 1 without a seqsigwit zip, `getnetworkhashps` over chainwork, the empty template and proposal needles, a `time-too-old` header, an invalid parent body that marks its branch, and the `submitblock` merkle, length, coinbase, duplicate, value, and missing-input rejects. After a 120-block pad: the `nblocks=0` window, GBT fee and sigops (bare, P2SH, P2WSH), sigop-adjusted mempool vsize (`getmempoolentry`, package retry, `blockmintxfee`; weight and the Esplora/Electrum histogram stay raw) and a big-sigops cluster under the block budget, a non-DER spend with Core `reject-details`, deprioritise, `generateblock` reject shapes then parent-first mining, a premature coinbase, proposal spend/value/final needles against the chain, default and explicit `maxfeerate`, `testmempoolaccept` known vs mempool vs archived, invalidate and reconsider, and a parked sibling (held `getblock`, `preciousblock`). Last, a mainnet view of the same hub refuses the regtest-only methods and still takes `submitblock`. | +| `sigop_adjusted_size_budget_and_reopen` | Mempool (crate) | One empty `ActiveMempool`: sigop-adjusted vsize (boundary, min relay, full-pool floor, RBF, package and 1p1c), the raw-weight cluster limit, the shared block sigop budget, and sigop cost plus bytes-per-sigop and reserve overlays across reopen and compact. Each beat restores an empty pool. | +| `mempool_accept_life` | Mempool (crate) | One `ActiveMempool` against one chain view that blocks move. Orphan parks and re-announce, dry run not parked, parent promotes the child; missing vout, invalid parent, and block-spent coin reject without parking. Full RBF and no return, the staged commit failing closed on a conflict that landed after prepare, pure RBFR unpinning a child, replaced txs out of the cluster count; a ~30 kvB single tx under the vsize cap and the ten-way merge over it. Package order, CPFP, child fail restoring the RBF victim. A block evicts double-spent txs with descendants; a reorg readmits the parent and evicts the BIP68 and coinbase-maturity spends. Raised `-minrelaytxfee`: 1p1c needs a paying child, an unrelated tx does not ride the waiver, child fail takes a promoted spender down. Full pool: a protected lone worst chunk evicts nothing and leaves the floor, the next arrival evicts the CPFP pair together. | +| `rpc_regtest_from_genesis` | RPC (crate) | One regtest hub from genesis through the first submits. At genesis: `size_on_disk` is the store walk, IBD comes from the hub and not the stale atomic, buried deployments, `generateblock submit=false` connects nothing, and the priority, mocktime, mockscheduler, submitheader decode, and not-found refuses. The first block pays a p2wpkh address: display-order hashes and txids, raw `getblock` and header, a headers-only child at progress 0.5. Mocktime stamps `generate` and makes a far block `time-too-new`. Coinbase-only blocks: verbosity 1 without a seqsigwit zip, `getnetworkhashps` over chainwork, the empty template and proposal needles, a `time-too-old` header, an invalid parent body that marks its branch, and the `submitblock` merkle, length, coinbase, duplicate, value, and missing-input rejects. | +| `rpc_regtest_mature_chain_ops` | RPC (crate) | One regtest hub mined to height 130. The `nblocks=0` window, GBT fee and sigops (bare, P2SH, P2WSH), sigop-adjusted mempool vsize (`getmempoolentry`, package retry, `blockmintxfee`; weight and the Esplora/Electrum histogram stay raw) and a big-sigops cluster under the block budget, a non-DER spend with Core `reject-details`, deprioritise, `generateblock` reject shapes then parent-first mining, a premature coinbase, proposal spend/value/final needles against the chain, default and explicit `maxfeerate`, `testmempoolaccept` known vs mempool vs archived, invalidate and reconsider, and a parked sibling (held `getblock`, `preciousblock`). Last, a mainnet view of the same hub refuses the regtest-only methods and still takes `submitblock`. | | `block_cache_and_mempool_hub_surface` | Net | BlockCache locator/eviction + MempoolHub accept/remove/reorg on mature chain. Eviction uses body depth 16. | | `store_error_and_corrupt_paths` | Store | Error/corrupt surfaces | | `store_table_header_and_idx_corrupt` | Store | Table header/head corrupt open | @@ -388,7 +390,8 @@ A cell that names a crate-local test describes the witness that exists today. It | `a_heavier_header_chain_keeps_its_context` | IBD (crate) | One hub: a challenger keeps median time while the tip is genesis (invalid at the median, valid one second later); that hub then confirms a stale ancestor, a heavier chain from below it replaces the candidate and drops its script skip, and a rewind uses the confirmed base work and height. Retarget is a second hub (period length is not regtest difficulty): a heavier fork across a gap retarget is adopted, rewind restores difficulty before the next retarget, and a challenger keeps its retarget snapshot. Those three checkpoint layouts cannot be one candidate. | | `analog_selector_keeps_each_boundary_rate` | Mempool (crate) | One selector table: the exact band edge, absolute log distance, the requested quantile, and the two-hundredth nearest window each keep their own rate. | | `fee_history_gaps_cache_and_reorg_share_one_history` | IBD (crate) | One history: heights without a hurdle are not observations, rates stay cached until a new height, only the newest hashes are kept, and a reorg drops those above. The RPC backfill stays `fee_history_backfills_from_the_chain_when_relay_starts`. | -| `peer_header_dos_and_self_announce` | P2P (**default**, crate) | One hub and one peer: verack order (wtxidrelay and sendaddrv2 stick, ping is logged, redundant verack is ignored, sendaddrv2 and oversized addrv2 after verack disconnect), unknown-parent block and compact, a full header batch continues from its last header, minchainwork stays silent until the floor, bad proof-of-work disconnects and time-too-new does not, empty-locator serves only a hash that has a body, an ancient weaker header disconnects unless noban, externalip is daily, `--no-discover` suppresses it, and onion / i2p replace that clearnet address. A noban peer stays up on a bad block and gathers no score. A version-3 header at CLTV activation logs `bad-version`, and a far stamp logs `time-too-new`. | +| `peer_header_dos_and_self_announce` | P2P (**default**, crate) | One hub and one peer: verack order (wtxidrelay and sendaddrv2 stick, ping is logged, redundant verack is ignored, sendaddrv2 and oversized addrv2 after verack disconnect), unknown-parent block and compact, a full header batch continues from its last header, minchainwork stays silent until the floor, bad proof-of-work disconnects and time-too-new does not, empty-locator serves only a hash that has a body, an ancient weaker header disconnects unless noban. A noban peer stays up on a bad block and gathers no score. A version-3 header at CLTV activation logs `bad-version`, and a far stamp logs `time-too-new`. | +| `self_announce_clearnet_then_overlay` | P2P (**default**, crate) | One address book, no chain: externalip is daily, loopback and `--no-discover` suppress it, then onion and i2p replace that clearnet address on the same book. | | `p2p_compact_hb_getblocktxn_and_orphan` | P2P (**default**) | One mature pad: HB coinbase `cmpctblock`, 2-tx compact → `getblocktxn` then same-peer compact retry while pending + `blocktxn` connect, unique short-id fill that fails header merkle → `getdata` (not `getblocktxn`, header not `BLOCK_FAILED`) then honest full `block` connects, orphan child GetData then parent accept (INV AlreadyHave), then live `getblocks` → `inv`, inbound `feefilter`, `filterload` disconnect. The pad seals filters through height 1, does not advertise `NODE_COMPACT_FILTERS` while the tip is ahead, stays silent for a stop at the tip, and answers `getcfilters` / `getcfheaders` / `getcfcheckpt` for the sealed height. An oversize locator is rejected. `mempool`, `filteradd`, and `filterclear` disconnect. Live mutated `block` disconnects in `on_block`. Inbound `getdata` of 20 witness blocks serves `MAX_SERVE_BLOCKS` (16); 17th is not queued (`getdata_skips_reconstruct_when_serve_inflight_at_cap`). Same-peer pending compact skip is `same_peer_pending_cmpct_does_not_getblocktxn_again`. Tokio-worker park and park-not-reject logs are `peer_blocksonly_and_orphan_tx` | | `p2p_feeler_completes_and_closes` | P2P (**default**) | Outbound feeler: VERSION then close (`feeler connection completed`). No live follow; dummy has no completed inbound. Same test: `run_feeler_timed` silence is `Timeout`. Inbound/outbound/plain silence stay `handshake_timeout_after_silence` | | `p2p_inbound_full_rejects_extra` | P2P (**default**) | `max_inbound=1`: second follow is refused; first inbound stays. Same test: `select_inbound_eviction` 21-cand ranking (4 block + 5 slow + 4 tx + 8 ping → victim in slow). A peer that is only noban is not the eviction victim | diff --git a/docs/external_findings/021-regtest-activation-heights.md b/docs/external_findings/021-regtest-activation-heights.md index f1c3933b6..c8adac1f6 100644 --- a/docs/external_findings/021-regtest-activation-heights.md +++ b/docs/external_findings/021-regtest-activation-heights.md @@ -31,5 +31,5 @@ the height push and nVersion 4. `block::structure_rule_tests::s7_regtest_rejects_bip34_missing_at_height_1`, `s7_regtest_bip34_activation_height_override`, `params::tests::bip34_hash_gates_bip30_like_core`; `rbitcoin-rpc` -`rpc_regtest_chain_ops` (`generatetoaddress` at height 1, `getdeploymentinfo` +`rpc_regtest_from_genesis` (`generatetoaddress` at height 1, `getdeploymentinfo` bip34 active). diff --git a/docs/external_findings/085-64-byte-tx-mutation.md b/docs/external_findings/085-64-byte-tx-mutation.md index 8c7a4c13d..7237e8e20 100644 --- a/docs/external_findings/085-64-byte-tx-mutation.md +++ b/docs/external_findings/085-64-byte-tx-mutation.md @@ -38,4 +38,4 @@ lands, this finding is not fully fixed. `chain::tests::hostile_peer_session`, `peer::tests::peer_header_dos_and_self_announce`, `compact::tests::prefilled_64_byte_body_without_coinbase_is_not_a_block`; -`rbitcoin-rpc` `methods::tests::rpc_regtest_chain_ops`. +`rbitcoin-rpc` `methods::tests::rpc_regtest_from_genesis`. diff --git a/docs/external_findings/README.md b/docs/external_findings/README.md index e80600b2b..c81f679a2 100644 --- a/docs/external_findings/README.md +++ b/docs/external_findings/README.md @@ -89,7 +89,7 @@ rbitcoin reference, or redteam static analysis). Numbered reports live beside th | [082](./082-invalid-hash-cap.md) | low | Invalid-hash set stops at 4096 | fixed | `invalid_hash_set_stops_at_the_cap` | | [083](./083-mempool-expiry-cursor.md) | low | Mempool expiry runs without a new admission | fixed | `expire_stale_drops_old_tx_without_a_new_accept` | | [084](./084-findanddelete-empty-sig.md) | critical | Empty sig in legacy CHECKMULTISIG did not delete OP_0 from scriptCode | fixed | `legacy_multisig_empty_sig_deletes_op_0_from_script_code` | -| [085](./085-64-byte-tx-mutation.md) | high | Coinbase-less 64-byte body cached as an invalid block hash | partial (IBD confirm path: `consensus/ibd-mutated-body-reject`) | `hostile_peer_session`, `peer_header_dos_and_self_announce`, `prefilled_64_byte_body_without_coinbase_is_not_a_block`, `rpc_regtest_chain_ops` | +| [085](./085-64-byte-tx-mutation.md) | high | Coinbase-less 64-byte body cached as an invalid block hash | partial (IBD confirm path: `consensus/ibd-mutated-body-reject`) | `hostile_peer_session`, `peer_header_dos_and_self_announce`, `prefilled_64_byte_body_without_coinbase_is_not_a_block`, `rpc_regtest_from_genesis` | **012–021:** fuzzamoto differential report (`rbitcoin-report.tar.gz`, baseline `8f3990f`). Report-local 001–010 are **renumbered** here. Identity/BIP30 diff --git a/docs/sv2-template-provider.md b/docs/sv2-template-provider.md index 28b3e5bfc..24a3ad05b 100644 --- a/docs/sv2-template-provider.md +++ b/docs/sv2-template-provider.md @@ -187,10 +187,10 @@ fits; a chunk that would pass 80_000 is skipped. No new flag. `select_budgets_sigops_skip_and_continue` pins the budget, the base fee -under a delta, and the exact-80_000 edge. `mempool_accept_life` pins -admission against the same cap. `hub_live_journey` reads fee and sigop +under a delta, and the exact-80_000 edge. `sigop_adjusted_size_budget_and_reopen` +pins admission against the same cap. `hub_live_journey` reads fee and sigop cost through the hub call (reserve 0 fits a 79,920-cost tx; a caller -reserving 400 does not). `rpc_regtest_chain_ops` pins GBT `fee` beside +reserving 400 does not). `rpc_regtest_mature_chain_ops` pins GBT `fee` beside `sigops`. ---