From 6bb5e342fabc3780bd2b83cba237275892844bcc Mon Sep 17 00:00:00 2001 From: adrienlacombe <6303520+adrienlacombe@users.noreply.github.com> Date: Sun, 20 Sep 2026 17:09:01 +0200 Subject: [PATCH] Mask five-byte CSV operands before narrowing --- README.md | 10 ++ src/lib.rs | 13 +- tests/checksequenceverify.rs | 250 +++++++++++++++++++++++++++++++++++ 3 files changed, 268 insertions(+), 5 deletions(-) create mode 100644 tests/checksequenceverify.rs diff --git a/README.md b/README.md index 56dbe7d..ad6972f 100644 --- a/README.md +++ b/README.md @@ -13,6 +13,16 @@ This project is a work-in-progress mostly attempting to facilitate BitVM develop It does not yet fully implement all opcodes, but as a library already gives you pretty good insight into the internals of the execution in a step-wise manner. +`OP_CHECKSEQUENCEVERIFY` accepts five-byte ScriptNum operands. Its relative +lock comparison uses only the type flag (bit 22) and low 16 bits, including +when the operand exceeds `u32::MAX`; the version and disable-bit rules still +apply. Regression tests cover these rules, numeric encoding and rejection +boundaries in legacy, SegWit v0 and Tapscript contexts: + +``` +cargo test --locked --test checksequenceverify +``` + # Usage diff --git a/src/lib.rs b/src/lib.rs index a237437..877dfca 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -398,11 +398,14 @@ impl Exec { None => return false, }; - let lock_time = - match LockTime::from_consensus(u32::try_from(sequence).expect("sequence is u32")) { - Ok(lt) => lt, - Err(_) => return false, - }; + // BIP112 accepts five-byte ScriptNums, so the operand can exceed u32. + // BIP68 compares only the type flag and low 16 bits. Mask before + // narrowing; the caller already checks negativity and the disable bit. + let sequence_mask: i64 = (1 << 22) | 0xffff; + let lock_time = match LockTime::from_consensus((sequence & sequence_mask) as u32) { + Ok(lt) => lt, + Err(_) => return false, + }; match (lock_time, input_lock_time) { (LockTime::Blocks(h1), LockTime::Blocks(h2)) if h1 > h2 => return false, diff --git a/tests/checksequenceverify.rs b/tests/checksequenceverify.rs new file mode 100644 index 0000000..ead92f6 --- /dev/null +++ b/tests/checksequenceverify.rs @@ -0,0 +1,250 @@ +//! BIP112 permits five-byte ScriptNum operands. BIP68 compares only bit 22 +//! and the low 16 bits, after applying the operand/input disable-bit rules. +//! These are interpreter tests, not complete transaction validation. + +use bitcoin::absolute; +use bitcoin::hashes::Hash; +use bitcoin::opcodes::all::{OP_CSV, OP_DROP}; +use bitcoin::script::Builder; +use bitcoin::taproot::TapLeafHash; +use bitcoin::transaction::Version; +use bitcoin::{Sequence, Transaction, TxIn}; +use bitcoin_scriptexec::{Exec, ExecCtx, ExecError, ExecutionResult, Options, TxTemplate}; + +const CONTEXTS: [ExecCtx; 3] = [ExecCtx::Legacy, ExecCtx::SegwitV0, ExecCtx::Tapscript]; +const TIME: u32 = 1 << 22; +const DISABLE: u32 = 1 << 31; + +fn number(value: i64) -> Vec { + if value == 0 { + return vec![]; + } + let mut magnitude = value.unsigned_abs(); + let mut bytes = Vec::new(); + while magnitude != 0 { + bytes.push(magnitude as u8); + magnitude >>= 8; + } + if bytes.last().unwrap() & 0x80 != 0 { + bytes.push(if value < 0 { 0x80 } else { 0 }); + } else if value < 0 { + *bytes.last_mut().unwrap() |= 0x80; + } + bytes +} + +fn run( + context: ExecCtx, + operand: Vec, + version: i32, + sequence: u32, + options: Options, +) -> ExecutionResult { + let script = Builder::new() + .push_opcode(OP_CSV) + .push_opcode(OP_DROP) + .push_int(1) + .into_script(); + let mut exec = Exec::new( + context, + options, + TxTemplate { + tx: Transaction { + version: Version(version), + lock_time: absolute::LockTime::ZERO, + input: vec![TxIn { + sequence: Sequence::from_consensus(sequence), + ..TxIn::default() + }], + output: vec![], + }, + prevouts: vec![], + input_idx: 0, + taproot_annex_scriptleaf: Some((TapLeafHash::all_zeros(), None)), + }, + script, + vec![operand], + ) + .unwrap(); + while exec.exec_next().is_ok() {} + exec.result().unwrap().clone() +} + +fn check(operand: i64, version: i32, sequence: u32, error: Option) { + for context in CONTEXTS { + let result = run( + context, + number(operand), + version, + sequence, + Options::default(), + ); + assert_eq!( + result.error, error, + "{context:?}: operand={operand:#x}, version={version}, sequence={sequence:#x}" + ); + assert_eq!(result.success, error.is_none()); + } +} + +#[test] +fn five_byte_high_bits_do_not_change_the_relative_lock() { + // Each operand is minimally encoded in five bytes, with bit 31 unset. + // The high bits must be discarded before narrowing to a u32. + for high in [1_i64 << 32, 1 << 33, 1 << 38, 0x7f_0000_0000] { + for low in [0, 1, 0xffff] { + check(high | low, 2, low as u32, None); + if low != 0 { + check( + high | low, + 2, + low as u32 - 1, + Some(ExecError::UnsatisfiedLocktime), + ); + } + } + } +} + +#[test] +fn reserved_bits_do_not_change_height_or_time_constraints() { + for reserved in [1_i64 << 16, 1 << 21, 1 << 23, 1 << 30] { + for high in [0, 1_i64 << 32] { + check(high | reserved | 7, 2, 7, None); + check( + high | reserved | 7, + 2, + 6, + Some(ExecError::UnsatisfiedLocktime), + ); + check(high | reserved | i64::from(TIME) | 7, 2, TIME | 7, None); + check( + high | reserved | i64::from(TIME) | 7, + 2, + TIME | 6, + Some(ExecError::UnsatisfiedLocktime), + ); + } + // Reserved bits in the transaction's sequence are ignored as well. + check(7, 2, reserved as u32 | 7, None); + check(i64::from(TIME) | 7, 2, reserved as u32 | TIME | 7, None); + } +} + +#[test] +fn height_and_time_are_distinct_even_for_five_byte_operands() { + for high in [0, 1_i64 << 32, 0x7f_0000_0000] { + for low in [0, 1, 0xffff] { + check( + high | low, + 2, + TIME | low as u32, + Some(ExecError::UnsatisfiedLocktime), + ); + check( + high | i64::from(TIME) | low, + 2, + low as u32, + Some(ExecError::UnsatisfiedLocktime), + ); + check(high | i64::from(TIME) | low, 2, TIME | low as u32, None); + } + } +} + +#[test] +fn transaction_version_and_input_disable_bit_still_apply() { + for operand in [0, 1, 1_i64 << 32, (1 << 32) | 1] { + check(operand, 1, 1, Some(ExecError::UnsatisfiedLocktime)); + check( + operand, + 2, + DISABLE | 1, + Some(ExecError::UnsatisfiedLocktime), + ); + check(operand, 2, u32::MAX, Some(ExecError::UnsatisfiedLocktime)); + check(operand, 2, 1, None); + } +} + +#[test] +fn operand_disable_bit_bypasses_relative_lock_checks() { + for operand in [ + i64::from(DISABLE), + i64::from(DISABLE) | (1_i64 << 32) | 7, + (1_i64 << 39) - 1, + ] { + for version in [1, 2] { + for sequence in [0, 1, TIME | 1, DISABLE, u32::MAX] { + check(operand, version, sequence, None); + } + } + } +} + +#[test] +fn negative_and_overlong_operands_reject_before_masking() { + for operand in [-1, -(1_i64 << 32), -((1_i64 << 39) - 1)] { + check(operand, 2, u32::MAX, Some(ExecError::NegativeLocktime)); + } + for context in CONTEXTS { + for require_minimal in [false, true] { + for operand in [number(1_i64 << 39), vec![0, 0, 0, 0x80, 0, 0]] { + let result = run( + context, + operand, + 2, + 0, + Options { + require_minimal, + ..Options::default() + }, + ); + assert_eq!(result.error, Some(ExecError::ScriptIntNumericOverflow)); + assert!(!result.success); + } + } + } +} + +#[test] +fn numeric_minimality_remains_controlled_by_options() { + for context in CONTEXTS { + for operand in [vec![0], vec![0x80], vec![1, 0], vec![1, 0, 0, 0, 0]] { + let permissive = run( + context, + operand.clone(), + 2, + 1, + Options { + require_minimal: false, + ..Options::default() + }, + ); + assert!(permissive.success, "{context:?}: {operand:?}"); + let minimal = run(context, operand, 2, 1, Options::default()); + assert_eq!(minimal.error, Some(ExecError::MinimalData)); + assert!(!minimal.success); + } + } +} + +#[test] +fn disabling_csv_skips_operand_interpretation() { + for context in CONTEXTS { + for operand in [number(-1), number(1_i64 << 39), vec![0x80]] { + let result = run( + context, + operand, + 1, + u32::MAX, + Options { + verify_csv: false, + ..Options::default() + }, + ); + assert!(result.success); + assert_eq!(result.error, None); + } + } +}