From 4cb5d621f40770c10017dc04e954b6f84f2f9df4 Mon Sep 17 00:00:00 2001 From: Brandon Black Date: Fri, 9 Oct 2026 08:47:37 -0700 Subject: [PATCH] fuzz: keep compact follow inside Core's mock clock The nightly compact job setmocktime's bitcoind to regtest genesis, then stamped headers up to genesis plus about three hours. Core rejected those as time-too-new while the hub, on the wall clock, accepted them. Fold the stamp into the two-hour window, pin the follow accept to the same clock, and put Core's submitblock reason on the split panic. Co-authored-by: Cursor --- TESTING.md | 4 + changelog.d/cmpct-mock-clock.md | 5 + fuzz/Cargo.lock | 2 + fuzz/fuzz_targets/block_csv_differential.rs | 4 +- fuzz/fuzz_targets/block_differential.rs | 4 +- fuzz/fuzz_targets/block_fork_differential.rs | 4 +- .../block_reorg_n_differential.rs | 4 +- fuzz/fuzz_targets/block_spend_differential.rs | 4 +- fuzz/fuzz_targets/cmpct_differential.rs | 11 ++- fuzz/fuzz_targets/cmpct_reorg_differential.rs | 4 +- fuzz/fuzz_targets/mempool_differential.rs | 4 +- .../fuzz_targets/p2p_sequence_differential.rs | 8 +- fuzz/fuzz_targets/script_differential.rs | 4 +- .../script_verify_differential.rs | 4 +- fuzz/src/block_diff.rs | 31 +++++- fuzz/src/cmpct_fuzz.rs | 98 +++++++++++++++---- 16 files changed, 162 insertions(+), 33 deletions(-) create mode 100644 changelog.d/cmpct-mock-clock.md diff --git a/TESTING.md b/TESTING.md index df4f7622c1..c52f43f68f 100644 --- a/TESTING.md +++ b/TESTING.md @@ -526,6 +526,10 @@ count, prefill mask, fill/duplicate/corrupt flags, nonce) then encodes a well-formed `cmpctblock`. `data[0] % 8 == 7` is the raw-wire arm (`prepare_cmpct_fuzz_hsi` restamp; malformed decode is skip). Each case grinds a unique header (prev = genesis) so Core treats it as a new compact. +The stamp folds a mix of the input into genesis + 600 through genesis + 2 +hours: this job's `setmocktime` is genesis, and a later header is +`time-too-new` on Core. The follow accept pins the hub clock to that same +mocktime. A split panic includes Core's `submitblock` reason. Spawn `setmocktime`s Core to regtest genesis time (`CanDirectFetch`). P2P `bitcoind` also gets `-maxtipage=999999999` so Core v31's IBD latch (`UpdateIBDStatus` on `LoadChainTip`, not `setmocktime`) leaves IBD at diff --git a/changelog.d/cmpct-mock-clock.md b/changelog.d/cmpct-mock-clock.md new file mode 100644 index 0000000000..4ccc80afe4 --- /dev/null +++ b/changelog.d/cmpct-mock-clock.md @@ -0,0 +1,5 @@ +Fixed + +- **Compact fuzz shares Core's mock clock.** Header stamps stay within + two hours of regtest genesis, and the follow accept uses that same + clock. A split panic includes Core's `submitblock` reason. diff --git a/fuzz/Cargo.lock b/fuzz/Cargo.lock index 7712eb4e5d..0406749efe 100644 --- a/fuzz/Cargo.lock +++ b/fuzz/Cargo.lock @@ -516,6 +516,8 @@ dependencies = [ "rbitcoin-primitives", "rbitcoin-query", "rbitcoin-store", + "serde", + "serde_json", "sha3", "tokio", ] diff --git a/fuzz/fuzz_targets/block_csv_differential.rs b/fuzz/fuzz_targets/block_csv_differential.rs index 750d7d0c11..917c5a74b4 100644 --- a/fuzz/fuzz_targets/block_csv_differential.rs +++ b/fuzz/fuzz_targets/block_csv_differential.rs @@ -89,7 +89,9 @@ fuzz_target!(|data: &[u8]| { ORACLE_DOWN_STREAK.store(0, Ordering::Relaxed); } CompareOne::Agreed { .. } => note_comparison(), - CompareOne::Disagreed { ours, core, hex } => { + CompareOne::Disagreed { + ours, core, hex, .. + } => { eprintln!("=== BLOCK-CSV-DIFFERENTIAL FUZZ CONSENSUS DIVERGENCE ==="); eprintln!("ours_accept={ours} core_accept={core}"); eprintln!("block_hex={hex}"); diff --git a/fuzz/fuzz_targets/block_differential.rs b/fuzz/fuzz_targets/block_differential.rs index 16dcfa0ae9..cfdab78139 100644 --- a/fuzz/fuzz_targets/block_differential.rs +++ b/fuzz/fuzz_targets/block_differential.rs @@ -81,7 +81,9 @@ fuzz_target!(|data: &[u8]| { ORACLE_DOWN_STREAK.store(0, Ordering::Relaxed); } CompareOne::Agreed { .. } => note_comparison(), - CompareOne::Disagreed { ours, core, hex } => { + CompareOne::Disagreed { + ours, core, hex, .. + } => { eprintln!("=== BLOCK-DIFFERENTIAL FUZZ CONSENSUS DIVERGENCE ==="); eprintln!("ours_accept={ours} core_accept={core}"); eprintln!("block_hex={hex}"); diff --git a/fuzz/fuzz_targets/block_fork_differential.rs b/fuzz/fuzz_targets/block_fork_differential.rs index 57ac85fa39..eafc8b1eb5 100644 --- a/fuzz/fuzz_targets/block_fork_differential.rs +++ b/fuzz/fuzz_targets/block_fork_differential.rs @@ -87,7 +87,9 @@ fuzz_target!(|data: &[u8]| { ORACLE_DOWN_STREAK.store(0, Ordering::Relaxed); } CompareOne::Agreed { .. } => note_comparison(), - CompareOne::Disagreed { ours, core, hex } => { + CompareOne::Disagreed { + ours, core, hex, .. + } => { eprintln!("=== BLOCK-FORK-DIFFERENTIAL FUZZ CONSENSUS DIVERGENCE ==="); eprintln!("ours_accept={ours} core_accept={core}"); eprintln!("block_hex={hex}"); diff --git a/fuzz/fuzz_targets/block_reorg_n_differential.rs b/fuzz/fuzz_targets/block_reorg_n_differential.rs index c763e8f0a7..e1a41315aa 100644 --- a/fuzz/fuzz_targets/block_reorg_n_differential.rs +++ b/fuzz/fuzz_targets/block_reorg_n_differential.rs @@ -87,7 +87,9 @@ fuzz_target!(|data: &[u8]| { ORACLE_DOWN_STREAK.store(0, Ordering::Relaxed); } CompareOne::Agreed { .. } => note_comparison(), - CompareOne::Disagreed { ours, core, hex } => { + CompareOne::Disagreed { + ours, core, hex, .. + } => { eprintln!("=== BLOCK-REORG-N-DIFFERENTIAL FUZZ CONSENSUS DIVERGENCE ==="); eprintln!("ours_accept={ours} core_accept={core}"); eprintln!("block_hex={hex}"); diff --git a/fuzz/fuzz_targets/block_spend_differential.rs b/fuzz/fuzz_targets/block_spend_differential.rs index 6225a5aac7..37301bfe40 100644 --- a/fuzz/fuzz_targets/block_spend_differential.rs +++ b/fuzz/fuzz_targets/block_spend_differential.rs @@ -89,7 +89,9 @@ fuzz_target!(|data: &[u8]| { ORACLE_DOWN_STREAK.store(0, Ordering::Relaxed); } CompareOne::Agreed { .. } => note_comparison(), - CompareOne::Disagreed { ours, core, hex } => { + CompareOne::Disagreed { + ours, core, hex, .. + } => { eprintln!("=== BLOCK-SPEND-DIFFERENTIAL FUZZ CONSENSUS DIVERGENCE ==="); eprintln!("ours_accept={ours} core_accept={core}"); eprintln!("block_hex={hex}"); diff --git a/fuzz/fuzz_targets/cmpct_differential.rs b/fuzz/fuzz_targets/cmpct_differential.rs index 873c4e6bf2..c19554b482 100644 --- a/fuzz/fuzz_targets/cmpct_differential.rs +++ b/fuzz/fuzz_targets/cmpct_differential.rs @@ -229,8 +229,15 @@ fn send_one(b: &Base, data: &[u8]) -> SendOutcome { if let Some(fate) = follow_reconstructed_cmpct(&b.hub, &b.core.rpc, &case) { match fate { CompareOne::Harness(msg) => harness_failure(msg), - CompareOne::Disagreed { ours, core, hex } => { - panic!("cmpct follow split ours={ours} core={core} hex={hex}"); + CompareOne::Disagreed { + ours, + core, + reason, + hex, + } => { + panic!( + "cmpct follow split ours={ours} core={core} reason={reason} hex={hex}" + ); } CompareOne::Agreed { accept: true } => { if b.hub.rewind_to_height(0).is_err() { diff --git a/fuzz/fuzz_targets/cmpct_reorg_differential.rs b/fuzz/fuzz_targets/cmpct_reorg_differential.rs index dc658e1b01..212765aaae 100644 --- a/fuzz/fuzz_targets/cmpct_reorg_differential.rs +++ b/fuzz/fuzz_targets/cmpct_reorg_differential.rs @@ -87,7 +87,9 @@ fuzz_target!(|data: &[u8]| { ORACLE_DOWN_STREAK.store(0, Ordering::Relaxed); } CompareOne::Agreed { .. } => note_comparison(), - CompareOne::Disagreed { ours, core, hex } => { + CompareOne::Disagreed { + ours, core, hex, .. + } => { eprintln!("=== CMPCT-REORG-DIFFERENTIAL FUZZ CONSENSUS DIVERGENCE ==="); eprintln!("ours_accept={ours} core_accept={core}"); eprintln!("block_hex={hex}"); diff --git a/fuzz/fuzz_targets/mempool_differential.rs b/fuzz/fuzz_targets/mempool_differential.rs index 79a0997031..0c2ede1527 100644 --- a/fuzz/fuzz_targets/mempool_differential.rs +++ b/fuzz/fuzz_targets/mempool_differential.rs @@ -92,7 +92,9 @@ fuzz_target!(|data: &[u8]| { ORACLE_DOWN_STREAK.store(0, Ordering::Relaxed); } CompareOne::Agreed { .. } => note_comparison(), - CompareOne::Disagreed { ours, core, hex } => { + CompareOne::Disagreed { + ours, core, hex, .. + } => { eprintln!("=== MEMPOOL-DIFFERENTIAL FUZZ CONSENSUS DIVERGENCE ==="); eprintln!("ours_accept={ours} core_accept={core}"); eprintln!("tx_hex={hex}"); diff --git a/fuzz/fuzz_targets/p2p_sequence_differential.rs b/fuzz/fuzz_targets/p2p_sequence_differential.rs index 7a1458701c..3cbbff8c25 100644 --- a/fuzz/fuzz_targets/p2p_sequence_differential.rs +++ b/fuzz/fuzz_targets/p2p_sequence_differential.rs @@ -430,7 +430,9 @@ fuzz_target!(|data: &[u8]| { match compare_one(&b.hub, &mut tip, &b.core.rpc, body) { CompareOne::Agreed { .. } => note_comparison(), CompareOne::NotABlock | CompareOne::Skipped => {} - CompareOne::Disagreed { ours, core, hex } => { + CompareOne::Disagreed { + ours, core, hex, .. + } => { panic!("p2p-sequence block: ours={ours} core={core} hex={hex}"); } CompareOne::Harness(msg) if msg == "oracle dead" => {} @@ -440,7 +442,9 @@ fuzz_target!(|data: &[u8]| { P2pSeqKind::Tx => match compare_tx_bytes(&b.hub, &b.core.rpc, &step.payload) { CompareOne::Agreed { .. } => note_comparison(), CompareOne::NotABlock | CompareOne::Skipped => {} - CompareOne::Disagreed { ours, core, hex } => { + CompareOne::Disagreed { + ours, core, hex, .. + } => { panic!("p2p-sequence tx: ours={ours} core={core} hex={hex}"); } CompareOne::Harness(msg) if msg == "oracle dead" => {} diff --git a/fuzz/fuzz_targets/script_differential.rs b/fuzz/fuzz_targets/script_differential.rs index 04fe436209..61ca52a020 100644 --- a/fuzz/fuzz_targets/script_differential.rs +++ b/fuzz/fuzz_targets/script_differential.rs @@ -89,7 +89,9 @@ fuzz_target!(|data: &[u8]| { ORACLE_DOWN_STREAK.store(0, Ordering::Relaxed); } CompareOne::Agreed { .. } => note_comparison(), - CompareOne::Disagreed { ours, core, hex } => { + CompareOne::Disagreed { + ours, core, hex, .. + } => { eprintln!("=== SCRIPT-DIFFERENTIAL FUZZ CONSENSUS DIVERGENCE ==="); eprintln!("ours_accept={ours} core_accept={core}"); eprintln!("block_hex={hex}"); diff --git a/fuzz/fuzz_targets/script_verify_differential.rs b/fuzz/fuzz_targets/script_verify_differential.rs index e5b4f128f6..793796561f 100644 --- a/fuzz/fuzz_targets/script_verify_differential.rs +++ b/fuzz/fuzz_targets/script_verify_differential.rs @@ -86,7 +86,9 @@ fuzz_target!(|data: &[u8]| { ORACLE_DOWN_STREAK.store(0, Ordering::Relaxed); } CompareOne::Agreed { .. } => note_comparison(), - CompareOne::Disagreed { ours, core, hex } => { + CompareOne::Disagreed { + ours, core, hex, .. + } => { eprintln!("=== SCRIPT-VERIFY-DIFFERENTIAL FUZZ CONSENSUS DIVERGENCE ==="); eprintln!("ours_accept={ours} core_accept={core}"); eprintln!("tx_hex={hex}"); diff --git a/fuzz/src/block_diff.rs b/fuzz/src/block_diff.rs index 4da3c421e9..adad7a5f95 100644 --- a/fuzz/src/block_diff.rs +++ b/fuzz/src/block_diff.rs @@ -137,8 +137,17 @@ pub struct DiffPad { pub enum CompareOne { NotABlock, Skipped, - Agreed { accept: bool }, - Disagreed { ours: bool, core: bool, hex: String }, + Agreed { + accept: bool, + }, + Disagreed { + ours: bool, + core: bool, + hex: String, + /// Core `submitblock` reason when this split came from a reply. + /// Empty when the split has no oracle string. + reason: String, + }, Harness(&'static str), } @@ -966,6 +975,7 @@ pub fn compare_tx_bytes(hub: &ChainHub, oracle: &dyn BlockOracle, raw: &[u8]) -> ours: ours == DiffVerdict::Accept, core: core == DiffVerdict::Accept, hex, + reason: String::new(), } } } @@ -1016,6 +1026,7 @@ pub fn compare_mempool_one( ours: ours == DiffVerdict::Accept, core: core == DiffVerdict::Accept, hex, + reason: String::new(), } } } @@ -1066,6 +1077,7 @@ pub fn compare_script_verify_one( ours: ours == DiffVerdict::Accept, core: core == DiffVerdict::Accept, hex: hex2, + reason: String::new(), } } } @@ -1752,6 +1764,7 @@ fn finish_reorg_compare( ours: true, core: false, hex, + reason: String::new(), } } (DiffVerdict::Reject, DiffVerdict::Reject) => { @@ -1767,6 +1780,7 @@ fn finish_reorg_compare( ours: false, core: true, hex, + reason: String::new(), } } (DiffVerdict::Reject, DiffVerdict::Skip) => { @@ -1833,11 +1847,13 @@ pub fn honest_replay_verdict(ours: DiffVerdict, core: DiffVerdict) -> CompareOne ours: false, core: true, hex: String::new(), + reason: String::new(), }, (DiffVerdict::Accept, DiffVerdict::Reject) => CompareOne::Disagreed { ours: true, core: false, hex: String::new(), + reason: String::new(), }, (DiffVerdict::Reject, DiffVerdict::Reject) => CompareOne::Agreed { accept: false }, (DiffVerdict::Skip, _) | (_, DiffVerdict::Skip) => CompareOne::Skipped, @@ -1877,6 +1893,7 @@ fn replay_honest_twin( ours: true, core: true, hex, + reason: String::new(), }; } match honest_replay_verdict(ours, core) { @@ -1887,7 +1904,12 @@ fn replay_honest_twin( let _ = hub.rewind_to_height(keep); } let _ = oracle.core_rewind_to_height(keep); - CompareOne::Disagreed { ours, core, hex } + CompareOne::Disagreed { + ours, + core, + hex, + reason: String::new(), + } } other => { if ours == DiffVerdict::Accept { @@ -1991,6 +2013,7 @@ fn combine( ours: true, core: false, hex: hex.to_string(), + reason: String::new(), } } (DiffVerdict::Reject, DiffVerdict::Reject) => CompareOne::Agreed { accept: false }, @@ -1998,6 +2021,7 @@ fn combine( ours: false, core: true, hex: hex.to_string(), + reason: String::new(), }, (DiffVerdict::Reject, DiffVerdict::Skip) => CompareOne::Skipped, (DiffVerdict::Skip, DiffVerdict::Accept) => { @@ -2199,6 +2223,7 @@ mod tests { ours: false, core: true, hex: String::new(), + reason: String::new(), } ); } diff --git a/fuzz/src/cmpct_fuzz.rs b/fuzz/src/cmpct_fuzz.rs index be25cef009..3a1ebfdb1b 100644 --- a/fuzz/src/cmpct_fuzz.rs +++ b/fuzz/src/cmpct_fuzz.rs @@ -98,6 +98,24 @@ pub fn cmpct_hsi_regtest_connectable(hsi: &HeaderAndShortIds) -> bool { .is_ok() } +/// Core's compact job sets mocktime to regtest genesis and rejects a header +/// more than two hours later (`time-too-new`). Fold the mix into +/// genesis + one regtest interval through that cap so stamps stay unique. +fn cmpct_header_time(genesis_time: u32, extra: u32) -> u32 { + const MAX_FUTURE: u32 = 2 * 60 * 60; + let earliest = genesis_time.saturating_add(REGTEST_BLOCK_SPACING); + let latest = genesis_time.saturating_add(MAX_FUTURE); + let span = latest.saturating_sub(earliest).saturating_add(1); + earliest.saturating_add(extra % span) +} + +/// Hub accept uses this clock. Wall time would accept a 2011 stamp Core's +/// mocktime calls `time-too-new`. +fn pin_cmpct_hub_clock(hub: &ChainHub) { + let t = genesis_block(&ChainParams::regtest()).header.time; + hub.clock.set_mock(i64::from(t)); +} + /// Decode a compact announcement and restamp a unique grinded height-1 header. pub fn prepare_cmpct_fuzz_hsi(data: &[u8]) -> Option { let mut hsi = decode_cmpct_hsi(data)?; @@ -106,11 +124,7 @@ pub fn prepare_cmpct_fuzz_hsi(data: &[u8]) -> Option { hsi.header.bits = genesis.header.bits; let mix = sha256::Hash::hash(data); let extra = u32::from_le_bytes(mix.to_byte_array()[..4].try_into().ok()?); - hsi.header.time = genesis - .header - .time - .saturating_add(REGTEST_BLOCK_SPACING) - .saturating_add(extra % 10_000); + hsi.header.time = cmpct_header_time(genesis.header.time, extra); grind_regtest_pow(&mut hsi.header); cmpct_hsi_regtest_connectable(&hsi).then_some(hsi) } @@ -213,7 +227,7 @@ fn follow_core_verdict( oracle: &dyn BlockOracle, hex: &str, hash: &str, -) -> Result { +) -> Result<(DiffVerdict, String), CompareOne> { let reply = submit_clearing_invalidate(oracle, hex, hash)?; if matches!(reply, OracleReply::Dead) || (matches!(reply, OracleReply::RpcError) && !oracle.liveness_ok()) @@ -221,16 +235,16 @@ fn follow_core_verdict( return Err(CompareOne::Harness("oracle dead")); } let reason = match &reply { - OracleReply::Reason(s) => s.as_str(), - _ => "", + OracleReply::Reason(s) => s.clone(), + _ => String::new(), }; if matches!(reply, OracleReply::NullAccept) || reason == "duplicate" { - return Ok(DiffVerdict::Accept); + return Ok((DiffVerdict::Accept, String::new())); } if reason == "duplicate-invalid" || reason == "duplicate-inconclusive" { - return Ok(DiffVerdict::Reject); + return Ok((DiffVerdict::Reject, reason)); } - Ok(verdict_from_core_reply(&reply)) + Ok((verdict_from_core_reply(&reply), reason)) } /// Mutated compact body, then the honest block for that header, both through @@ -242,6 +256,7 @@ pub fn follow_invalid_cmpct( mutated: bitcoin::Block, successor: bitcoin::Block, ) -> CompareOne { + pin_cmpct_hub_clock(hub); let succ_hash = successor.block_hash(); if let Err(e) = drain_one(hub, mutated) { return if cmpct_drain_harness(&e) { @@ -251,6 +266,7 @@ pub fn follow_invalid_cmpct( ours: false, core: true, hex: String::new(), + reason: String::new(), } }; } @@ -263,12 +279,13 @@ pub fn follow_invalid_cmpct( ours: false, core: true, hex: String::new(), + reason: String::new(), }; } } let hash = succ_hash.to_string(); let hex = hex_encode(serialize(&successor)); - let core = match follow_core_verdict(oracle, &hex, &hash) { + let (core, reason) = match follow_core_verdict(oracle, &hex, &hash) { Ok(v) => v, Err(fate) => return fate, }; @@ -280,11 +297,13 @@ pub fn follow_invalid_cmpct( ours: true, core: false, hex, + reason, }, (false, DiffVerdict::Accept) => CompareOne::Disagreed { ours: false, core: true, hex, + reason, }, (_, DiffVerdict::Skip) => CompareOne::Skipped, } @@ -390,11 +409,7 @@ fn structured_cmpct_case(data: &[u8]) -> CmpctFuzzCase { }; let genesis = genesis_block(&ChainParams::regtest()); let extra_time = u32::from_le_bytes(mix.to_byte_array()[..4].try_into().unwrap_or([0; 4])); - let time = genesis - .header - .time - .saturating_add(REGTEST_BLOCK_SPACING) - .saturating_add(extra_time % 10_000); + let time = cmpct_header_time(genesis.header.time, extra_time); let block = mine_regtest_paying( genesis.block_hash(), time, @@ -634,10 +649,59 @@ mod tests { ), "{fate:?}" ); + assert!(format!("{fate:?}").contains("bad-txnmrklroot"), "{fate:?}"); assert!(oracle.reconsidered.get()); assert_eq!(oracle.submits.get(), 2); } + /// `[1, 0, 0, 0]` and the overnight crasher `[253, 16]` both hash to a + /// stamp past genesis + 2h. Core's mocktime is genesis. + #[test] + fn prepare_cmpct_stamp_stays_inside_core_mock_window() { + let genesis = genesis_block(&ChainParams::regtest()); + let cap = genesis.header.time + 2 * 60 * 60; + for data in [&[1u8, 0, 0, 0][..], &[253, 16][..]] { + let case = prepare_cmpct_fuzz_case(data).expect("case"); + assert!(case.hsi.header.time > genesis.header.time, "{data:?}"); + assert!( + case.hsi.header.time <= cap, + "{data:?} time {} past cap {cap}", + case.hsi.header.time + ); + } + } + + #[test] + fn follow_past_mock_window_rejects_with_core() { + use rbitcoin_consensus::{genesis_block, mine_regtest_paying, Milestone}; + use rbitcoin_net::ChainHub; + + let oracle = StickyInvalidate { + reconsidered: std::cell::Cell::new(false), + submits: std::cell::Cell::new(0), + before: "time-too-new", + after: OracleReply::Reason("time-too-new".into()), + }; + let (_dir, q) = rbitcoin_query::testutil::tiny_query_labeled("cmpct-follow-future"); + let params = crate::block_diff::diff_regtest_params(); + let hub = ChainHub::new(q, params.clone(), Milestone::NONE); + hub.ensure_genesis().unwrap(); + let genesis = genesis_block(¶ms); + let honest = mine_regtest_paying( + genesis.block_hash(), + genesis.header.time + 2 * 60 * 60 + 60, + 1, + ScriptBuf::from_bytes(vec![0x51]), + vec![], + ); + let mutated = same_hash_merkle_mutant(&honest).expect("mutant"); + let fate = follow_invalid_cmpct(&hub, &oracle, mutated, honest); + assert!( + matches!(fate, CompareOne::Agreed { accept: false }), + "{fate:?}" + ); + } + #[test] fn duplicate_inconclusive_after_reconsider_agrees() { let oracle = StickyInvalidate {