diff --git a/TESTING.md b/TESTING.md index 820c6682d..2368f1ee2 100644 --- a/TESTING.md +++ b/TESTING.md @@ -224,7 +224,7 @@ Prefer **one high-level scenario** per behavior cluster. Delete lower-level test | `consensus_mature_chain_spend_reconstruct_and_scripthash` | Consensus+query | **One** mature mine: spend, local prev_fk, double-spend, reopen reconstruct (`witness_block_bytes` == serialize), SH history/balance | | `ibd_parallel_archive_idempotent_confirm_without_tx_head` | Query+consensus | Out-of-order archive, re-archive idempotent, head-off prevout+maturity | | `resume_head_off_warms_cache_for_external_prev` | Query+consensus | Resume head-off: warm Class A cache fixes external-prev missing prevout | -| `consensus_rules` (test binary) | Consensus | Focused reject paths for structure/header/connect rules we own — see [`docs/consensus-tests.md`](./docs/consensus-tests.md). Hornet-mapped subset: `./scripts/test-hornet-rules.sh` | +| `consensus_rules` (test binary) | Consensus | Focused reject paths for structure/header/connect rules we own — see [`docs/consensus-tests.md`](./docs/consensus-tests.md). Combined `header_and_spending_boundaries` includes H1/H2/H5. Hornet-mapped subset: `./scripts/test-hornet-rules.sh` | | `core_analogs::analog_milestone_and_mempool_persist` | Consensus | Milestone skip-below/check-above, missing prevout under high milestone, mempool persist (one pad) | | `core_analogs::analog_reconstruct_after_lost_head` | Store+query | Wipe `tx.head/`, reopen, reconstruct height 1 and txid probe. Does **not** pin empty-head / truncated-head / v1 fuse refuse | | `unified_wire_pipeline_multi_block_to_tip` | Consensus+query | Class A archived ahead of tip then `confirm_wire_run` (no re-append + re-entry); then heights 2..=4 unified load/scripts/write | @@ -237,7 +237,7 @@ Prefer **one high-level scenario** per behavior cluster. Delete lower-level test | `electrum_tweaks_subscribe_streams_then_done` | Electrum | Cake `tweaks.subscribe`: one-height result, per-height notifies, then `done` | | `electrum_max_connections_rejects_extra_client` | Electrum | TCP cap drops the extra client | | `electrum_idle_timeout_disconnects_quiet_client` | Electrum | Idle timeout closes a quiet socket | -| `esplora_broadcast_visible_in_rpc_and_electrum` | Node + Electrum + Esplora + RPC | One `run_p2p` datadir: HTTP `sendrawtransaction` / `testmempoolaccept` (allowed + missing-or-spent), Esplora `POST /tx` parent and mempool child appear in `getrawmempool` and Electrum mempool/history (`fee` on unconfirmed, including child `height = -1`). Keep `accept.rs` reject units and RPC dry-run orphan-count | +| `esplora_broadcast_visible_in_rpc_and_electrum` | Node + Electrum + Esplora + RPC | One `run_p2p` datadir: HTTP `sendrawtransaction` / `testmempoolaccept` (allowed, missing-or-spent, min-relay), Esplora `POST /tx` parent and mempool child appear in `getrawmempool` and Electrum mempool/history (`fee` on unconfirmed, including child `height = -1`); `generate` includes those txs; immature coinbase sendraw rejects. Keep `accept.rs` reject units and RPC dry-run orphan-count | | `two_node_header_and_block_sync` | P2P (**default + multinode CI**) | Seeder → peer 8-block IBD; peer `last_write` meter. **Not** re-run under `coverage.sh`. | | `p2p_timeout_getaddr_and_keepalive_ping` | P2P (**default**) | One pad: v1-magic inbound drops at `peertimeout=1`, full-relay GetAddr cache 1000, headers-sync stall replace, self-connect refuses, AddrFetch `getaddr`/`addrv2` (no `getheaders`), one keepalive ping/pong. Sole-preferred stall KEEP stays a PeerHub unit. | | `p2p_compact_hb_getblocktxn_and_orphan` | P2P (**default**) | One mature pad: HB coinbase `cmpctblock`, 2-tx compact → `getblocktxn` + connect, orphan child GetData then parent accept (INV AlreadyHave). Does **not** pin depth-10 full-block serve, tokio-worker lock, or park-not-reject logs | diff --git a/crates/rbitcoin-consensus/tests/script_edge_fixtures.rs b/crates/rbitcoin-consensus/tests/script_edge_fixtures.rs index f4c1631b4..e102ca056 100644 --- a/crates/rbitcoin-consensus/tests/script_edge_fixtures.rs +++ b/crates/rbitcoin-consensus/tests/script_edge_fixtures.rs @@ -1,7 +1,7 @@ //! Consensus script-edge regression fixtures (signet + mainnet wire blocks). //! -//! One integration binary keeps link/build cost low; each `#[test]` still pins a -//! unique hash / opcode / verify path (see docs/consensus-tests.md and comments). +//! Captured blocks are not Core JSON rows. One table pins hash / opcode +//! presence; detached verify stays for the two script-engine edges. use bitcoin::consensus::deserialize; use bitcoin::script::ScriptBuf; @@ -25,72 +25,50 @@ fn hex_bytes(s: &str) -> Vec { .collect() } -// ── signet 200001: OP_CHECKSIGADD (0xba) ───────────────────────────────────── +fn block_contains_byte(b: &Block, needle: u8) -> bool { + for tx in &b.txdata { + for input in &tx.input { + for i in 0..input.witness.len() { + if input.witness.nth(i).unwrap_or(&[]).contains(&needle) { + return true; + } + } + if input.script_sig.as_bytes().contains(&needle) { + return true; + } + } + for o in &tx.output { + if o.script_pubkey.as_bytes().contains(&needle) { + return true; + } + } + } + false +} #[test] -fn block_200001_deserializes_and_matches_reject_hash() { +fn captured_signet_blocks_match_hashes_and_opcodes() { let b = load_block("signet_block_200001.bin"); assert_eq!(b.txdata.len(), 321); assert_eq!( format!("{}", b.block_hash()), "000000ad6bf1ea934186822de99a611924d94aff8fbcb1ad6be2c790c3b92ae1" ); -} - -#[test] -fn block_200001_witnesses_contain_opcode_0xba() { - let b = load_block("signet_block_200001.bin"); - let mut found = false; - for tx in &b.txdata { - for input in &tx.input { - for i in 0..input.witness.len() { - let item = input.witness.nth(i).unwrap_or(&[]); - if item.contains(&0xba) { - found = true; - } - } - } - } assert!( - found, - "expected 0xba (OP_CHECKSIGADD) in some witness element of block 200001" + block_contains_byte(&b, 0xba), + "expected 0xba (OP_CHECKSIGADD) in block 200001" ); -} - -// ── signet 200945: OP_1SUB (0x8c) ──────────────────────────────────────────── -#[test] -fn block_200945_has_op_1sub_and_matches_hash() { let b = load_block("signet_block_200945.bin"); assert_eq!( format!("{}", b.block_hash()), "00000065c6d2d4cb574038892a535c50efd66f28265a6ab4c48bd121fef795f7" ); - let mut found = false; - for tx in &b.txdata { - for input in &tx.input { - for i in 0..input.witness.len() { - if input.witness.nth(i).unwrap_or(&[]).contains(&0x8c) { - found = true; - } - } - if input.script_sig.as_bytes().contains(&0x8c) { - found = true; - } - } - for o in &tx.output { - if o.script_pubkey.as_bytes().contains(&0x8c) { - found = true; - } - } - } - assert!(found, "expected 0x8c (OP_1SUB) in block 200945"); -} - -// ── signet 201393: large tapscript (>10k) ──────────────────────────────────── + assert!( + block_contains_byte(&b, 0x8c), + "expected 0x8c (OP_1SUB) in block 200945" + ); -#[test] -fn block_201393_has_witness_script_over_10k() { let b = load_block("signet_block_201393.bin"); assert_eq!( format!("{}", b.block_hash()), @@ -108,35 +86,20 @@ fn block_201393_has_witness_script_over_10k() { max_item > 10_000, "expected a witness item >10k (tapscript leaf); max={max_item}" ); -} - -// ── signet 204802: P2SH multi-push ─────────────────────────────────────────── -#[test] -fn block_204802_matches_reject_hash() { let b = load_block("signet_block_204802.bin"); assert_eq!( format!("{}", b.block_hash()), "0000004273035bc6ed29b7197e9c7615da498baeedb7d9e1c5edb4479de7ecc4" ); -} - -// ── signet 219477: P2SH cleanstack ─────────────────────────────────────────── -#[test] -fn block_219477_matches_reject_hash() { let b = load_block("signet_block_219477.bin"); assert_eq!( format!("{}", b.block_hash()), "000000d59c5d06312f71cd887a500cfb3ecdfd8563c5205c4a075ac33ae08fbc" ); assert!(b.txdata.len() > 1); -} -// ── signet 277442: CODESEPARATOR + P2WSH CSV ────────────────────────────────── - -#[test] -fn block_277442_matches_reject_hash() { let b = load_block("signet_block_277442.bin"); assert_eq!( format!("{}", b.block_hash()), @@ -154,8 +117,6 @@ fn block_277442_matches_reject_hash() { ); } -// ── signet 90719: BIP342 CODESEPARATOR tapscript ───────────────────────────── - const BLOCK_90719_HASH: &str = "000001425fa8c62dfd856ae0fee3b36add930a5826778f62c54c5e7a089cb2cd"; const SPEND_90719_TXID: &str = "179341698633641e6079171f4a61eb1fe203611df3618e717951f2636a7c5481"; const PREV_90719_VALUE: u64 = 99_639; @@ -163,15 +124,10 @@ const PREV_90719_SPK_HEX: &str = "5120141cf362a850f2bca99e43abca8783cf5db18baadfef55b9769ea285da326c9f"; #[test] -fn block_90719_matches_reject_hash() { +fn block_90719_codeseparator_tapscript_verifies() { let b = load_block("signet_block_90719.bin"); assert_eq!(format!("{}", b.block_hash()), BLOCK_90719_HASH); assert_eq!(b.txdata.len(), 14); -} - -#[test] -fn block_90719_codeseparator_tapscript_verifies() { - let b = load_block("signet_block_90719.bin"); let tx = b .txdata .iter() @@ -220,8 +176,6 @@ fn block_90719_codeseparator_tapscript_verifies() { .expect("BIP342 CODESEPARATOR tapscript must verify"); } -// ── mainnet 290329: P2SH FindAndDelete ─────────────────────────────────────── - const MAINNET_290329: &[u8] = include_bytes!("fixtures/mainnet_block_290329.bin"); const FAIL_TXID_290329: &str = "5df1375ffe61ac35ca178ebb0cab9ea26dedbd0e96005dfcee7e379fa513232f"; diff --git a/crates/rbitcoin-test/tests/consensus_rules.rs b/crates/rbitcoin-test/tests/consensus_rules.rs index 798414c88..f080015ac 100644 --- a/crates/rbitcoin-test/tests/consensus_rules.rs +++ b/crates/rbitcoin-test/tests/consensus_rules.rs @@ -27,41 +27,6 @@ fn connect_genesis(q: &Query, params: &ChainParams) { // ─── Header rules ─────────────────────────────────────────────────────────── -#[test] -fn h1_rejects_wrong_genesis_hash() { - let (_td, q, params) = regtest_q(); - let mut g = regtest_genesis(); - g.header.nonce = g.header.nonce.wrapping_add(1); - // Even if PoW happens to pass regtest, genesis hash check fires first for h=0. - let err = validate_header(&q, ¶ms, Height::GENESIS, &g.header).unwrap_err(); - assert!( - matches!(err, ConsensusError::BadHeader(s) if s.contains("genesis")), - "{err:?}" - ); -} - -#[test] -fn h2_rejects_bad_prev_link() { - let (_td, q, params) = regtest_q(); - connect_genesis(&q, ¶ms); - let g = regtest_genesis(); - let mut b1 = mine_regtest_block(g.block_hash(), g.header.time + 1, 1, vec![]); - b1.header.prev_blockhash = BlockHash::from_byte_array([0xee; 32]); - // Re-mine nonce after prev change (PoW may fail first; BadPrev is the link check). - let target = bitcoin::Target::from_compact(b1.header.bits); - for nonce in 0..100_000u32 { - b1.header.nonce = nonce; - if b1.header.validate_pow(target).is_ok() { - break; - } - } - let err = validate_header(&q, ¶ms, Height(1), &b1.header).unwrap_err(); - assert!( - matches!(err, ConsensusError::BadPrev), - "expected BadPrev, got {err:?}" - ); -} - #[test] fn h4_rejects_checkpoint_mismatch() { let (_td, q, mut params) = regtest_q(); @@ -80,28 +45,6 @@ fn h4_rejects_checkpoint_mismatch() { ); } -#[test] -fn h5_regtest_rejects_wrong_bits() { - let (_td, q, params) = regtest_q(); - connect_genesis(&q, ¶ms); - let g = regtest_genesis(); - let mut b1 = mine_regtest_block(g.block_hash(), g.header.time + 600, 1, vec![]); - // Corrupt bits (regtest has no retarget — must equal prev). - b1.header.bits = CompactTarget::from_consensus(0x207f_fffe); - let target = bitcoin::Target::from_compact(b1.header.bits); - for nonce in 0..100_000u32 { - b1.header.nonce = nonce; - if b1.header.validate_pow(target).is_ok() { - break; - } - } - let err = validate_header(&q, ¶ms, Height(1), &b1.header).unwrap_err(); - assert!( - matches!(err, ConsensusError::BadHeader(s) if s.contains("bits") || s.contains("proof")), - "{err:?}" - ); -} - #[test] fn h6_target_above_pow_limit_is_detectable() { // We reject `target > pow_limit` in validate_header; assert the comparison @@ -192,8 +135,30 @@ fn header_and_spending_boundaries() { let (_td, q, params) = regtest_q(); let g = regtest_genesis(); + let mut bad_g = g.clone(); + bad_g.header.nonce = g.header.nonce.wrapping_add(1); + let err = validate_header(&q, ¶ms, Height::GENESIS, &bad_g.header).unwrap_err(); + assert!( + matches!(err, ConsensusError::BadHeader(s) if s.contains("genesis")), + "h1: {err:?}" + ); accept_and_connect_block(&q, ¶ms, Height::GENESIS, &g, Milestone::NONE).unwrap(); + let mut bad_prev = mine_regtest_block(g.block_hash(), g.header.time + 1, 1, vec![]); + bad_prev.header.prev_blockhash = BlockHash::from_byte_array([0xee; 32]); + grind_pow(&mut bad_prev); + let err = validate_header(&q, ¶ms, Height(1), &bad_prev.header).unwrap_err(); + assert!(matches!(err, ConsensusError::BadPrev), "h2: {err:?}"); + + let mut bad_bits = mine_regtest_block(g.block_hash(), g.header.time + 600, 1, vec![]); + bad_bits.header.bits = CompactTarget::from_consensus(0x207f_fffe); + grind_pow(&mut bad_bits); + let err = validate_header(&q, ¶ms, Height(1), &bad_bits.header).unwrap_err(); + assert!( + matches!(err, ConsensusError::BadHeader(s) if s.contains("bits") || s.contains("proof")), + "h5: {err:?}" + ); + let b1 = mine_regtest_block(g.block_hash(), g.header.time + 600, 1, vec![]); validate_header(&q, ¶ms, Height(1), &b1.header).expect("valid parent, pow, bits"); accept_and_connect_block(&q, ¶ms, Height(1), &b1, Milestone::NONE).unwrap(); diff --git a/crates/rbitcoin-test/tests/cross_surface.rs b/crates/rbitcoin-test/tests/cross_surface.rs index eef4e46a1..3dbf62a5a 100644 --- a/crates/rbitcoin-test/tests/cross_surface.rs +++ b/crates/rbitcoin-test/tests/cross_surface.rs @@ -14,6 +14,7 @@ use rbitcoin_query::Query; use rbitcoin_test::TestDatadir; use serde_json::{json, Value}; use std::net::SocketAddr; +use std::str::FromStr; use std::time::{Duration, Instant}; use tokio::io::{AsyncBufReadExt, AsyncReadExt, AsyncWriteExt, BufReader}; use tokio::net::TcpStream; @@ -183,6 +184,17 @@ async fn esplora_broadcast_visible_in_rpc_and_electrum() { rpc_spend.consensus_encode(&mut rpc_raw).unwrap(); let rpc_hex = rbitcoin_primitives::hex_encode(&rpc_raw); let rpc_txid = rpc_spend.compute_txid().to_string(); + let mut zero_fee = rpc_spend.clone(); + zero_fee.output[0].value = Amount::from_sat(50_0000_0000); + let mut zero_raw = Vec::new(); + zero_fee.consensus_encode(&mut zero_raw).unwrap(); + let zero_hex = rbitcoin_primitives::hex_encode(&zero_raw); + let tma = jsonrpc(rpc_addr, "testmempoolaccept", json!([[zero_hex]])).await; + assert_eq!(tma["result"][0]["allowed"], false, "{tma}"); + assert_eq!( + tma["result"][0]["reject-reason"], "min relay fee not met", + "{tma}" + ); let tma = jsonrpc(rpc_addr, "testmempoolaccept", json!([[rpc_hex.clone()]])).await; assert_eq!(tma["result"][0]["allowed"], true, "{tma}"); let sent = jsonrpc(rpc_addr, "sendrawtransaction", json!([rpc_hex])).await; @@ -359,6 +371,60 @@ async fn esplora_broadcast_visible_in_rpc_and_electrum() { "{child_mem_row}" ); + let mined = jsonrpc(rpc_addr, "generate", json!([1])).await; + assert_eq!( + mined["result"].as_array().map(|a| a.len()), + Some(1), + "{mined}" + ); + let count = jsonrpc(rpc_addr, "getblockcount", json!([])).await; + assert_eq!(count["result"], 103, "{count}"); + let empty = jsonrpc(rpc_addr, "getrawmempool", json!([])).await; + assert_eq!(empty["result"], json!([]), "{empty}"); + let tip = jsonrpc(rpc_addr, "getbestblockhash", json!([])).await; + let blk = jsonrpc(rpc_addr, "getblock", json!([tip["result"].clone(), 2])).await; + let txs = blk["result"]["tx"].as_array().expect("mined tx array"); + assert!( + txs.len() >= 4, + "coinbase + sendraw + esplora parent + child: {blk}" + ); + assert!( + txs.iter().any(|t| t["txid"] == rpc_txid), + "generate must include sendraw: {blk}" + ); + let cb_txid = txs[0]["txid"].as_str().expect("coinbase txid").to_string(); + let cb_val = (txs[0]["vout"][0]["value"].as_f64().unwrap() * 100_000_000.0).round() as u64; + let immature = Transaction { + version: TxVersion::TWO, + lock_time: LockTime::ZERO, + input: vec![TxIn { + previous_output: OutPoint { + txid: Txid::from_str(&cb_txid).expect("coinbase txid"), + vout: 0, + }, + script_sig: ScriptBuf::new(), + sequence: Sequence::ENABLE_RBF_NO_LOCKTIME, + witness: Witness::new(), + }], + output: vec![TxOut { + value: Amount::from_sat(cb_val.saturating_sub(1_000)), + script_pubkey: ScriptBuf::from_bytes(vec![0x51]), + }], + }; + let mut imm_raw = Vec::new(); + immature.consensus_encode(&mut imm_raw).unwrap(); + let imm = jsonrpc( + rpc_addr, + "sendrawtransaction", + json!([rbitcoin_primitives::hex_encode(&imm_raw)]), + ) + .await; + assert_eq!(imm["error"]["code"], -26, "{imm}"); + assert_eq!( + imm["error"]["message"], "bad-txns-premature-spend-of-coinbase", + "{imm}" + ); + let _ = jsonrpc(rpc_addr, "stop", json!([])).await; let stopped = tokio::time::timeout(Duration::from_secs(15), node).await; match stopped { diff --git a/docs/consensus-tests.md b/docs/consensus-tests.md index cfc8c417d..6b0828399 100644 --- a/docs/consensus-tests.md +++ b/docs/consensus-tests.md @@ -18,6 +18,7 @@ cargo test -p rbitcoin-consensus --lib core_tx_ -- --nocapture cargo test -p rbitcoin-consensus --lib core_sighash -- --nocapture cargo test -p rbitcoin-consensus --lib core_bip341 -- --nocapture cargo test -p rbitcoin-consensus --lib block_866342 -- --nocapture +cargo test -p rbitcoin-consensus --test script_edge_fixtures # broader integration still covers connect success paths: cargo test -p rbitcoin-test --test scenarios consensus_ ``` @@ -103,11 +104,11 @@ Location: `crates/rbitcoin-consensus/src/block/structure_rule_tests.rs`. | ID | Rule | Error signal | Test | |----|------|--------------|------| -| H1 | Genesis hash matches params | `BadHeader("genesis hash mismatch")` | `h1_rejects_wrong_genesis_hash` | -| H2 | `prev` links to height−1 | `BadPrev` | `h2_rejects_bad_prev_link` | +| H1 | Genesis hash matches params | `BadHeader("genesis hash mismatch")` | `header_and_spending_boundaries` | +| H2 | `prev` links to height−1 | `BadPrev` | `header_and_spending_boundaries` | | H3 | `time > median_time_past` | `timestamp <= median-time-past` | `header_and_spending_boundaries` (`time==mtp` / `mtp+1`) | | H4 | Checkpoint hash at height | `checkpoint mismatch` | `h4_rejects_checkpoint_mismatch` | -| H5 | `bits == expected_next_bits` | `incorrect proof of work bits` | `h5_regtest_rejects_wrong_bits` (regtest: must equal prev) | +| H5 | `bits == expected_next_bits` | `incorrect proof of work bits` | `header_and_spending_boundaries` (regtest: must equal prev) | | H6 | Target ≤ `pow_limit` | `target above pow limit` | `h6_target_above_pow_limit_is_detectable` | | H7 | PoW valid for claimed bits | `InvalidPow` | `h7_rejects_header_hash_above_target` + smoke via `mine_regtest_block` accept | | H8 | Time not > now + 2h | `timestamp too far in future` | `h8_rejects_timestamp_too_far_in_future` + `h8_timestamp_exactly_two_hours_accepts_plus_one_rejects` | @@ -134,6 +135,7 @@ version floors and exact +2h). | C25 | BIP342 tapscript validation weight | `tapscript validation weight` | `script_path_rejects_tapscript_validation_weight` | | C26 | P2SH scriptSig eval + IsPushOnly | `script too large` / accept OP_1NEGATE | `p2sh_legacy_op_1negate_scriptsig_accepted`, `p2sh_legacy_scriptsig_over_10k_rejected` | | C22 | Subsidy halving interval from params | 50 BTC until interval | `p1_block_subsidy_halvings` | +| C27 | Captured signet/mainnet script-edge wire blocks (not Core JSON) | hash / opcode presence; detached verify | `script_edge_fixtures` | ## Adding a new rule diff --git a/docs/peer-clients.md b/docs/peer-clients.md index 3e85b1ad9..77cfda4ee 100644 --- a/docs/peer-clients.md +++ b/docs/peer-clients.md @@ -105,9 +105,9 @@ reject (or the Core-equivalent edge). Tests live in the suites named in | ID | Hornet rule (`spec.h` / spec.html) | Happy | Boundary / reject | |----|-------------------------------------|-------|-------------------| -| **H01** | Parent hash is a valid header | `header_and_spending_boundaries` (`validate_header` height 1) | `h2_rejects_bad_prev_link` | +| **H01** | Parent hash is a valid header | `header_and_spending_boundaries` (`validate_header` height 1) | `header_and_spending_boundaries` (`BadPrev`) | | **H02** | Header hash `<=` claimed target | same journey (regtest grind) | `h7_rejects_header_hash_above_target` (mainnet bits, nonce misses) | -| **H03** | `nBits` matches difficulty adjust | same journey | `h5_regtest_rejects_wrong_bits`; testnet 20 min min-diff: `testnet_min_difficulty_after_20_minute_gap` | +| **H03** | `nBits` matches difficulty adjust | same journey | `header_and_spending_boundaries` (wrong bits); testnet 20 min min-diff: `testnet_min_difficulty_after_20_minute_gap` | | **H04** | `time > MTP(11)` | journey: `mtp+1` accepts | journey: `time == mtp` rejects | | **H05** | `time <= now + 2h` | `h8_timestamp_exactly_two_hours_accepts_plus_one_rejects` (`now+7200`) | same test (`now+7201`); `h8_rejects_timestamp_too_far_in_future` | | **H06** | Version not retired by BIP34/66/65 | `h9_version_floors_at_bip34_66_65` (v2 @ BIP34, v3 @ BIP66, v4 @ BIP65 / regtest h=1) | same test (v1 @ BIP34, v2 @ BIP66, v3 @ BIP65 / regtest v3) |