Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions changelog.d/missed-mutant-pins.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
Changed

- Nightly mutants: fee-filter equality, `getblocktxn` depth 10, txid `notfound`, and the send-budget read pause are pinned. BIP34 no longer carries a negative-height encoder. Archive coinbase detection is `OutPoint::is_null`. The no-precompute value sum uses the money-range check and rejects an over-max output as `bad-txns-vout-toolarge`.
118 changes: 117 additions & 1 deletion crates/rbitcoin-consensus/src/block/finality_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -216,7 +216,18 @@ fn height_zero_timelock_block(sequence: u32) -> bitcoin::Block {
block
}

/// Height-type locks ignore coin MTP (write path may leave mtps as 0).
/// Exactly 500_000_000 is a timestamp. One below the threshold is a height.
#[test]
fn locktime_at_threshold_is_a_timestamp() {
let t = LOCKTIME_THRESHOLD;
let tx = bare_tx(1, LockTime::from_time(t).unwrap(), Sequence::ZERO);
assert!(
is_final_tx(&tx, 1, t + 1),
"exactly {t} compares as a time, so a later cutoff is final"
);
assert!(!is_final_tx(&tx, 1, t));
}

#[test]
fn bip68_height_type_ignores_zero_mtp() {
let tx = bare_tx(2, LockTime::ZERO, Sequence::from_consensus(10));
Expand All @@ -225,3 +236,108 @@ fn bip68_height_type_ignores_zero_mtp() {
assert!(sequence_locks_satisfied(&tx, &[100], &[u32::MAX], 110, 0));
assert!(!sequence_locks_satisfied(&tx, &[100], &[0], 109, 0));
}

fn seq_tx(seqs: &[u32]) -> Transaction {
Transaction {
version: bitcoin::transaction::Version(2),
lock_time: LockTime::ZERO,
input: seqs
.iter()
.map(|seq| TxIn {
previous_output: OutPoint::null(),
script_sig: ScriptBuf::new(),
sequence: Sequence::from_consensus(*seq),
witness: Witness::new(),
})
.collect(),
output: vec![TxOut {
value: Amount::from_sat(1),
script_pubkey: ScriptBuf::from_bytes(vec![0x51]),
}],
}
}

fn csv_block(txs: Vec<Transaction>) -> bitcoin::Block {
use bitcoin::block::{Header, Version};
use bitcoin::hashes::Hash;
use bitcoin::{Block, BlockHash, CompactTarget, TxMerkleNode};
let mut block = Block {
header: Header {
version: Version::from_consensus(1),
prev_blockhash: BlockHash::from_byte_array([0; 32]),
merkle_root: TxMerkleNode::from_byte_array([0; 32]),
time: 1_231_006_505,
bits: CompactTarget::from_consensus(0x1d00_ffff),
nonce: 0,
},
txdata: txs,
};
block.header.merkle_root = block.compute_merkle_root().unwrap();
block
}

/// Three two-input txs must connect. `si * n_in` false-rejects the third.
#[test]
fn structural_bip68_multi_input_block_connects() {
let params = Box::leak(Box::new(crate::params::ChainParams::regtest()));
let ctx = super::ValidationContext::at(
params,
rbitcoin_primitives::Height(1),
crate::milestone::Milestone::NONE,
);
let (_dir, query) = rbitcoin_query::testutil::tiny_query_labeled("bip68-multi");
let coinbase = bare_tx(1, LockTime::ZERO, Sequence::MAX);
let spend = |seq: u32| seq_tx(&[seq, seq]);
let block = csv_block(vec![
coinbase,
spend(u32::MAX),
spend(u32::MAX),
spend(u32::MAX),
]);
let spends = vec![
(
[0u8; 32],
0u32,
rbitcoin_primitives::Fk::NULL,
rbitcoin_primitives::Fk::NULL,
0u32
);
6
];
let mut cache = rbitcoin_query::U32Map::default();
cache.insert(0, 1_000);
super::structural_bip68(
&query,
&block,
&ctx,
&spends,
&rbitcoin_query::FkMap::default(),
&mut cache,
)
.expect("a well-formed multi-input block passes the spend cursor");
}

/// Height-type CSV does not prefetch a median. A missing parent header must
/// still pass when the block's own previous median is already cached.
#[test]
fn structural_bip68_height_lock_skips_missing_coin_mtp() {
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-height-mtp");
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),
seq_tx(&[0]),
]);
let spends = [([0u8; 32], 0u32, rbitcoin_primitives::Fk::NULL, create, 0u32)];
let mut cache = rbitcoin_query::U32Map::default();
cache.insert(5, 1_500_000);
super::structural_bip68(&query, &block, &ctx, &spends, &create_heights, &mut cache)
.expect("height-type lock must not read the coin median");
}
56 changes: 23 additions & 33 deletions crates/rbitcoin-consensus/src/block/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -238,7 +238,8 @@ pub fn validate_block_structure_with_pres(
check_bip34_coinbase(&block.txdata[0], ctx.height.0)?;
}

