diff --git a/TESTING.md b/TESTING.md index df4f7622c..c52f43f68 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 000000000..4ccc80afe --- /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 7712eb4e5..0406749ef 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 750d7d0c1..917c5a74b 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 16dcfa0ae..cfdab7813 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 57ac85fa3..eafc8b1eb 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 c763e8f0a..e1a41315a 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 6225a5aac..37301bfe4 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 873c4e6bf..c19554b48 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 dc658e1b0..212765aaa 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 79a099703..0c2ede152 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 7a1458701..3cbbff8c2 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 04fe43620..61ca52a02 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 e5b4f128f..793796561 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 4da3c421e..adad7a5f9 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 be25cef00..3a1ebfdb1 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 {