From 9b1eddeb4735d1c607fc066aa5290c23b9d8baa1 Mon Sep 17 00:00:00 2001 From: adrienlacombe <6303520+adrienlacombe@users.noreply.github.com> Date: Sun, 20 Sep 2026 16:42:17 +0200 Subject: [PATCH] Account for complete tapscript witnesses in signature budgets --- README.md | 22 ++ src/lib.rs | 78 +++++++ tests/tapscript_budget.rs | 424 ++++++++++++++++++++++++++++++++++++++ 3 files changed, 524 insertions(+) create mode 100644 tests/tapscript_budget.rs diff --git a/README.md b/README.md index 56dbe7d..b43e327 100644 --- a/README.md +++ b/README.md @@ -16,6 +16,28 @@ good insight into the internals of the execution in a step-wise manner. # Usage +## Library: complete tapscript witnesses + +`Exec::new_tapscript(options, transaction_template)` derives the script, initial +data stack, leaf hash, and optional annex from the selected transaction input's +script-path witness. Its BIP342 signature budget is 50 plus the serialized size +of that complete witness, including the script, control block, annex, item count, +and all item length prefixes. Every executed signature opcode with a nonempty +signature consumes 50 units, including a charge that makes execution fail. + +The constructor accepts only structurally valid control blocks with the tapscript +leaf version and requires one prevout per transaction input. An explicitly +supplied `taproot_annex_scriptleaf` must match the witness-derived context; `None` +lets the constructor derive it. It does not validate the output commitment, the +transaction, or relay policy, and it does not resolve unsupported interpreter +behavior such as OP_SUCCESS. Execution options still apply, including experimental +features. It must not be used as a consensus validator. + +`Exec::new` and `Exec::with_stack` remain available for executing explicit scripts +and fragments. Their historical signature budget uses only the serialized data +stack passed as `script_witness`, even when the transaction has a complete +witness; use `new_tapscript` when full witness budget accounting is required. + ## CLI You can simply use `cargo run` or build/intall the binary as follows: diff --git a/src/lib.rs b/src/lib.rs index a237437..d7cdf64 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -207,6 +207,77 @@ impl std::ops::Drop for Exec { } impl Exec { + /// Creates an executor for a Taproot script-path witness in the selected input. + /// + /// The script, initial stack, leaf hash, and optional annex are derived from + /// `tx.tx.input[tx.input_idx].witness`. The BIP342 signature budget includes + /// the serialized *complete* witness: its item count, data, script, control + /// block, annex, and all item length prefixes. If `taproot_annex_scriptleaf` + /// is supplied, it must agree with the derived context; otherwise it is set. + /// A complete set of prevouts is required for signature hashing. + /// + /// This accepts only structurally valid script-path witnesses with the + /// tapscript leaf version. It does not validate the Taproot output commitment, + /// the transaction, or relay policy, and does not add support for OP_SUCCESS + /// or other unsupported interpreter behavior. `opt` still controls script + /// execution, including experimental features. This is not a consensus + /// validation API. + pub fn new_tapscript(opt: Options, mut tx: TxTemplate) -> Result { + let input = tx + .tx + .input + .get(tx.input_idx) + .ok_or(Error::Other("tapscript input index out of bounds"))?; + if tx.prevouts.len() != tx.tx.input.len() { + return Err(Error::Other("tapscript requires one prevout per input")); + } + let witness_size = input.witness.size(); + let start_validation_weight = i64::try_from(witness_size) + .ok() + .and_then(|size| size.checked_add(VALIDATION_WEIGHT_OFFSET)) + .ok_or(Error::Other( + "tapscript witness size exceeds validation budget range", + ))?; + let mut stack = input.witness.to_vec(); + let annex = if stack.len() >= 2 + && stack.last().and_then(|item| item.first()) == Some(&taproot::TAPROOT_ANNEX_PREFIX) + { + stack.pop() + } else { + None + }; + if stack.len() < 2 { + return Err(Error::Other("expected a tapscript script-path witness")); + } + let control = taproot::ControlBlock::decode(&stack.pop().unwrap()) + .map_err(|_| Error::Other("invalid tapscript control block"))?; + if control.leaf_version != taproot::LeafVersion::TapScript { + return Err(Error::Other("unsupported taproot leaf version")); + } + let script = ScriptBuf::from_bytes(stack.pop().unwrap()); + let context = ( + TapLeafHash::from_script(&script, control.leaf_version), + annex, + ); + if let Some(ref supplied) = tx.taproot_annex_scriptleaf { + if supplied != &context { + return Err(Error::Other("taproot context disagrees with input witness")); + } + } + tx.taproot_annex_scriptleaf = Some(context); + let mut exec = Self::new(ExecCtx::Tapscript, opt, tx, script, stack)?; + exec.validation_weight = start_validation_weight; + exec.stats.start_validation_weight = start_validation_weight; + exec.stats.validation_weight = start_validation_weight; + Ok(exec) + } + + /// Creates an executor for an explicit script and initial data stack. + /// + /// For compatibility, the tapscript signature budget uses only the serialized + /// `script_witness` data stack supplied here. It does not include the script, + /// control block, or annex from the transaction. Use [`Self::new_tapscript`] + /// for BIP342 budget accounting from a complete script-path witness. pub fn new( ctx: ExecCtx, opt: Options, @@ -286,6 +357,10 @@ impl Exec { Ok(ret) } + /// Like [`Self::new`], but replaces the initial main and alt stacks. + /// + /// The signature budget retains [`Self::new`]'s data-only accounting based + /// on `script_witness`; replacement stacks do not change it. pub fn with_stack( ctx: ExecCtx, opt: Options, @@ -444,6 +519,9 @@ impl Exec { fn check_sig_tap(&mut self, sig: &[u8], pk: &[u8]) -> Result { if !sig.is_empty() { self.validation_weight -= VALIDATION_WEIGHT_PER_SIGOP_PASSED; + // A failed signature opcode exits before exec_next's normal stats + // update, but its charge still belongs in the remaining budget. + self.stats.validation_weight = self.validation_weight; if self.validation_weight < 0 { return Err(ExecError::TapscriptValidationWeight); } diff --git a/tests/tapscript_budget.rs b/tests/tapscript_budget.rs new file mode 100644 index 0000000..5a7beef --- /dev/null +++ b/tests/tapscript_budget.rs @@ -0,0 +1,424 @@ +use bitcoin::absolute; +use bitcoin::hashes::Hash; +use bitcoin::opcodes::all::*; +use bitcoin::script::Builder; +use bitcoin::secp256k1::{Keypair, Secp256k1, SecretKey}; +use bitcoin::sighash::{Annex, Prevouts, SighashCache, TapSighashType}; +use bitcoin::taproot::{LeafVersion, TapLeafHash}; +use bitcoin::transaction::Version; +use bitcoin::{Amount, ScriptBuf, Transaction, TxIn, TxOut, Witness}; +use bitcoin_scriptexec::{Error, Exec, ExecCtx, ExecError, Options, Stack, TxTemplate}; + +fn keypair() -> Keypair { + Keypair::from_secret_key(&Secp256k1::new(), &SecretKey::from_slice(&[1; 32]).unwrap()) +} + +// Deliberately only a structurally valid control block: these tests concern +// execution context, not Taproot output commitment validation. +fn control(depth: usize) -> Vec { + let mut bytes = vec![0xc0]; + bytes.extend(keypair().x_only_public_key().0.serialize()); + bytes.resize(33 + 32 * depth, 0); + bytes +} + +fn template( + script: &ScriptBuf, + data: Vec>, + control: Vec, + annex: Option>, +) -> TxTemplate { + let mut items = data; + items.push(script.as_bytes().to_vec()); + items.push(control); + if let Some(annex) = annex { + items.push(annex); + } + TxTemplate { + tx: Transaction { + version: Version::TWO, + lock_time: absolute::LockTime::ZERO, + input: vec![TxIn { + witness: Witness::from_slice(&items), + ..TxIn::default() + }], + output: vec![TxOut { + value: Amount::from_sat(9000), + script_pubkey: ScriptBuf::new(), + }], + }, + prevouts: vec![TxOut { + value: Amount::from_sat(10000), + script_pubkey: ScriptBuf::new(), + }], + input_idx: 0, + taproot_annex_scriptleaf: None, + } +} + +fn sign(tx: &mut TxTemplate, script: &ScriptBuf, annex: Option<&[u8]>) { + let hash = SighashCache::new(&tx.tx) + .taproot_signature_hash( + tx.input_idx, + &Prevouts::All(&tx.prevouts), + annex.map(|bytes| Annex::new(bytes).unwrap()), + Some(( + TapLeafHash::from_script(script, LeafVersion::TapScript), + u32::MAX, + )), + TapSighashType::Default, + ) + .unwrap(); + let signature = Secp256k1::new().sign_schnorr_no_aux_rand(&hash.into(), &keypair()); + let mut items = tx.tx.input[tx.input_idx].witness.to_vec(); + items[0] = signature.as_ref().to_vec(); + tx.tx.input[tx.input_idx].witness = Witness::from_slice(&items); +} + +fn run(mut exec: Exec) -> Exec { + while exec.exec_next().is_ok() {} + exec +} + +#[derive(Clone, Copy, Debug)] +enum SigOp { + Check, + Verify, + Add, +} + +fn repeated_script(op: SigOp, checks: usize, padding: usize) -> ScriptBuf { + let mut builder = Builder::new(); + for _ in 0..padding { + builder = builder.push_opcode(OP_NOP); + } + for _ in 0..checks { + builder = builder.push_opcode(OP_2DUP); + builder = match op { + SigOp::Check => builder.push_opcode(OP_CHECKSIG).push_opcode(OP_VERIFY), + SigOp::Verify => builder.push_opcode(OP_CHECKSIGVERIFY), + SigOp::Add => builder + .push_int(0) + .push_opcode(OP_SWAP) + .push_opcode(OP_CHECKSIGADD) + .push_int(1) + .push_opcode(OP_NUMEQUALVERIFY), + }; + } + builder.push_opcode(OP_2DROP).push_int(1).into_script() +} + +fn boundary_exec(op: SigOp, checks: usize, budget: usize) -> Exec { + for padding in 0..budget { + let script = repeated_script(op, checks, padding); + let mut tx = template( + &script, + vec![ + vec![0; 64], + keypair().x_only_public_key().0.serialize().to_vec(), + ], + control(0), + None, + ); + if tx.tx.input[0].witness.size() + 50 == budget { + sign(&mut tx, &script, None); + return Exec::new_tapscript(Options::default(), tx).unwrap(); + } + } + panic!("could not construct exact budget {budget}"); +} + +#[test] +fn exact_exhaustion_and_next_charge_for_all_signature_opcodes() { + for op in [SigOp::Check, SigOp::Verify, SigOp::Add] { + let success = run(boundary_exec(op, 6, 300)); + assert!(success.result().unwrap().success, "{op:?}"); + assert_eq!(success.stats().start_validation_weight, 300); + assert_eq!(success.stats().validation_weight, 0); + + let next = run(boundary_exec(op, 7, 300)); + assert_eq!( + next.result().unwrap().error, + Some(ExecError::TapscriptValidationWeight) + ); + assert_eq!(next.stats().validation_weight, -50, "{op:?}"); + + let short = run(boundary_exec(op, 6, 299)); + assert_eq!( + short.result().unwrap().error, + Some(ExecError::TapscriptValidationWeight) + ); + assert_eq!(short.stats().validation_weight, -1, "{op:?}"); + } +} + +#[test] +fn empty_signatures_do_not_consume_budget() { + let pk = keypair().x_only_public_key().0.serialize(); + for op in [SigOp::Check, SigOp::Verify, SigOp::Add] { + let builder = Builder::new(); + let script = match op { + SigOp::Check => builder + .push_slice(pk) + .push_opcode(OP_CHECKSIG) + .push_opcode(OP_NOT), + SigOp::Verify => builder + .push_slice(pk) + .push_opcode(OP_CHECKSIGVERIFY) + .push_int(1), + SigOp::Add => builder + .push_int(0) + .push_slice(pk) + .push_opcode(OP_CHECKSIGADD) + .push_int(0) + .push_opcode(OP_NUMEQUAL), + } + .into_script(); + let exec = run(Exec::new_tapscript( + Options::default(), + template(&script, vec![vec![]], control(0), None), + ) + .unwrap()); + assert_eq!( + exec.stats().validation_weight, + exec.stats().start_validation_weight + ); + if matches!(op, SigOp::Verify) { + assert_eq!( + exec.result().unwrap().error, + Some(ExecError::CheckSigVerify) + ); + } else { + assert!(exec.result().unwrap().success); + } + } +} + +#[test] +fn unsuccessful_nonempty_signature_still_records_its_charge() { + let script = Builder::new() + .push_slice(keypair().x_only_public_key().0.serialize()) + .push_opcode(OP_CHECKSIG) + .into_script(); + let exec = run(Exec::new_tapscript( + Options::default(), + template(&script, vec![vec![1]], control(0), None), + ) + .unwrap()); + assert_eq!( + exec.result().unwrap().error, + Some(ExecError::SchnorrSigSize) + ); + assert_eq!( + exec.stats().validation_weight, + exec.stats().start_validation_weight - 50 + ); +} + +#[test] +fn compactsize_boundaries_include_all_witness_fields() { + let script_of_len = |len: usize| { + let mut bytes = vec![OP_NOP.to_u8(); len - 1]; + bytes.push(OP_PUSHNUM_1.to_u8()); + ScriptBuf::from_bytes(bytes) + }; + let cases = [ + (1, vec![], 0, None, 87), + (252, vec![], 0, None, 338), + (253, vec![], 0, None, 341), + (1, vec![vec![0; 252]], 0, None, 340), + (1, vec![vec![0; 253]], 0, None, 343), + (1, vec![vec![]; 250], 0, None, 337), + (1, vec![vec![]; 251], 0, None, 340), + (1, vec![], 0, Some(vec![0x50; 252]), 340), + (1, vec![], 0, Some(vec![0x50; 253]), 343), + (1, vec![], 6, None, 279), + (1, vec![], 7, None, 313), + (1, vec![], 128, None, 4185), + ]; + for (script_len, data, depth, annex, expected) in cases { + let count = data.len(); + let script = script_of_len(script_len); + let exec = Exec::new_tapscript( + Options::default(), + template(&script, data, control(depth), annex), + ) + .unwrap(); + assert_eq!(exec.stats().start_validation_weight, expected); + assert_eq!(exec.stack().len(), count); + assert_eq!(exec.remaining_script(), script.as_script()); + } +} + +#[test] +fn derived_annex_and_leaf_context_verify_real_signatures() { + let script = Builder::new() + .push_slice(keypair().x_only_public_key().0.serialize()) + .push_opcode(OP_CHECKSIG) + .into_script(); + for depth in [0, 1, 128] { + for annex in [None, Some(vec![0x50]), Some(vec![0x50; 253])] { + let mut tx = template(&script, vec![vec![0; 64]], control(depth), annex.clone()); + sign(&mut tx, &script, annex.as_deref()); + let exec = run(Exec::new_tapscript(Options::default(), tx).unwrap()); + assert!(exec.result().unwrap().success); + assert_eq!( + exec.stats().validation_weight, + exec.stats().start_validation_weight - 50 + ); + } + } +} + +#[test] +fn budget_and_execution_context_use_only_the_selected_input() { + let first_script = Builder::new().push_opcode(OP_RETURN).into_script(); + let second_script = Builder::new() + .push_slice(keypair().x_only_public_key().0.serialize()) + .push_opcode(OP_CHECKSIG) + .into_script(); + let mut tx = template( + &second_script, + vec![vec![0; 64]], + control(1), + Some(vec![0x50]), + ); + let first = template( + &first_script, + vec![vec![2; 520]; 10], + control(128), + Some(vec![0x50; 253]), + ); + tx.tx.input.insert(0, first.tx.input[0].clone()); + tx.prevouts.insert(0, first.prevouts[0].clone()); + tx.input_idx = 1; + sign(&mut tx, &second_script, Some(&[0x50])); + let selected_budget = 50 + tx.tx.input[1].witness.size() as i64; + let exec = Exec::new_tapscript(Options::default(), tx).unwrap(); + assert_eq!(exec.stats().start_validation_weight, selected_budget); + assert_eq!(exec.stack().len(), 1); + assert_eq!(exec.remaining_script(), second_script.as_script()); + let exec = run(exec); + assert!(exec.result().unwrap().success); + assert_eq!(exec.stats().validation_weight, selected_budget - 50); +} + +#[test] +fn supplied_context_must_match_both_leaf_and_annex() { + let script = Builder::new().push_int(1).into_script(); + let leaf = TapLeafHash::from_script(&script, LeafVersion::TapScript); + for supplied in [ + (TapLeafHash::all_zeros(), Some(vec![0x50])), + (leaf, None), + (leaf, Some(vec![0x50, 1])), + ] { + let mut tx = template(&script, vec![], control(0), Some(vec![0x50])); + tx.taproot_annex_scriptleaf = Some(supplied); + assert_eq!( + Exec::new_tapscript(Options::default(), tx).err(), + Some(Error::Other("taproot context disagrees with input witness")) + ); + } + let mut tx = template(&script, vec![], control(0), Some(vec![0x50])); + tx.taproot_annex_scriptleaf = Some((leaf, Some(vec![0x50]))); + assert!( + run(Exec::new_tapscript(Options::default(), tx).unwrap()) + .result() + .unwrap() + .success + ); +} + +#[test] +fn malformed_or_unsupported_witness_context_returns_an_error() { + let script = Builder::new().push_int(1).into_script(); + for items in [ + vec![], + vec![vec![0; 64]], + vec![vec![0x50]], + vec![vec![0; 64], vec![0x50]], + ] { + let mut tx = template(&script, vec![], control(0), None); + tx.tx.input[0].witness = Witness::from_slice(&items); + assert_eq!( + Exec::new_tapscript(Options::default(), tx).err(), + Some(Error::Other("expected a tapscript script-path witness")) + ); + } + let mut future = control(0); + future[0] = 0xc2; + let mut invalid_key = control(0); + invalid_key[1..33].fill(0xff); + for control in [vec![], vec![0; 32], vec![0; 34], invalid_key, control(129)] { + let tx = template(&script, vec![], control, None); + assert_eq!( + Exec::new_tapscript(Options::default(), tx).err(), + Some(Error::Other("invalid tapscript control block")) + ); + } + assert_eq!( + Exec::new_tapscript(Options::default(), template(&script, vec![], future, None)).err(), + Some(Error::Other("unsupported taproot leaf version")) + ); + + let mut tx = template(&script, vec![], control(0), None); + tx.input_idx = 1; + assert_eq!( + Exec::new_tapscript(Options::default(), tx).err(), + Some(Error::Other("tapscript input index out of bounds")) + ); + let mut tx = template(&script, vec![], control(0), None); + tx.tx.input.clear(); + assert_eq!( + Exec::new_tapscript(Options::default(), tx).err(), + Some(Error::Other("tapscript input index out of bounds")) + ); + for count in [0, 2] { + let mut tx = template(&script, vec![], control(0), None); + tx.prevouts.resize(count, tx.prevouts[0].clone()); + assert_eq!( + Exec::new_tapscript(Options::default(), tx).err(), + Some(Error::Other("tapscript requires one prevout per input")) + ); + } +} + +#[test] +fn explicit_data_stack_constructors_keep_compatibility_accounting() { + let script = Builder::new() + .push_opcode(OP_DROP) + .push_int(1) + .into_script(); + for ctx in [ExecCtx::Legacy, ExecCtx::SegwitV0, ExecCtx::Tapscript] { + let make_tx = || { + let mut tx = template(&script, vec![vec![1]], control(0), Some(vec![0x50; 253])); + tx.taproot_annex_scriptleaf = Some(( + TapLeafHash::from_script(&script, LeafVersion::TapScript), + Some(vec![0x50; 253]), + )); + tx + }; + let exec = run(Exec::new( + ctx, + Options::default(), + make_tx(), + script.clone(), + vec![vec![1]], + ) + .unwrap()); + assert!(exec.result().unwrap().success); + assert_eq!(exec.stats().start_validation_weight, 53); + let exec = Exec::with_stack( + ctx, + Options::default(), + make_tx(), + script.clone(), + vec![vec![1]], + Stack::from_u8_vec(vec![vec![2]; 3]), + Stack::new(), + ) + .unwrap(); + assert_eq!(exec.stats().start_validation_weight, 53); + assert_eq!(exec.stack().len(), 3); + } +}