From 4b0e5cf0cabb0bccc73055a520507efed5719e09 Mon Sep 17 00:00:00 2001 From: Andrew Poelstra Date: Wed, 3 Dec 2025 00:02:04 +0000 Subject: [PATCH 1/2] pset finalize: pull txenv logic out of "finalize" method We want to reuse this for "pset run". --- .../cmd/simplicity/pset/finalize.rs | 75 +--------------- .../hal-simplicity/cmd/simplicity/pset/mod.rs | 85 +++++++++++++++++++ src/hal_simplicity.rs | 4 +- 3 files changed, 91 insertions(+), 73 deletions(-) diff --git a/src/bin/hal-simplicity/cmd/simplicity/pset/finalize.rs b/src/bin/hal-simplicity/cmd/simplicity/pset/finalize.rs index e528d42..53033c2 100644 --- a/src/bin/hal-simplicity/cmd/simplicity/pset/finalize.rs +++ b/src/bin/hal-simplicity/cmd/simplicity/pset/finalize.rs @@ -3,12 +3,8 @@ use crate::cmd; -use std::sync::Arc; - -use elements::hashes::Hash as _; use hal_simplicity::hal_simplicity::Program; use hal_simplicity::simplicity::jet; -use hal_simplicity::simplicity::jet::elements::{ElementsEnv, ElementsUtxo}; use super::super::{Error, ErrorExt as _}; use super::UpdatedPset; @@ -61,83 +57,20 @@ fn exec_inner( let input_idx: u32 = input_idx.parse().result_context("parsing input-idx")?; let input_idx_usize = input_idx as usize; // 32->usize cast ok on almost all systems - let n_inputs = pset.n_inputs(); - let input = pset - .inputs_mut() - .get_mut(input_idx_usize) - .ok_or_else(|| { - format!("index {} out-of-range for PSET with {} inputs", input_idx, n_inputs) - }) - .result_context("parsing input index")?; - let program = Program::::from_str(program, Some(witness)) .result_context("parsing program")?; - // 2. Build transaction environment. - // Default to Liquid Testnet genesis block - let genesis_hash = match genesis_hash { - Some(s) => s.parse().result_context("parsing genesis hash")?, - None => elements::BlockHash::from_byte_array([ - // copied out of simplicity-webide source - 0xc1, 0xb1, 0x6a, 0xe2, 0x4f, 0x24, 0x23, 0xae, 0xa2, 0xea, 0x34, 0x55, 0x22, 0x92, - 0x79, 0x3b, 0x5b, 0x5e, 0x82, 0x99, 0x9a, 0x1e, 0xed, 0x81, 0xd5, 0x6a, 0xee, 0x52, - 0x8e, 0xda, 0x71, 0xa7, - ]), - }; - - let cmr = program.cmr(); - // Unlike in the 'update-input' case we don't insist on any particular form of - // the Taptree. We just look for the CMR in the list. - let mut control_block_leaf = None; - for (cb, script_ver) in &input.tap_scripts { - if script_ver.1 == simplicity::leaf_version() && &script_ver.0[..] == cmr.as_ref() { - control_block_leaf = Some((cb.clone(), script_ver.0.clone())); - } - } - let (control_block, tap_leaf) = match control_block_leaf { - Some((cb, leaf)) => (cb, leaf), - None => { - return Err(format!("could not find Simplicity leaf in PSET taptree with CMR {}; did you forget to run 'simplicity pset update-input'?", cmr)) - .result_context("PSET tap_scripts field") - } - }; - - let tx = pset.extract_tx().result_context("extracting transaction from PSET")?; - let tx = Arc::new(tx); - - let input_utxos = pset - .inputs() - .iter() - .enumerate() - .map(|(n, input)| match input.witness_utxo { - Some(ref utxo) => Ok(ElementsUtxo { - script_pubkey: utxo.script_pubkey.clone(), - asset: utxo.asset, - value: utxo.value, - }), - None => Err(format!("witness_utxo field not populated for input {n}")), - }) - .collect::, _>>() - .result_context("extracting input UTXO information")?; - + // 2. Extract transaction environment. + let (tx_env, control_block, tap_leaf) = + super::execution_environment(&pset, input_idx_usize, program.cmr(), genesis_hash)?; let cb_serialized = control_block.serialize(); - let tx_env = ElementsEnv::new( - tx, - input_utxos, - input_idx, - cmr, - control_block, - None, // FIXME populate this; needs https://github.com/BlockstreamResearch/rust-simplicity/issues/315 first - genesis_hash, - ); // 3. Prune program. let redeem_node = program.redeem_node().expect("populated"); let pruned = redeem_node.prune(&tx_env).result_context("pruning program")?; let (prog, witness) = pruned.to_vec_with_witness(); - // Rust makes us re-borrow 'input' mutably since we used 'pset' immutably since we - // last borrowed it. We can unwrap() this time since we know it'll succeed. + // If `execution_environment` above succeeded we are guaranteed that this index is in bounds. let input = &mut pset.inputs_mut()[input_idx_usize]; input.final_script_witness = Some(vec![witness, prog, tap_leaf.into_bytes(), cb_serialized]); diff --git a/src/bin/hal-simplicity/cmd/simplicity/pset/mod.rs b/src/bin/hal-simplicity/cmd/simplicity/pset/mod.rs index aa97ee4..974ded8 100644 --- a/src/bin/hal-simplicity/cmd/simplicity/pset/mod.rs +++ b/src/bin/hal-simplicity/cmd/simplicity/pset/mod.rs @@ -6,7 +6,18 @@ mod extract; mod finalize; mod update_input; +use std::sync::Arc; + +use super::{Error, ErrorExt as _}; use crate::cmd; + +use elements::hashes::Hash as _; +use elements::pset::PartiallySignedTransaction; +use elements::taproot::ControlBlock; +use elements::Script; +use hal_simplicity::simplicity::elements::Transaction; +use hal_simplicity::simplicity::jet::elements::{ElementsEnv, ElementsUtxo}; +use hal_simplicity::simplicity::Cmr; use serde::Serialize; #[derive(Serialize)] @@ -32,3 +43,77 @@ pub fn exec<'a>(matches: &clap::ArgMatches<'a>) { (_, _) => unreachable!("clap prints help"), }; } + +fn execution_environment( + pset: &PartiallySignedTransaction, + input_idx: usize, + cmr: Cmr, + genesis_hash: Option<&str>, +) -> Result<(ElementsEnv>, ControlBlock, Script), Error> { + let n_inputs = pset.n_inputs(); + let input = pset + .inputs() + .get(input_idx) + .ok_or_else(|| { + format!("index {} out-of-range for PSET with {} inputs", input_idx, n_inputs) + }) + .result_context("parsing input index")?; + + // Default to Liquid Testnet genesis block + let genesis_hash = match genesis_hash { + Some(s) => s.parse().result_context("parsing genesis hash")?, + None => elements::BlockHash::from_byte_array([ + // copied out of simplicity-webide source + 0xc1, 0xb1, 0x6a, 0xe2, 0x4f, 0x24, 0x23, 0xae, 0xa2, 0xea, 0x34, 0x55, 0x22, 0x92, + 0x79, 0x3b, 0x5b, 0x5e, 0x82, 0x99, 0x9a, 0x1e, 0xed, 0x81, 0xd5, 0x6a, 0xee, 0x52, + 0x8e, 0xda, 0x71, 0xa7, + ]), + }; + + // Unlike in the 'update-input' case we don't insist on any particular form of + // the Taptree. We just look for the CMR in the list. + let mut control_block_leaf = None; + for (cb, script_ver) in &input.tap_scripts { + if script_ver.1 == simplicity::leaf_version() && &script_ver.0[..] == cmr.as_ref() { + control_block_leaf = Some((cb.clone(), script_ver.0.clone())); + } + } + let (control_block, tap_leaf) = match control_block_leaf { + Some((cb, leaf)) => (cb, leaf), + None => { + return Err(format!("could not find Simplicity leaf in PSET taptree with CMR {}; did you forget to run 'simplicity pset update-input'?", cmr)) + .result_context("PSET tap_scripts field") + } + }; + + let tx = pset.extract_tx().result_context("extracting transaction from PSET")?; + let tx = Arc::new(tx); + + let input_utxos = pset + .inputs() + .iter() + .enumerate() + .map(|(n, input)| match input.witness_utxo { + Some(ref utxo) => Ok(ElementsUtxo { + script_pubkey: utxo.script_pubkey.clone(), + asset: utxo.asset, + value: utxo.value, + }), + None => Err(format!("witness_utxo field not populated for input {n}")), + }) + .collect::, _>>() + .result_context("extracting input UTXO information")?; + + let tx_env = ElementsEnv::new( + tx, + input_utxos, + input_idx as u32, // cast fine, input indices are always small + cmr, + control_block.clone(), + None, // FIXME populate this; needs https://github.com/BlockstreamResearch/rust-simplicity/issues/315 first + genesis_hash, + ); + + // 3. Prune program. + Ok((tx_env, control_block, tap_leaf)) +} diff --git a/src/hal_simplicity.rs b/src/hal_simplicity.rs index e3732d5..396f231 100644 --- a/src/hal_simplicity.rs +++ b/src/hal_simplicity.rs @@ -85,8 +85,8 @@ impl Program { } /// Accessor for the commitment-time program. - pub fn redeem_node(&self) -> Option<&RedeemNode> { - self.redeem_prog.as_ref().map(Arc::as_ref) + pub fn redeem_node(&self) -> Option<&Arc>> { + self.redeem_prog.as_ref() } } From 895cdddf1563fe05bcdfd3a7981aa42900156af7 Mon Sep 17 00:00:00 2001 From: Andrew Poelstra Date: Wed, 3 Dec 2025 00:02:54 +0000 Subject: [PATCH 2/2] add 'simplicity pset run' command which outputs jet calls and inputs and outputs This is a bit of a hacky and incomplete implementation but it's already very useful so I think we should ship it. It runs through a program and outputs information about each jet that it hits. It special-cases the equality jets and also splits up the input so you can see which values it's comparing. It is not encoding input/output data correctly if the data length isn't a multiple of 8 (or 64 actually); it just takes the raw words from the bit machine and outputs them. It also doesn't attempt to parse values or do any other interpretation, except for the equality checks (and it doesn't do eq_1 or eq_2 since those would require parsing out the nybbles rather than just splitting the hex string). It also doesn't understand the `dbg!` construction although this looks like it will not be too hard to add. Should definitely extend and better-document the ExecTracer trait upstream. But for now this is already pretty useful. --- .../hal-simplicity/cmd/simplicity/pset/mod.rs | 3 + .../hal-simplicity/cmd/simplicity/pset/run.rs | 153 ++++++++++++++++++ 2 files changed, 156 insertions(+) create mode 100644 src/bin/hal-simplicity/cmd/simplicity/pset/run.rs diff --git a/src/bin/hal-simplicity/cmd/simplicity/pset/mod.rs b/src/bin/hal-simplicity/cmd/simplicity/pset/mod.rs index 974ded8..16d8a21 100644 --- a/src/bin/hal-simplicity/cmd/simplicity/pset/mod.rs +++ b/src/bin/hal-simplicity/cmd/simplicity/pset/mod.rs @@ -4,6 +4,7 @@ mod create; mod extract; mod finalize; +mod run; mod update_input; use std::sync::Arc; @@ -31,6 +32,7 @@ pub fn cmd<'a>() -> clap::App<'a, 'a> { .subcommand(self::create::cmd()) .subcommand(self::extract::cmd()) .subcommand(self::finalize::cmd()) + .subcommand(self::run::cmd()) .subcommand(self::update_input::cmd()) } @@ -39,6 +41,7 @@ pub fn exec<'a>(matches: &clap::ArgMatches<'a>) { ("create", Some(m)) => self::create::exec(m), ("extract", Some(m)) => self::extract::exec(m), ("finalize", Some(m)) => self::finalize::exec(m), + ("run", Some(m)) => self::run::exec(m), ("update-input", Some(m)) => self::update_input::exec(m), (_, _) => unreachable!("clap prints help"), }; diff --git a/src/bin/hal-simplicity/cmd/simplicity/pset/run.rs b/src/bin/hal-simplicity/cmd/simplicity/pset/run.rs new file mode 100644 index 0000000..4886ef6 --- /dev/null +++ b/src/bin/hal-simplicity/cmd/simplicity/pset/run.rs @@ -0,0 +1,153 @@ +// Copyright 2025 Andrew Poelstra +// SPDX-License-Identifier: CC0-1.0 + +use crate::cmd; + +use hal_simplicity::hal_simplicity::Program; +use hal_simplicity::simplicity::bit_machine::{BitMachine, ExecTracker}; +use hal_simplicity::simplicity::jet; +use hal_simplicity::simplicity::{Cmr, Ihr}; + +use super::super::{Error, ErrorExt as _}; + +pub fn cmd<'a>() -> clap::App<'a, 'a> { + cmd::subcommand("run", "Run a Simplicity program in the context of a PSET input.") + .args(&cmd::opts_networks()) + .args(&[ + cmd::arg("pset", "PSET to update (base64)").takes_value(true).required(true), + cmd::arg("input-index", "the index of the input to sign (decimal)") + .takes_value(true) + .required(true), + cmd::arg("program", "Simplicity program (base64)").takes_value(true).required(true), + cmd::arg("witness", "Simplicity program witness (hex)") + .takes_value(true) + .required(true), + cmd::opt( + "genesis-hash", + "genesis hash of the blockchain the transaction belongs to (hex)", + ) + .short("g") + .required(false), + ]) +} + +pub fn exec<'a>(matches: &clap::ArgMatches<'a>) { + let pset_b64 = matches.value_of("pset").expect("tx mandatory"); + let input_idx = matches.value_of("input-index").expect("input-idx is mandatory"); + let program = matches.value_of("program").expect("program is mandatory"); + let witness = matches.value_of("witness").expect("witness is mandatory"); + let genesis_hash = matches.value_of("genesis-hash"); + + match exec_inner(pset_b64, input_idx, program, witness, genesis_hash) { + Ok(info) => cmd::print_output(matches, &info), + Err(e) => cmd::print_output(matches, &e), + } +} + +#[derive(serde::Serialize)] +struct JetCall { + jet: String, + source_ty: String, + target_ty: String, + success: bool, + input_hex: String, + output_hex: String, + #[serde(skip_serializing_if = "Option::is_none")] + equality_check: Option<(String, String)>, +} + +#[derive(serde::Serialize)] +struct Response { + success: bool, + jets: Vec, +} + +#[allow(clippy::too_many_arguments)] +fn exec_inner( + pset_b64: &str, + input_idx: &str, + program: &str, + witness: &str, + genesis_hash: Option<&str>, +) -> Result { + struct JetTracker(Vec); + impl ExecTracker for JetTracker { + fn track_left(&mut self, _: Ihr) {} + fn track_right(&mut self, _: Ihr) {} + fn track_jet_call( + &mut self, + jet: &J, + input_buffer: &[simplicity::ffi::ffi::UWORD], + output_buffer: &[simplicity::ffi::ffi::UWORD], + success: bool, + ) { + // The word slices are in reverse order for some reason. + // FIXME maybe we should attempt to parse out Simplicity values here which + // can often be displayed in a better way, esp for e.g. option types. + let mut input_hex = String::new(); + for word in input_buffer.iter().rev() { + for byte in word.to_be_bytes() { + input_hex.push_str(&format!("{:02x}", byte)); + } + } + + let mut output_hex = String::new(); + for word in output_buffer.iter().rev() { + for byte in word.to_be_bytes() { + output_hex.push_str(&format!("{:02x}", byte)); + } + } + + let jet_name = jet.to_string(); + let equality_check = match jet_name.as_str() { + "eq_1" => None, // FIXME parse bits out of input + "eq_2" => None, // FIXME parse bits out of input + x if x.strip_prefix("eq_").is_some() => { + let split = input_hex.split_at(input_hex.len() / 2); + Some((split.0.to_owned(), split.1.to_owned())) + } + _ => None, + }; + self.0.push(JetCall { + jet: jet_name, + source_ty: jet.source_ty().to_final().to_string(), + target_ty: jet.target_ty().to_final().to_string(), + success, + input_hex, + output_hex, + equality_check, + }); + } + + fn track_dbg_call(&mut self, _: &Cmr, _: simplicity::Value) {} + fn is_track_debug_enabled(&self) -> bool { + false + } + } + + // 1. Parse everything. + let pset: elements::pset::PartiallySignedTransaction = + pset_b64.parse().result_context("decoding PSET")?; + let input_idx: u32 = input_idx.parse().result_context("parsing input-idx")?; + let input_idx_usize = input_idx as usize; // 32->usize cast ok on almost all systems + + let program = Program::::from_str(program, Some(witness)) + .result_context("parsing program")?; + + // 2. Extract transaction environment. + let (tx_env, _control_block, _tap_leaf) = + super::execution_environment(&pset, input_idx_usize, program.cmr(), genesis_hash)?; + + // 3. Prune program. + let redeem_node = program.redeem_node().expect("populated"); + + let mut mac = + BitMachine::for_program(redeem_node).result_context("constructing bit machine")?; + let mut tracker = JetTracker(vec![]); + // Eat success/failure. FIXME should probably report this to the user. + let success = mac.exec_with_tracker(redeem_node, &tx_env, &mut tracker).is_ok(); + Ok(Response { + success, + jets: tracker.0, + }) +}