diff --git a/changelog.d/core-edge-pins.md b/changelog.d/core-edge-pins.md new file mode 100644 index 000000000..301bc3aa5 --- /dev/null +++ b/changelog.d/core-edge-pins.md @@ -0,0 +1,3 @@ +Added + +- **Consensus tests pin retarget compact form, exception-block script execution, the anchored script skip, and several block and script edges against Bitcoin Core.** diff --git a/crates/rbitcoin-consensus/src/block/finality_tests.rs b/crates/rbitcoin-consensus/src/block/finality_tests.rs index 69bb92b23..62f705ce7 100644 --- a/crates/rbitcoin-consensus/src/block/finality_tests.rs +++ b/crates/rbitcoin-consensus/src/block/finality_tests.rs @@ -341,3 +341,64 @@ fn structural_bip68_height_lock_skips_missing_coin_mtp() { super::structural_bip68(&query, &block, &ctx, &spends, &create_heights, &mut cache) .expect("height-type lock must not read the coin median"); } + +/// BIP68 walks `txdata[1..]`. A coinbase whose relative lock would fail still connects. +#[test] +fn bip68_skips_a_coinbase_whose_relative_lock_would_fail() { + use crate::{accept_and_connect_block, prepare_regtest_candidate, ConsensusError}; + + let coinbase = bare_tx(2, LockTime::ZERO, Sequence::from_consensus(200)); + assert!( + !sequence_locks_satisfied(&coinbase, &[], &[], 1, 0), + "the same lock fails closed when the coinbase is treated as a normal tx" + ); + + let (_dir, q) = rbitcoin_query::testutil::tiny_query_labeled("bip68-coinbase"); + let params = crate::params::ChainParams::regtest(); + let genesis = bitcoin::blockdata::constants::genesis_block(bitcoin::Network::Regtest); + accept_and_connect_block( + &q, + ¶ms, + rbitcoin_primitives::Height::GENESIS, + &genesis, + crate::Milestone::NONE, + ) + .unwrap(); + let mut block = crate::mine_empty_regtest(genesis.block_hash(), genesis.header.time + 600, 1); + block.txdata[0].version = bitcoin::transaction::Version::TWO; + block.txdata[0].input[0].sequence = Sequence::from_consensus(200); + prepare_regtest_candidate(&mut block, genesis.block_hash(), genesis.header.time + 600); + accept_and_connect_block( + &q, + ¶ms, + rbitcoin_primitives::Height(1), + &block, + crate::Milestone::NONE, + ) + .expect("coinbase sequence is not a BIP68 input"); + assert_eq!(q.tip_height(), Some(rbitcoin_primitives::Height(1))); + + let params = Box::leak(Box::new(crate::params::ChainParams::regtest())); + let ctx = super::ValidationContext::at( + params, + rbitcoin_primitives::Height(6), + crate::milestone::Milestone::NONE, + ); + let (_dir, query) = rbitcoin_query::testutil::tiny_query_labeled("bip68-noncb"); + let create = rbitcoin_primitives::Fk(3); + let mut create_heights = rbitcoin_query::FkMap::default(); + create_heights.insert(create, 5); + let block = csv_block(vec![ + bare_tx(1, LockTime::ZERO, Sequence::MAX), + bare_tx(2, LockTime::ZERO, Sequence::from_consensus(200)), + ]); + let spends = [([0u8; 32], 0u32, rbitcoin_primitives::Fk::NULL, create, 0u32)]; + let mut cache = rbitcoin_query::U32Map::default(); + cache.insert(5, 1_500_000); + let err = super::structural_bip68(&query, &block, &ctx, &spends, &create_heights, &mut cache) + .expect_err("the same lock rejects a non-coinbase"); + assert!( + matches!(err, ConsensusError::BadTx(s) if s.contains("nonfinal")), + "{err:?}" + ); +} diff --git a/crates/rbitcoin-consensus/src/block/sigop_cost_tests.rs b/crates/rbitcoin-consensus/src/block/sigop_cost_tests.rs index a5944ca4d..df6360450 100644 --- a/crates/rbitcoin-consensus/src/block/sigop_cost_tests.rs +++ b/crates/rbitcoin-consensus/src/block/sigop_cost_tests.rs @@ -287,6 +287,47 @@ fn witness_p2wpkh_counts_one() { assert_eq!(witness_sigop_count(&tx, &spks(&prevouts)), 1); } +/// Empty P2WSH has no witness script to count. A v1 program is not P2WPKH or P2WSH. +#[test] +fn witness_empty_p2wsh_and_v1_program_cost_zero() { + let spend = |spk: Vec| { + let prevouts = vec![TxOut { + value: Amount::from_sat(10), + script_pubkey: ScriptBuf::from_bytes(spk), + }]; + let tx = Transaction { + version: TxVersion::TWO, + lock_time: LockTime::ZERO, + input: vec![TxIn { + previous_output: OutPoint { + txid: Txid::from_byte_array([5; 32]), + vout: 0, + }, + script_sig: ScriptBuf::new(), + sequence: Sequence::MAX, + witness: Witness::new(), + }], + output: vec![TxOut { + value: Amount::from_sat(1), + script_pubkey: ScriptBuf::from_bytes(vec![0x51]), + }], + }; + (tx, prevouts) + }; + + let mut p2wsh = vec![0x00, 0x20]; + p2wsh.extend([0u8; 32]); + let (tx, prevouts) = spend(p2wsh); + assert_eq!(witness_sigop_count(&tx, &spks(&prevouts)), 0); + assert_eq!(tx_sigop_cost(&tx, &spks(&prevouts), true, true), 0); + + let mut v1 = vec![0x51, 0x20]; + v1.extend([0xab; 32]); + let (tx, prevouts) = spend(v1); + assert_eq!(witness_sigop_count(&tx, &spks(&prevouts)), 0); + assert_eq!(tx_sigop_cost(&tx, &spks(&prevouts), true, true), 0); +} + #[test] fn witness_sigops_gated_on_witness_flag() { let mut spk = vec![0x00, 0x14]; diff --git a/crates/rbitcoin-consensus/src/block/structure_rule_tests.rs b/crates/rbitcoin-consensus/src/block/structure_rule_tests.rs index e85c9a331..496b3cb97 100644 --- a/crates/rbitcoin-consensus/src/block/structure_rule_tests.rs +++ b/crates/rbitcoin-consensus/src/block/structure_rule_tests.rs @@ -1100,6 +1100,100 @@ fn s8_rejects_empty_or_multi_item_coinbase_witness_reserved() { } } +/// Core `GetWitnessCommitmentIndex` keeps the last output whose script is at +/// least 38 bytes and starts `6a24aa21a9ed`. An earlier match does not win. +#[test] +fn s8_last_witness_commitment_output_wins() { + let mut spend = non_coinbase_spend(17); + spend.input[0].witness = Witness::from_slice(&[vec![0xab]]); + let wtxid = spend.compute_wtxid().to_byte_array(); + let reserved = [0x42u8; 32]; + let good = witness_commitment_script([wtxid], &reserved); + let mut bad = good.clone(); + bad[6] ^= 0xff; + let commit = |bytes: Vec| TxOut { + value: Amount::ZERO, + script_pubkey: ScriptBuf::from_bytes(bytes), + }; + + { + let mut cb = coinbase(1); + cb.input[0].witness = Witness::from_slice(&[reserved.as_slice()]); + cb.output.push(commit(bad.clone())); + cb.output.push(commit(good.clone())); + let b = block_with(vec![cb, spend.clone()]); + validate_block_structure(&b, &ctx_h(1)) + .expect("the last commitment hash is the one checked"); + } + { + let mut cb = coinbase(1); + cb.input[0].witness = Witness::from_slice(&[reserved.as_slice()]); + cb.output.push(commit(good.clone())); + cb.output.push(commit(bad)); + let err = + validate_block_structure(&block_with(vec![cb, spend.clone()]), &ctx_h(1)).unwrap_err(); + assert_bad_block(err, "witness commitment mismatch"); + } + // 37 bytes is under Core's minimum, so a trailing magic prefix does not + // displace the earlier 38-byte commitment. + { + let mut cb = coinbase(1); + cb.input[0].witness = Witness::from_slice(&[reserved.as_slice()]); + cb.output.push(commit(good)); + let mut short = vec![0x6a, 0x24, 0xaa, 0x21, 0xa9, 0xed]; + short.extend([0u8; 31]); + assert_eq!(short.len(), 37); + cb.output.push(commit(short)); + validate_block_structure(&block_with(vec![cb, spend]), &ctx_h(1)) + .expect("a 37-byte trailer is not a witness commitment"); + } +} + +/// Nonce length is exactly 32. Empty and multi-item stacks are pinned separately. +#[test] +fn s8_rejects_nonce_lengths_beside_32() { + let mut spend = non_coinbase_spend(19); + spend.input[0].witness = Witness::from_slice(&[vec![0xab]]); + let wtxid = spend.compute_wtxid().to_byte_array(); + let reserved = [0x11u8; 32]; + let spk = witness_commitment_script([wtxid], &reserved); + for n in [31usize, 33] { + let mut cb = coinbase(1); + cb.output.push(TxOut { + value: Amount::ZERO, + script_pubkey: ScriptBuf::from_bytes(spk.clone()), + }); + cb.input[0].witness = Witness::from_slice(&[vec![0u8; n]]); + let err = + validate_block_structure(&block_with(vec![cb, spend.clone()]), &ctx_h(1)).unwrap_err(); + assert_bad_block(err, "bad-witness-nonce-size"); + } +} + +/// Once segwit is active, witness bytes on the coinbase alone still require a commitment. +#[test] +fn s8_coinbase_witness_without_commitment_is_missing() { + let mut cb = coinbase(1); + cb.input[0].witness = Witness::from_slice(&[&[0u8; 32]]); + let err = validate_block_structure(&block_with(vec![cb]), &ctx_h(1)).unwrap_err(); + assert_bad_block(err, "missing witness commitment"); +} + +/// Length 2 is the floor. Height 1's BIP34 push is one byte (`OP_1`); the +/// length rule rejects that encoding before the height check. +#[test] +fn s9_coinbase_scriptsig_length_two_accepts_one_rejects() { + let cb = coinbase(1); + assert_eq!(cb.input[0].script_sig.len(), 2); + validate_block_structure(&block_with(vec![cb]), &ctx_h(1)).expect("scriptSig length 2"); + + let mut short = coinbase(1); + short.input[0].script_sig = ScriptBuf::from_bytes(vec![0x51]); + assert_eq!(short.input[0].script_sig.len(), 1); + let err = validate_block_structure(&block_with(vec![short]), &ctx_h(1)).unwrap_err(); + assert_bad_block(err, "bad-cb-length"); +} + #[test] fn bip34_wrong_push_encoding_rejected_after_activation() { let p = Box::leak(Box::new(ChainParams::signet())); @@ -1639,6 +1733,114 @@ fn genesis_coinbase_is_never_a_coin() { } } +/// An anchored milestone skips a script that fails, and only on that header path. +/// +/// The spent output is P2PKH and the scriptSig is empty, so the interpreter +/// rejects it. `OP_TRUE` and `OP_RETURN` are not this case. +#[test] +fn anchored_milestone_skips_a_failing_p2pkh_script() { + use crate::milestone::MilestoneAnchor; + use crate::{accept_and_connect_block, mine_regtest_paying, pad_empty_from}; + use bitcoin::hashes::Hash; + + let (_dir, q) = rbitcoin_query::testutil::tiny_query_labeled("anchor-skip-p2pkh"); + let params = ChainParams::regtest(); + let genesis = bitcoin::blockdata::constants::genesis_block(bitcoin::Network::Regtest); + accept_and_connect_block(&q, ¶ms, Height::GENESIS, &genesis, Milestone::NONE).unwrap(); + + let mut spk = vec![0x76, 0xa9, 0x14]; + spk.extend_from_slice(&[0x11; 20]); + spk.extend_from_slice(&[0x88, 0xac]); + let created = mine_regtest_paying( + genesis.block_hash(), + genesis.header.time + 600, + 1, + ScriptBuf::from_bytes(spk), + Vec::new(), + ); + accept_and_connect_block(&q, ¶ms, Height(1), &created, Milestone::NONE).unwrap(); + let coin_txid = created.txdata[0].compute_txid(); + let coin_value = created.txdata[0].output[0].value; + + let maturity = params.coinbase_maturity(); + let (tip, time, _) = pad_empty_from( + &q, + ¶ms, + created.block_hash(), + created.header.time, + 2, + maturity, + 0, + ); + let h = maturity + 1; + let spend = Transaction { + version: TxVersion::ONE, + lock_time: LockTime::ZERO, + input: vec![TxIn { + previous_output: OutPoint { + txid: coin_txid, + vout: 0, + }, + script_sig: ScriptBuf::new(), + sequence: Sequence::MAX, + witness: Witness::new(), + }], + output: vec![TxOut { + value: coin_value - Amount::from_sat(1_000), + script_pubkey: ScriptBuf::from_bytes(vec![0x51]), + }], + }; + let block = mine_regtest_paying( + tip, + time + 600, + h, + ScriptBuf::from_bytes(vec![0x51]), + vec![spend], + ); + let block_hash = block.block_hash().to_byte_array(); + let parent_hash = tip.to_byte_array(); + let one = { + let mut w = [0u8; 32]; + w[31] = 1; + bitcoin::Work::from_be_bytes(w) + }; + let note = |height: u32, hash: [u8; 32]| { + q.note_milestone_header(height, hash, [0; 32], one, Some(one)); + }; + let anchored = |height: u32, hash: [u8; 32]| Milestone { + height, + anchor: Some(MilestoneAnchor { + hash: bitcoin::BlockHash::from_byte_array(hash), + min_work_be: [0xff; 32], + }), + }; + + note(maturity, parent_hash); + let above = anchored(maturity, parent_hash); + let err = accept_and_connect_block(&q, ¶ms, Height(h), &block, above) + .expect_err("one height above the anchor still runs the failing script"); + assert!( + matches!(err, ConsensusError::Script(_)), + "expected a script failure, got {err:?}" + ); + assert_eq!(q.tip_height(), Some(Height(maturity))); + + q.clear_milestone_path_above(0); + let mut wrong = block_hash; + wrong[0] ^= 0xff; + note(h, wrong); + let err = accept_and_connect_block(&q, ¶ms, Height(h), &block, anchored(h, block_hash)) + .expect_err("a different hash at the anchor height does not skip"); + assert!(matches!(err, ConsensusError::Script(_)), "{err:?}"); + assert_eq!(q.tip_height(), Some(Height(maturity))); + + q.clear_milestone_path_above(0); + note(h, block_hash); + accept_and_connect_block(&q, ¶ms, Height(h), &block, anchored(h, block_hash)) + .expect("the anchor hash skips the failing P2PKH script"); + assert_eq!(q.tip_height(), Some(Height(h))); +} + /// An output whose script starts with `OP_RETURN`, or is longer than 10_000 /// bytes, is not a coin. Spending it is a missing prevout with scripts on /// and under a height-only milestone. A 10_000-byte script that does not @@ -2764,6 +2966,109 @@ fn script_flags_follow_core_exception_table() { let f = ScriptVerifyFlags::consensus_at(&ctx, &TAPROOT_EXCEPTION_MAINNET, true); assert!(f.bip16_active && f.witness_active && !f.taproot_active); assert!(f.null_dummy && f.bip66_active); + for f in [ + ScriptVerifyFlags::consensus_at( + &ValidationContext::at(main, Height(692_262), Milestone::NONE), + &[1u8; 32], + true, + ), + ScriptVerifyFlags::consensus_at( + &ValidationContext::at(main, Height(170_060), Milestone::NONE), + &BIP16_EXCEPTION_MAINNET, + false, + ), + f, + ] { + assert!( + !f.cleanstack && !f.low_s && !f.minimal_data && !f.strictenc && !f.const_scriptcode, + "policy flags stay off: {f:?}" + ); + } +} + +/// The exception hashes change execution, not only the flag bits. +/// +/// A taproot key-path signature with hashtype byte `0x00` is invalid when +/// taproot is on and ignored when the mainnet exception turns taproot off. +/// A P2SH redeem of `OP_0` matches the template hash either way. On the BIP16 +/// exception the redeem is not executed. On the next hash it is, and fails. +#[test] +fn exception_hashes_change_which_scripts_run() { + let main = Box::leak(Box::new(ChainParams::mainnet())); + + let mut program = vec![0x51, 0x20]; + program.extend_from_slice(&[0xff; 32]); + let mut sig = vec![0x11u8; 65]; + sig[64] = 0x00; + let tap_tx = Transaction { + version: TxVersion::TWO, + lock_time: LockTime::ZERO, + input: vec![TxIn { + previous_output: OutPoint::null(), + script_sig: ScriptBuf::new(), + sequence: Sequence::MAX, + witness: Witness::from_slice(&[sig.as_slice()]), + }], + output: vec![TxOut { + value: Amount::from_sat(1), + script_pubkey: ScriptBuf::from_bytes(vec![0x51]), + }], + }; + let tap_prev = vec![TxOut { + value: Amount::from_sat(2), + script_pubkey: ScriptBuf::from_bytes(program), + }]; + let tap_flags = |hash: &[u8; 32]| ScriptVerifyFlags::for_block(main, 692_261, hash, 0); + let accept = ScriptCheckJob::new( + tap_prev.clone(), + tap_tx.clone(), + tap_flags(&TAPROOT_EXCEPTION_MAINNET), + ); + crate::script::verify_job_all_inputs(&accept) + .expect("taproot exception does not run the key path"); + let reject = ScriptCheckJob::new(tap_prev, tap_tx, tap_flags(&[1u8; 32])); + let err = crate::script::verify_job_all_inputs(&reject).expect_err("neighbor enforces taproot"); + assert!( + format!("{err}").contains("sighash"), + "expected hashtype 0x00 reject, got {err}" + ); + + let redeem = [0x00u8]; + let redeem_hash = bitcoin::hashes::hash160::Hash::hash(&redeem); + let mut p2sh = vec![0xa9, 0x14]; + p2sh.extend_from_slice(redeem_hash.as_byte_array()); + p2sh.push(0x87); + let p2sh_tx = Transaction { + version: TxVersion::TWO, + lock_time: LockTime::ZERO, + input: vec![TxIn { + previous_output: OutPoint::null(), + script_sig: ScriptBuf::from_bytes(vec![0x01, 0x00]), + sequence: Sequence::MAX, + witness: Witness::new(), + }], + output: vec![TxOut { + value: Amount::from_sat(1), + script_pubkey: ScriptBuf::from_bytes(vec![0x51]), + }], + }; + let p2sh_prev = vec![TxOut { + value: Amount::from_sat(2), + script_pubkey: ScriptBuf::from_bytes(p2sh), + }]; + let p2sh_flags = |hash: &[u8; 32]| ScriptVerifyFlags::for_block(main, 170_060, hash, 0); + let bare = ScriptCheckJob::new( + p2sh_prev.clone(), + p2sh_tx.clone(), + p2sh_flags(&BIP16_EXCEPTION_MAINNET), + ); + crate::script::verify_job_all_inputs(&bare) + .expect("BIP16 exception checks the hash and does not run the redeem"); + let enforced = ScriptCheckJob::new(p2sh_prev, p2sh_tx, p2sh_flags(&[2u8; 32])); + assert!( + crate::script::verify_job_all_inputs(&enforced).is_err(), + "neighbor executes the OP_0 redeem" + ); } /// Confirm jobs share wire Arc — same Transaction address, no deep clone. diff --git a/crates/rbitcoin-consensus/src/header.rs b/crates/rbitcoin-consensus/src/header.rs index 8334a30fe..8eb7f8f36 100644 --- a/crates/rbitcoin-consensus/src/header.rs +++ b/crates/rbitcoin-consensus/src/header.rs @@ -358,6 +358,71 @@ mod median_time_past_tests { ); } + /// Mainnet block 32256 (`000000004f2886a170adb7204cb0c7a824217dd24d11a74423d564c4e0904967`). + /// + /// Parent 32255 time `1262152739`, bits `0x1d00ffff`. Period start is + /// height 30240, time `1261130161`. Timespan `1022578` is inside the + /// clamp, so the compact is Core's `GetCompact` of that product: + /// `0x1d00d86a`. The literal is not produced by calling the function + /// under test. + #[test] + fn mainnet_retarget_32256_matches_core_compact() { + let params = ChainParams::mainnet(); + let got = next_work_bits( + ¶ms, + 32_256, + CompactTarget::from_consensus(0x1d00_ffff), + 1_262_152_739, + 1_262_152_739, + Some(1_261_130_161), + |_| None, + ); + assert_eq!(got, Some(CompactTarget::from_consensus(0x1d00_d86a))); + } + + /// Clamp and pow-limit cap, with expected compacts written as integers. + /// + /// Core order: timespan clamped to `[target/4, target*4]` (1_209_600 is + /// two weeks), `target * timespan / 1_209_600`, then cap at the pow + /// limit, then `GetCompact`. + /// + /// - Quarter floor from `0x1d00ffff` and a 1-second span. Clamped span is + /// 302_400. `0xffff << 208` * 302_400 / 1_209_600 compacts to + /// `0x1c3fffc0`. Without the floor the compact is `0x1a0ddead`. + /// - 4× ceiling from `0x1c1fffe0` (`0xffff << 205`) and a span one second + /// past four periods. Clamped product compacts to `0x1c7fff80`, still + /// under the pow limit. Without the ceiling the compact is `0x1c7fff81`. + /// - Pow-limit cap from `0x1d007fff` (`0x7fff << 208`) and an exact 4× + /// span. The product exceeds the limit. Compact is `0x1d00ffff`. + /// Without the cap it is `0x1d01fffc`. + #[test] + fn retarget_clamps_timespan_and_caps_at_pow_limit() { + let params = ChainParams::mainnet(); + let bits = |prev: u32, prev_time: u32, first: u32| { + next_work_bits( + ¶ms, + 2_016, + CompactTarget::from_consensus(prev), + prev_time, + prev_time, + Some(first), + |_| None, + ) + }; + assert_eq!( + bits(0x1d00_ffff, 2, 1), + Some(CompactTarget::from_consensus(0x1c3f_ffc0)) + ); + assert_eq!( + bits(0x1c1f_ffe0, 4_838_402, 1), + Some(CompactTarget::from_consensus(0x1c7f_ff80)) + ); + assert_eq!( + bits(0x1d00_7fff, 4_838_401, 1), + Some(CompactTarget::from_consensus(0x1d00_ffff)) + ); + } + #[test] fn mtp_times_picks_middle_of_sorted() { assert_eq!(median_time_past_times(&[3, 1, 2]).unwrap(), 2); diff --git a/crates/rbitcoin-consensus/src/script/interpreter.rs b/crates/rbitcoin-consensus/src/script/interpreter.rs index ab3ac57ed..d1816ecfb 100644 --- a/crates/rbitcoin-consensus/src/script/interpreter.rs +++ b/crates/rbitcoin-consensus/src/script/interpreter.rs @@ -1960,6 +1960,20 @@ mod success_and_disabled_tests { eval(&script_ok, SigVersion::WitnessV0).expect("0-of-0 empty dummy"); } + /// Witness v0 rejects a non-empty dummy even though `EvalContext::new` + /// leaves NULLDUMMY off. Core would accept that. Base with the flag off + /// accepts it, which is the version confirm actually runs before segwit. + #[test] + fn witness_v0_nulldummy_rejects_when_flag_is_off() { + let script = vec![0x01, 0xff, 0x00, 0x00, 0xae]; + let err = eval(&script, SigVersion::WitnessV0).unwrap_err(); + assert!( + format!("{err}").contains("NULLDUMMY"), + "witness v0 is stricter than the flag, got {err}" + ); + eval(&script, SigVersion::Base).expect("base with NULLDUMMY off allows the dummy"); + } + /// BIP112: OP_CSV fails when tx.nVersion < 2 (unsigned), matching Core. /// (`docs/external_findings/004-csv-nop-and-scriptnum-width.md`). #[test] diff --git a/crates/rbitcoin-consensus/src/script/mod.rs b/crates/rbitcoin-consensus/src/script/mod.rs index 5b7a7323e..33fc1e664 100644 --- a/crates/rbitcoin-consensus/src/script/mod.rs +++ b/crates/rbitcoin-consensus/src/script/mod.rs @@ -1512,4 +1512,68 @@ mod verify_routing_tests { let err = verify_job_all_inputs(&job).unwrap_err(); assert!(matches!(err, ConsensusError::Script(_))); } + + /// Fast path and interpreter agree on a real P2PKH signature, and both + /// reject the only push that `FindAndDelete` could remove from the + /// 25-byte template (the 20-byte pubkey hash). That stack item is not a + /// verifying signature, so the different sighash is not a chain split. + #[test] + fn p2pkh_fast_path_matches_interpreter_including_hash_collision() { + use bitcoin::secp256k1::{Message, Secp256k1, SecretKey}; + use bitcoin::sighash::{EcdsaSighashType, SighashCache}; + + let secp = Secp256k1::new(); + let sk = SecretKey::from_slice(&[9u8; 32]).unwrap(); + let pk = bitcoin::PublicKey::new(sk.public_key(&secp)); + let spk = ScriptBuf::new_p2pkh(&bitcoin::PubkeyHash::hash(&pk.to_bytes())); + let prevout = TxOut { + value: Amount::from_sat(50_000), + script_pubkey: spk.clone(), + }; + let shell = Transaction { + version: bitcoin::transaction::Version::ONE, + lock_time: LockTime::ZERO, + input: vec![TxIn { + previous_output: OutPoint::null(), + script_sig: ScriptBuf::new(), + sequence: Sequence::MAX, + witness: Witness::new(), + }], + output: vec![TxOut { + value: Amount::from_sat(49_000), + script_pubkey: ScriptBuf::from_bytes(vec![0x51]), + }], + }; + let sighash = SighashCache::new(&shell) + .legacy_signature_hash(0, spk.as_script(), EcdsaSighashType::All as u32) + .unwrap(); + let mut sig = secp + .sign_ecdsa(&Message::from_digest(sighash.to_byte_array()), &sk) + .serialize_der() + .to_vec(); + sig.push(EcdsaSighashType::All as u8); + let mut script_sig = Vec::new(); + script_sig.push(sig.len() as u8); + script_sig.extend_from_slice(&sig); + script_sig.push(pk.to_bytes().len() as u8); + script_sig.extend_from_slice(&pk.to_bytes()); + let mut good = shell.clone(); + good.input[0].script_sig = ScriptBuf::from_bytes(script_sig); + let flags = crate::block::ScriptVerifyFlags::buried(false, false, true, true, false); + let job = ScriptCheckJob::new(vec![prevout.clone()], good, flags); + assert!(verify_job_all_inputs(&job).is_ok()); + assert!(verify_bare(&job, 0, &job.tx).is_ok()); + + let keyhash = bitcoin::PubkeyHash::hash(&pk.to_bytes()); + let mut bad_sig = Vec::new(); + bad_sig.push(20); + bad_sig.extend_from_slice(keyhash.as_byte_array()); + bad_sig.push(pk.to_bytes().len() as u8); + bad_sig.extend_from_slice(&pk.to_bytes()); + let mut bad = shell; + bad.input[0].script_sig = ScriptBuf::from_bytes(bad_sig); + let job = ScriptCheckJob::new(vec![prevout], bad, flags); + assert!(verify_job_all_inputs(&job).is_err()); + assert!(verify_bare(&job, 0, &job.tx).is_err()); + } } diff --git a/crates/rbitcoin-consensus/src/script/p2tr.rs b/crates/rbitcoin-consensus/src/script/p2tr.rs index ab04e3b1c..25e794a57 100644 --- a/crates/rbitcoin-consensus/src/script/p2tr.rs +++ b/crates/rbitcoin-consensus/src/script/p2tr.rs @@ -395,6 +395,112 @@ mod bip341_tests { script::verify_job_all_inputs(&job_for(0x50, true)).expect("leaf 0x50 with annex"); } + /// Unknown leaf versions succeed before the 520-byte and 1000-stack checks. + #[test] + fn unknown_leaf_succeeds_past_witness_limits() { + use bitcoin::consensus::Encodable; + use bitcoin::key::TapTweak; + use bitcoin::secp256k1::Parity; + + fn job(stack: Vec>) -> ScriptCheckJob { + let secp = Secp256k1::new(); + let internal_sk = SecretKey::from_slice(&[3u8; 32]).unwrap(); + let internal_kp = Keypair::from_secret_key(&secp, &internal_sk); + let (internal, _) = internal_kp.x_only_public_key(); + let leaf = ScriptBuf::from_bytes(vec![0x51]); + let leaf_ver = 0xc2u8; + let mut eng = TapLeafHash::engine(); + leaf_ver.consensus_encode(&mut eng).expect("engine"); + leaf.as_script().consensus_encode(&mut eng).expect("engine"); + let node = TapNodeHash::from_byte_array(TapLeafHash::from_engine(eng).to_byte_array()); + let (tweaked, parity) = internal.tap_tweak(&secp, Some(node)); + let output_key = tweaked.to_x_only_public_key(); + let parity_bit = match parity { + Parity::Even => 0u8, + Parity::Odd => 1, + }; + let mut control = Vec::with_capacity(33); + control.push(leaf_ver | parity_bit); + control.extend_from_slice(&internal.serialize()); + let mut wit = stack; + wit.push(leaf.as_bytes().to_vec()); + wit.push(control); + let refs: Vec<&[u8]> = wit.iter().map(|v| v.as_slice()).collect(); + let prevout = TxOut { + value: Amount::from_sat(50_000), + script_pubkey: p2tr_spk(output_key), + }; + let tx = Transaction { + version: bitcoin::transaction::Version::TWO, + lock_time: LockTime::ZERO, + input: vec![TxIn { + previous_output: OutPoint::null(), + script_sig: ScriptBuf::new(), + sequence: Sequence::ENABLE_RBF_NO_LOCKTIME, + witness: Witness::from_slice(&refs), + }], + output: vec![TxOut { + value: Amount::from_sat(49_000), + script_pubkey: ScriptBuf::from_bytes(vec![0x51]), + }], + }; + ScriptCheckJob { + txid: [0u8; 32], + prevouts: crate::block::JobPrevouts::owned(vec![prevout]), + tx: crate::block::JobTx::owned(tx), + flags: crate::block::ScriptVerifyFlags { + bip65_active: true, + bip112_active: true, + bip66_active: true, + bip16_active: true, + taproot_active: true, + minimal_if: false, + nullfail: false, + low_s: false, + strictenc: false, + null_dummy: false, + minimal_data: false, + witness_pubkeytype: false, + witness_active: true, + discourage_upgradable_witness: false, + const_scriptcode: false, + cleanstack: false, + }, + pre: std::sync::OnceLock::new(), + } + } + + let ones: Vec> = (0..1001).map(|_| vec![0x01]).collect(); + script::verify_job_all_inputs(&job(ones)).expect("unknown leaf ignores the 1000 stack cap"); + script::verify_job_all_inputs(&job(vec![vec![0u8; 521]])) + .expect("unknown leaf ignores the 520-byte element cap"); + } + + #[test] + fn tapscript_sighash_single_without_output_fails() { + let (mut job, _) = make_script_path_spend(); + let mut tx = (*job.tx).clone(); + tx.output.clear(); + job.tx = crate::block::JobTx::owned(tx); + let spent = super::crypto::TapSpentHashes::default(); + let leaf = bitcoin::TapLeafHash::from_byte_array([0u8; 32]); + let single = std::cell::OnceCell::new(); + let err = super::crypto::tap_signature_hash( + &job, + 0, + TapSighashType::Single, + &spent, + None, + Some((leaf, 0xffff_ffff)), + &single, + ) + .expect_err("SIGHASH_SINGLE with no output"); + assert!( + format!("{err}").contains("sighash single"), + "expected missing output, got {err}" + ); + } + /// Core ExecuteWitnessScript (TAPSCRIPT): initial stack > 1000 is /// SCRIPT_ERR_STACK_SIZE even if the leaf would drain it. #[test] diff --git a/crates/rbitcoin-consensus/src/script/tests_verify.rs b/crates/rbitcoin-consensus/src/script/tests_verify.rs index 2fd383aef..2c4195517 100644 --- a/crates/rbitcoin-consensus/src/script/tests_verify.rs +++ b/crates/rbitcoin-consensus/src/script/tests_verify.rs @@ -1471,3 +1471,46 @@ fn p2sh_script_pubkey_push_counts_against_max_stack_size() { ); } } + +/// Legacy `SIGHASH_SINGLE` past the last output is `uint256` one, matching Core. +/// The in-range input is a real digest, not that constant. +#[test] +fn legacy_sighash_single_past_last_output_is_uint256_one() { + let spk = ScriptBuf::from_bytes(vec![0x51]); + let input = TxIn { + previous_output: OutPoint::null(), + script_sig: ScriptBuf::new(), + sequence: Sequence::MAX, + witness: Witness::new(), + }; + let one = [ + 1u8, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, + ]; + let past = Transaction { + version: bitcoin::transaction::Version::ONE, + lock_time: LockTime::ZERO, + input: vec![input.clone(), input.clone()], + output: vec![TxOut { + value: Amount::from_sat(1), + script_pubkey: spk.clone(), + }], + }; + let past_hash = SighashCache::new(&past) + .legacy_signature_hash(1, spk.as_script(), EcdsaSighashType::Single as u32) + .unwrap(); + assert_eq!(past_hash.to_byte_array(), one); + let in_range = Transaction { + version: bitcoin::transaction::Version::ONE, + lock_time: LockTime::ZERO, + input: vec![input], + output: vec![TxOut { + value: Amount::from_sat(1), + script_pubkey: spk.clone(), + }], + }; + let in_hash = SighashCache::new(&in_range) + .legacy_signature_hash(0, spk.as_script(), EcdsaSighashType::Single as u32) + .unwrap(); + assert_ne!(in_hash.to_byte_array(), one); +} diff --git a/crates/rbitcoin-net/src/codec.rs b/crates/rbitcoin-net/src/codec.rs index 6eed02fe6..301d2e915 100644 --- a/crates/rbitcoin-net/src/codec.rs +++ b/crates/rbitcoin-net/src/codec.rs @@ -587,6 +587,36 @@ mod tests { } } + /// Post-IBD `FramedMessage::decode` rejects a trailing byte. The same + /// bytes without it are a block. Decode does not touch the invalid-hash set. + #[test] + fn trailing_byte_on_a_regtest_block_is_unknown() { + let genesis = genesis_block(Network::Regtest); + let payload = serialize(&genesis); + let clean = FramedMessage { + magic: Magic::from(Network::Regtest), + command: *b"block\0\0\0\0\0\0\0", + payload: payload.clone(), + }; + match clean.decode().payload() { + NetworkMessage::Block(b) => assert_eq!(b.block_hash(), genesis.block_hash()), + other => panic!("expected block, got {other:?}"), + } + let mut extra = payload; + extra.push(0); + let trailed = FramedMessage { + magic: Magic::from(Network::Regtest), + command: *b"block\0\0\0\0\0\0\0", + payload: extra, + }; + match trailed.decode().payload() { + NetworkMessage::Unknown { command, .. } => { + assert_eq!(command.to_string(), "block"); + } + other => panic!("expected Unknown, got {other:?}"), + } + } + #[test] fn command_bytes_ok_accepts_null_padded() { assert!(command_bytes_ok(b"version\0\0\0\0\0")); diff --git a/docs/consensus-tests.md b/docs/consensus-tests.md index ca95437f3..bfda6eae2 100644 --- a/docs/consensus-tests.md +++ b/docs/consensus-tests.md @@ -2,7 +2,7 @@ Every consensus rule **we implement** (not delegated wholesale to rust-bitcoin) has an automated test that would fail if the check were removed or inverted. -**Out of scope:** full secp256k1 / script-interpreter opcode parity vs Core; rust-bitcoin PoW / `CompactTarget` retarget math; full mainnet retarget golden vectors. +**Out of scope:** full secp256k1 / script-interpreter opcode parity vs Core; every historical mainnet retarget. One boundary is pinned: height 32256 compact `0x1d00d86a`, plus the quarter, 4×, and pow-limit clamp literals (`mainnet_retarget_32256_matches_core_compact`, `retarget_clamps_timespan_and_caps_at_pow_limit`). ## Running @@ -86,11 +86,11 @@ header version/time, `finality_tests`, `sigop_cost_tests`, | S5 | Unique txids | `BadBlock("duplicate txid")` (`bad-txns-inputs-missingorspent`, as Core rejects the second copy at connect) | `structure_rule_tests::s5_rejects_duplicate_txid`; tip cache `chain::tests::hostile_peer_session` | | S6 | Merkle root matches txids, checked before every other body rule; a repeated tail (CVE-2012-2459) is mutated | `BadBlock("merkle root mismatch")`, `BadBlock("bad-txns-duplicate")`; RPC `bad-txnmrklroot` | `structure_rule_tests::s6_rejects_merkle_root_mismatch`, `body_rules_run_after_the_header_merkle_check` (+ `merkle_root_bytes_single_and_odd`; store `merkle_mutation_flags_a_repeated_tail_at_any_level`; RPC `rpc_regtest_from_genesis`) | | S7 | BIP34 height in coinbase (h≥1) | `BadBlock("bip34…")` | `s7_rejects_bip34_missing_at_height_1`, `s7_bip34_not_required_at_height_0`, `s7_regtest_rejects_bip34_missing_at_height_1`, `s7_regtest_bip34_activation_height_override` | -| S8 | Witness commitment when any witness; a commitment needs a 32-byte nonce; reject witness before SegWit activation | missing / mismatch / `BadBlock("unexpected witness before segwit")` | `rpc_regtest_from_genesis` (`missing witness commitment`, `witness commitment mismatch`); `s8_accepts_witness_commitment_with_reserved_value` and `s8_rejects_empty_or_multi_item_coinbase_witness_reserved` (nonce); `s8_mainnet_accepts_pre_segwit_commitment_magic_without_nonce` (pre-segwit `aa21a9ed` is data); `consensus_rules::header_and_spending_boundaries` (connect-path `unexpected witness before segwit`) | -| S9 | Coinbase scriptSig length 2..=100, after the merkle check | `bad-cb-length` | `rpc_regtest_from_genesis` (under 2 and 101 reject; a padded height push and 100 bytes connect); `short_coinbase_under_a_real_header_is_merkle_mismatch` | +| S8 | Witness commitment when any witness; the last output of at least 38 bytes starting `6a24aa21a9ed` is the commitment; a commitment needs a nonce of exactly 32 bytes; reject witness before SegWit activation | missing / mismatch / `bad-witness-nonce-size` / `BadBlock("unexpected witness before segwit")` | `rpc_regtest_from_genesis` (`missing witness commitment`, `witness commitment mismatch`); `s8_last_witness_commitment_output_wins` (last ≥38-byte output; a 37-byte trailer does not count); `s8_coinbase_witness_without_commitment_is_missing` (coinbase witness alone); `s8_accepts_witness_commitment_with_reserved_value` and `s8_rejects_empty_or_multi_item_coinbase_witness_reserved` (empty and multi-item nonce); `s8_rejects_nonce_lengths_beside_32` (31 and 33 bytes); `s8_mainnet_accepts_pre_segwit_commitment_magic_without_nonce` (pre-segwit `aa21a9ed` is data); `consensus_rules::header_and_spending_boundaries` (connect-path `unexpected witness before segwit`) | +| S9 | Coinbase scriptSig length 2..=100, after the merkle check | `bad-cb-length` | `s9_coinbase_scriptsig_length_two_accepts_one_rejects` (length 2 accepts; length 1 is `bad-cb-length` even when the byte is the BIP34 push); `rpc_regtest_from_genesis` (under 2 and 101 reject; a padded height push and 100 bytes connect); `short_coinbase_under_a_real_header_is_merkle_mismatch` | | S10 | Output value / sum ≤ MAX_MONEY | `toolarge` | `rpc_regtest_from_genesis` submit (`bad-txns-vout-toolarge` above MAX_MONEY; MAX_MONEY is `bad-cb-amount`; two under-max outputs sum to `bad-txns-txouttotal-toolarge`) | | S11 | Legacy sigops cost ≤ 80_000 | `bad-blk-sigops` | `rpc_regtest_from_genesis` (20_000 connect / 20_001 reject) | -| S12 | Connect: P2SH + witness sigops (BIP16/BIP141); P2SH scriptSig opcode `> OP_16` → 0; witness sigops whenever the WITNESS script flag is set (every block except the BIP16 exception, as in Core) | `bad-blk-sigops` | `sigop_cost_tests::*` + `p2sh_sigops_non_push_scriptsig_is_zero` + `witness_sigops_gated_on_witness_flag` + `script_flags_follow_core_exception_table` | +| S12 | Connect: P2SH + witness sigops (BIP16/BIP141); P2SH scriptSig opcode `> OP_16` → 0; witness sigops whenever the WITNESS script flag is set (every block except the BIP16 exception, as in Core). Empty P2WSH and a v1 witness program cost 0; empty P2WPKH still costs 1 | `bad-blk-sigops` | `sigop_cost_tests::*` + `witness_empty_p2wsh_and_v1_program_cost_zero` + `witness_p2wpkh_counts_one` + `p2sh_sigops_non_push_scriptsig_is_zero` + `witness_sigops_gated_on_witness_flag` + `script_flags_follow_core_exception_table` | | S13 | Every tx including coinbase has ≥1 output | `no outputs` | `rpc_regtest_from_genesis` submit of a coinbase with no outputs (`bad-txns-vout-empty`); `header_and_spending_boundaries` (non-coinbase empty `vout`) | | S14 | Stripped size ≤ 1_000_000 | `block stripped size too large` | `s14_stripped_size_1_000_000_accepts_1_000_001_rejects` | | S15 | Every tx has ≥1 input | `no inputs` | `s15_rejects_empty_vin` | @@ -107,7 +107,7 @@ The Test column is the witness. Do not copy it into [`TESTING.md`](../TESTING.md | 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`) | -| H5 | `bits == expected_next_bits` | `incorrect proof of work bits` | `header_and_spending_boundaries` (regtest: must equal prev) | +| H5 | `bits == expected_next_bits` | `incorrect proof of work bits` | `header_and_spending_boundaries` (regtest: must equal prev). Mainnet compact and clamps: `mainnet_retarget_32256_matches_core_compact`, `retarget_clamps_timespan_and_caps_at_pow_limit` | | H6 | Target ≤ `pow_limit` | `target above pow limit` | `header_and_spending_boundaries` (`validate_header` with mainnet `pow_limit`) | | 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` (exact +2h is the header unit; not duplicated on the connect journey) | @@ -135,7 +135,7 @@ version floors and exact +2h). | C22 | Subsidy halving interval from params | 50 BTC until interval | `p1_block_subsidy_halvings`; `rejects_coinbase_excess_value_fast`; journey overlay interval=2: `header_and_spending_boundaries` | | C27 | Captured signet/mainnet script-edge wire blocks (not Core JSON) | hash / opcode presence; detached verify | `script_edge_fixtures` | | C28 | Coinbase maturity (`COINBASE_MATURITY`) when the coinbase and its spender are in different blocks of one confirm batch | `BadTx("coinbase immature")` at created+99; created+100 accepts | `consensus_rules::coinbase_maturity_holds_inside_one_confirm_batch`; one-block-per-batch: `header_and_spending_boundaries` | -| C31 | Script flags: P2SH / WITNESS / TAPROOT on every block; Core's mainnet `script_flag_exceptions` replace the set (BIP16 170060 → none, Taproot 692261 → P2SH+WITNESS); DERSIG / CLTV / CSV / NULLDUMMY height-gated. The testnet3 BIP16 exception arrives with #884; once both land, `witness_active = bip16_active` clears WITNESS and TAPROOT for that block too, as in Core | `ScriptVerifyFlags::consensus_at`; script reject below an overlaid segwit height | `script_flags_follow_core_exception_table`; `consensus_rules::witness_program_rules_bind_below_segwit_height` | +| C31 | Script flags: P2SH / WITNESS / TAPROOT on every block; Core's mainnet `script_flag_exceptions` replace the set (BIP16 170060 → none, Taproot 692261 → P2SH+WITNESS); DERSIG / CLTV / CSV / NULLDUMMY height-gated. Policy flags stay off. The testnet3 BIP16 exception arrives with #884; once both land, `witness_active = bip16_active` clears WITNESS and TAPROOT for that block too, as in Core | `ScriptVerifyFlags::consensus_at`; script reject below an overlaid segwit height | `script_flags_follow_core_exception_table`; `exception_hashes_change_which_scripts_run` (taproot hashtype `0x00` accepted only on the exception hash; `OP_0` P2SH redeem not executed only on the BIP16 exception); `consensus_rules::witness_program_rules_bind_below_segwit_height` | | C32 | P2PKH fast path: scriptSig push > 520 falls back to the interpreter | `PUSH_SIZE` | `p2pkh_signature_push_over_520_rejected_pre_bip66` | | C33 | P2WPKH fast path (native + nested): strict DER only with DERSIG; witness element ≤ 520 | `PUSH_SIZE` / DER reject | `p2wpkh_der_follows_bip66_flag_and_push_size` | | C34 | Base CHECKMULTISIG FindAndDelete of an empty sig removes every OP_0 opcode before the other sigs hash scriptCode | accept / `SIG_DER` | `legacy_multisig_empty_sig_deletes_op_0_from_script_code` | @@ -144,9 +144,10 @@ version floors and exact +2h). | C37 | BIP30: a txid is not created again while an unspent output of that txid still exists. Exceptions 91842 and 91880 | every connecting block; `is_bip30_repeat_matches_core` | `buried_rules_and_a_lying_header_path` | | C38 | An input's prevout exists and is still unspent | height-1 coinbase spend accepts | missing txid → `MissingPrevout`; child-before-parent; a second spend of the same outpoint; two spends in one block. `header_and_spending_boundaries` | | C40 | A tx's output sum ≤ its input sum | `in==out` accepts | `in+1` rejects. `header_and_spending_boundaries` | -| C41 | BIP68 relative finality | `nSequence=10` at height 101 accepts; time-type after MTP | `nSequence=200` at 101 rejects; `finality_tests` 109/110 | +| C41 | BIP68 relative finality. The coinbase is not an input: version ≥ 2 and a relative lock that would fail on a non-coinbase still connect | `nSequence=10` at height 101 accepts; time-type after MTP | `nSequence=200` at 101 rejects; `bip68_skips_a_coinbase_whose_relative_lock_would_fail`; `finality_tests` 109/110 | | C42 | An input binds only to an output from an earlier block or an earlier tx in its own block, also inside one multi-block confirm batch (Core connects one block at a time) | `MissingPrevout` (`bad-txns-inputs-missingorspent`) | `consensus_rules::same_batch_spend_of_a_later_block_is_missing`; IBD isolation `batched_spend_of_a_later_block_is_rejected_alone` ([087](./external_findings/087-forward-spend-in-batch.md)); same block: `header_and_spending_boundaries` ([005](./external_findings/005-non-topological-block-accepted.md)) | | C43 | An output whose script starts with `OP_RETURN`, or is longer than 10,000 bytes, is not a coin. A 10,000-byte script that does not start with `OP_RETURN` is a coin. The archive still stores the output | `MissingPrevout` / `bad-txns-inputs-missingorspent`, with scripts on and under a height-only milestone | `spending_an_unspendable_output_is_missing_prevout`; `resolve_prevout_rejects_an_unspendable_script`; `proposal_spend_of_an_unspendable_output_is_missing` | +| C44 | Anchored script skip requires this block's hash and the anchor hash on the header path. A failing script above the anchor, or under a different hash, still runs | confirm accepts only when both hashes match | `anchored_milestone_skips_a_failing_p2pkh_script` (empty P2PKH scriptSig) | ## Adding a new rule