// Only money-range gate. `money_range_out_sum` casts a sum that passed here.
// Money-range gate on the precompute path. `assemble_tx_value_out` applies
// the same per-output check when a tx has no precompute row.
for (tx, p) in block.txdata.iter().zip(pres.iter()) {
for o in &tx.output {
if exceeds_max_money(o.value.to_sat()) {
Expand Down Expand Up @@ -608,32 +609,21 @@ pub(crate) fn check_bip34_coinbase(
/// Serialize `height` the same way Core pushes it into the coinbase scriptSig.
#[must_use]
pub fn bip34_height_script(height: u32) -> Vec<u8> {
let n = height as i64;
if n == 0 {
if height == 0 {
return vec![0x00];
}
if (1..=16).contains(&n) {
return vec![0x50 + n as u8];
if (1..=16).contains(&height) {
return vec![0x50 + height as u8];
}
// Height is at least 17, so the little-endian body is never empty.
let mut num = Vec::new();
let mut abs = n;
let neg = abs < 0;
if neg {
abs = -abs;
}
while abs > 0 {
num.push((abs & 0xff) as u8);
abs >>= 8;
}
if let Some(last) = num.last() {
if last & 0x80 != 0 {
num.push(if neg { 0x80 } else { 0x00 });
} else if neg {
let i = num.len() - 1;
num[i] |= 0x80;
}
} else {
num.push(0);
let mut n = height;
while n > 0 {
num.push((n & 0xff) as u8);
n >>= 8;
}
if num.last().unwrap() & 0x80 != 0 {
num.push(0x00);
}
let mut out = Vec::with_capacity(1 + num.len());
out.push(num.len() as u8);
Expand Down Expand Up @@ -1338,13 +1328,13 @@ fn should_use_pres(ti: usize, len: usize) -> bool {
ti < len
}

/// Core `MAX_MONEY` (21_000_000 BTC). This is the money-range predicate
/// `validate_block_structure_with_pres` uses for each output and for
/// `TxPrecompute::out_sum`.
/// Core `MAX_MONEY` (21_000_000 BTC). Structure validation uses this for
/// each output and for `TxPrecompute::out_sum`. The no-precompute value sum
/// uses it for each output too.
///
/// `Amount::MAX_MONEY` fits in `i64`, so a sum this returns false for cannot
/// become negative when `money_range_out_sum` casts it. That cast is not a
/// second range check.
/// `Amount::MAX_MONEY` fits in `i64`, so a value this returns false for
/// cannot become negative when `money_range_out_sum` or
/// `assemble_tx_value_out` casts it.
#[inline]
fn exceeds_max_money(sats: u64) -> bool {
sats > Amount::MAX_MONEY.to_sat()
Expand Down Expand Up @@ -1471,12 +1461,12 @@ fn assemble_tx_value_out(
None => {
let mut value_out = 0i64;
for o in &tx.output {
let sats = o.value.to_sat() as i64;
if sats < 0 {
return Err(ConsensusError::BadTx("negative output"));
let sats_u = o.value.to_sat();
if exceeds_max_money(sats_u) {
return Err(ConsensusError::BadBlock("bad-txns-vout-toolarge"));
}
value_out = value_out
.checked_add(sats)
.checked_add(sats_u as i64)
.ok_or(ConsensusError::BadTx("value out overflow"))?;
}
Ok(value_out)
Expand Down
21 changes: 21 additions & 0 deletions crates/rbitcoin-consensus/src/block/sigop_cost_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -443,6 +443,27 @@ fn verify_one_script_job_skips_anyone_can_spend() {
crate::block::ScriptVerifyFlags::buried(true, true, true, true, true),
);
assert!(verify_one_script_job(&job).is_ok());

let mut witnessed = job;
witnessed.tx = crate::block::JobTx::owned(Transaction {
version: TxVersion::TWO,
lock_time: LockTime::ZERO,
input: vec![TxIn {
previous_output: OutPoint {
txid: Txid::from_byte_array([9; 32]),
vout: 0,
},
script_sig: ScriptBuf::new(),
sequence: Sequence::MAX,
witness: Witness::from_slice(&[vec![0x01]]),
}],
output: vec![TxOut {
value: Amount::from_sat(1),
script_pubkey: ScriptBuf::from_bytes(vec![0x51]),
}],
});
let err = verify_one_script_job(&witnessed).expect_err("bare OP_TRUE rejects a witness");
assert!(format!("{err}").contains("WITNESS_UNEXPECTED"), "{err:?}");
}

#[test]
Expand Down
126 changes: 126 additions & 0 deletions crates/rbitcoin-consensus/src/block/structure_rule_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -962,6 +962,19 @@ fn script_sigop_count_and_last_push_helpers() {
assert!(!is_p2wsh_program(&[0x51, 0x20]));
}

#[test]
fn last_script_push_rejects_a_non_push_and_a_short_pushdata() {
// OP_16 (0x60) is an empty push. The next opcode is not a push.
assert_eq!(last_script_push(&[0x60]), Some(&[][..]));
assert!(last_script_push(&[0x61]).is_none());
// PUSHDATA2 needs two length bytes. One byte, or a length past the script, is no redeem.
assert!(last_script_push(&[0x4d]).is_none());
assert!(last_script_push(&[0x4d, 0x01]).is_none());
assert!(last_script_push(&[0x4d, 0x02, 0x00, 0x11]).is_none());
// PUSHDATA4 needs four length bytes. Three is no redeem.
assert!(last_script_push(&[0x4e, 0x00, 0x00, 0x00]).is_none());
}

#[test]
fn p3_default_milestone_heights() {
use crate::params::default_milestone_height;
Expand Down Expand Up @@ -2814,6 +2827,119 @@ fn max_money_fits_in_i64_so_the_assemble_cast_is_in_range() {
assert!(i64::try_from(u64::MAX).is_err());
}

#[test]
fn assemble_without_pres_sums_zero_and_rejects_over_max() {
fn one(sats: u64) -> Transaction {
Transaction {
version: TxVersion::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(sats),
script_pubkey: ScriptBuf::from_bytes(vec![0x51]),
}],
}
}
assert_eq!(super::assemble_tx_value_out(&one(0), 0, None).unwrap(), 0);
let max_money = Amount::MAX_MONEY.to_sat();
assert_eq!(
super::assemble_tx_value_out(&one(max_money), 0, None).unwrap(),
max_money as i64
);
assert!(matches!(
super::assemble_tx_value_out(&one(max_money + 1), 0, None),
Err(ConsensusError::BadBlock("bad-txns-vout-toolarge"))
));
}

#[test]
fn witness_commitment_leaves_a_legacy_block_unchanged() {
let mut b = block_with(vec![coinbase(1)]);
let outs = b.txdata[0].output.len();
apply_witness_commitment(&mut b);
assert_eq!(b.txdata[0].output.len(), outs);
assert!(b.txdata[0].input[0].witness.is_empty());
}

#[test]
fn resolve_prevout_rejects_a_same_index_parent() {
use super::{resolve_prevout, AsmPrevoutAcc, TxidMap};
let parent = coinbase(1);
let txid = parent.compute_txid().to_byte_array();
let spend = Transaction {
version: TxVersion::ONE,
lock_time: LockTime::ZERO,
input: vec![TxIn {
previous_output: OutPoint {
txid: parent.compute_txid(),
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]),
}],
};
let block = block_with(vec![parent, spend.clone()]);
let mut index = TxidMap::default();
index.insert(txid, 1usize);
match resolve_prevout(
&block,
spend.input[0].previous_output,
&spend.input[0],
None,
&index,
1,
&rbitcoin_query::BatchParents::new(),
true,
true,
false,
None,
&mut AsmPrevoutAcc::default(),
) {
Err(err) => assert!(format!("{err}").contains("lookup stage miss"), "{err}"),
Ok(_) => panic!("a tx cannot spend an output at its own index"),
}
}

#[test]
fn same_height_confirmed_spender_is_not_below_its_create() {
use super::structural_apply_one_meta;
use rbitcoin_primitives::Fk;
let (_dir, query) = rbitcoin_query::testutil::tiny_query_labeled("spend-same-h");
let store = query.store();
store
.confirmed
.set(rbitcoin_primitives::Height(0), Fk(1))
.unwrap();
store.header_txs.put_range(Fk(1), Fk(1), 1).unwrap();
store.strong_tx.set_strong(Fk(1), Fk(1)).unwrap();
store.rebuild_height_fence().unwrap();
let mut scratch = super::StructuralScratch::default();
scratch.height_by_id.insert(9, 0);
scratch.field_h_by_id.insert(1, 0);
structural_apply_one_meta(
&query,
Some((Fk(1), 0, 0)),
9,
0,
0,
Fk::NULL,
0,
Some(0),
&mut scratch,
)
.expect("spender and create at the same height are not corrupt");
}

#[test]
fn structure_rejects_pres_out_sum_above_max_money_before_assemble_casts_it() {
let max_money = Amount::MAX_MONEY.to_sat();
Expand Down
Loading
Loading