From a183dc161ed05c92430e1f828359d1c8e355b732 Mon Sep 17 00:00:00 2001 From: Andrew Poelstra Date: Sat, 25 Oct 2025 14:10:22 +0000 Subject: [PATCH 1/7] simplicity: allow parsing both programs and witnesses as hex or b64 Refactor the code a bit to allow parsing arbitrary strings as either hex or base 64. --- src/hal_simplicity.rs | 34 ++++++++++++++++------------------ src/lib.rs | 14 ++++++++++++++ 2 files changed, 30 insertions(+), 18 deletions(-) diff --git a/src/hal_simplicity.rs b/src/hal_simplicity.rs index 6316a6d..e3732d5 100644 --- a/src/hal_simplicity.rs +++ b/src/hal_simplicity.rs @@ -31,28 +31,26 @@ impl Program { /// The canonical representation of Simplicity programs is base64, but hex is a /// common output mode from rust-simplicity and what you will probably get when /// decoding data straight off the blockchain. + /// + /// The canonical representation of witnesses is hex, but old versions of simc + /// (e.g. every released version, and master, as of 2025-10-25) output base64. pub fn from_str(prog_b64: &str, wit_hex: Option<&str>) -> Result { - // Attempt to decode a program from base64, and failing that, try hex. - let commit_prog = match CommitNode::from_str(prog_b64) { - Ok(prog) => prog, - Err(e) => { - use simplicity::hex::FromHex as _; - if let Ok(bytes) = Vec::from_hex(prog_b64) { - let iter = simplicity::BitIter::new(bytes.into_iter()); - if let Ok(node) = CommitNode::decode(iter) { - node - } else { - return Err(e); - } - } else { - return Err(e); - } - } - }; + let prog_bytes = crate::hex_or_base64(prog_b64).map_err(ParseError::Base64)?; + let iter = BitIter::new(prog_bytes.iter().copied()); + let commit_prog = CommitNode::decode(iter).map_err(ParseError::Decode)?; + + let redeem_prog = wit_hex + .map(|wit_hex| { + let wit_bytes = crate::hex_or_base64(wit_hex).map_err(ParseError::Base64)?; + let prog_iter = BitIter::new(prog_bytes.into_iter()); + let wit_iter = BitIter::new(wit_bytes.into_iter()); + RedeemNode::decode(prog_iter, wit_iter).map_err(ParseError::Decode) + }) + .transpose()?; Ok(Self { commit_prog, - redeem_prog: wit_hex.map(|hex| RedeemNode::from_str(prog_b64, hex)).transpose()?, + redeem_prog, }) } diff --git a/src/lib.rs b/src/lib.rs index b39f823..c9e10c9 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -45,3 +45,17 @@ pub trait GetInfo { /// Get a description of this object given the network of interest. fn get_info(&self, network: Network) -> T; } + +/// Parse a string which may be base64 or hex-encoded. +/// +/// An even-length string with exclusively lowercase hex characters will be parsed as hex; +/// failing that, it will be parsed as base64 and return an error accordingly. +pub fn hex_or_base64(s: &str) -> Result, simplicity::base64::DecodeError> { + if s.len() % 2 == 0 && s.bytes().all(|b| b.is_ascii_hexdigit() && b.is_ascii_lowercase()) { + use simplicity::hex::FromHex as _; + Ok(Vec::from_hex(s).expect("charset checked above")) + } else { + use simplicity::base64::prelude::Engine as _; + simplicity::base64::prelude::BASE64_STANDARD.decode(s) + } +} From 5015d4aadc0ce011cb2618b46a392f6c08788418 Mon Sep 17 00:00:00 2001 From: Andrew Poelstra Date: Sat, 25 Oct 2025 18:01:27 +0000 Subject: [PATCH 2/7] simplicity: move parse_elements_utxo up from sighash to mod We will want to be able to parse this format for PSET support. --- src/bin/hal-simplicity/cmd/simplicity/mod.rs | 48 +++++++++++++++++ .../hal-simplicity/cmd/simplicity/sighash.rs | 51 ++----------------- 2 files changed, 51 insertions(+), 48 deletions(-) diff --git a/src/bin/hal-simplicity/cmd/simplicity/mod.rs b/src/bin/hal-simplicity/cmd/simplicity/mod.rs index 67151f8..1c65237 100644 --- a/src/bin/hal-simplicity/cmd/simplicity/mod.rs +++ b/src/bin/hal-simplicity/cmd/simplicity/mod.rs @@ -5,6 +5,10 @@ mod info; mod sighash; use crate::cmd; +use hal_simplicity::simplicity::bitcoin::{Amount, Denomination}; +use hal_simplicity::simplicity::elements::confidential; +use hal_simplicity::simplicity::elements::hex::FromHex as _; +use hal_simplicity::simplicity::jet::elements::ElementsUtxo; use serde::Serialize; @@ -40,3 +44,47 @@ pub fn execute<'a>(matches: &clap::ArgMatches<'a>) { (_, _) => unreachable!("clap prints help"), }; } + +fn parse_elements_utxo(s: &str) -> Result { + let parts: Vec<&str> = s.split(':').collect(); + if parts.len() != 3 { + return Err(Error { + context: "parsing input UTXO", + error: "expected format ::".to_string(), + }); + } + // Parse scriptPubKey + let script_pubkey: elements::Script = + parts[0].parse().result_context("parsing scriptPubKey hex")?; + + // Parse asset - try as explicit AssetId first, then as confidential commitment + let asset = if parts[1].len() == 64 { + // 32 bytes = explicit AssetId + let asset_id: elements::AssetId = parts[1].parse().result_context("parsing asset hex")?; + confidential::Asset::Explicit(asset_id) + } else { + // Parse anything except 32 bytes as a confidential commitment (which must be 33 bytes) + let commitment_bytes = + Vec::from_hex(parts[1]).result_context("parsing asset commitment hex")?; + elements::confidential::Asset::from_commitment(&commitment_bytes) + .result_context("decoding asset commitment")? + }; + + // Parse value - try as BTC decimal first, then as confidential commitment + let value = if let Ok(btc_amount) = Amount::from_str_in(parts[2], Denomination::Bitcoin) { + // Explicit value in BTC + elements::confidential::Value::Explicit(btc_amount.to_sat()) + } else { + // 33 bytes = confidential commitment + let commitment_bytes = + Vec::from_hex(parts[2]).result_context("parsing value commitment hex")?; + elements::confidential::Value::from_commitment(&commitment_bytes) + .result_context("decoding value commitment")? + }; + + Ok(ElementsUtxo { + script_pubkey, + asset, + value, + }) +} diff --git a/src/bin/hal-simplicity/cmd/simplicity/sighash.rs b/src/bin/hal-simplicity/cmd/simplicity/sighash.rs index 69e2b95..38eaa4b 100644 --- a/src/bin/hal-simplicity/cmd/simplicity/sighash.rs +++ b/src/bin/hal-simplicity/cmd/simplicity/sighash.rs @@ -9,12 +9,11 @@ use elements::hashes::Hash; use hal_simplicity::simplicity::bitcoin::secp256k1::{ schnorr, Keypair, Message, Secp256k1, SecretKey, }; -use hal_simplicity::simplicity::bitcoin::{Amount, Denomination}; use hal_simplicity::simplicity::elements::hashes::sha256; use hal_simplicity::simplicity::elements::hex::FromHex; use hal_simplicity::simplicity::elements::taproot::ControlBlock; -use hal_simplicity::simplicity::elements::{self, confidential, Transaction}; -use hal_simplicity::simplicity::jet::elements::{ElementsEnv, ElementsUtxo}; +use hal_simplicity::simplicity::elements::{self, Transaction}; +use hal_simplicity::simplicity::jet::elements::ElementsEnv; use hal_simplicity::simplicity::Cmr; use serde::Serialize; @@ -26,50 +25,6 @@ struct SighashInfo { valid_signature: Option, } -fn parse_elements_utxo(s: &str) -> Result { - let parts: Vec<&str> = s.split(':').collect(); - if parts.len() != 3 { - return Err(Error { - context: "parsing input UTXO", - error: "expected format ::".to_string(), - }); - } - // Parse scriptPubKey - let script_pubkey: elements::Script = - parts[0].parse().result_context("parsing scriptPubKey hex")?; - - // Parse asset - try as explicit AssetId first, then as confidential commitment - let asset = if parts[1].len() == 64 { - // 32 bytes = explicit AssetId - let asset_id: elements::AssetId = parts[1].parse().result_context("parsing asset hex")?; - confidential::Asset::Explicit(asset_id) - } else { - // Parse anything except 32 bytes as a confidential commitment (which must be 33 bytes) - let commitment_bytes = - Vec::from_hex(parts[1]).result_context("parsing asset commitment hex")?; - elements::confidential::Asset::from_commitment(&commitment_bytes) - .result_context("decoding asset commitment")? - }; - - // Parse value - try as BTC decimal first, then as confidential commitment - let value = if let Ok(btc_amount) = Amount::from_str_in(parts[2], Denomination::Bitcoin) { - // Explicit value in BTC - elements::confidential::Value::Explicit(btc_amount.to_sat()) - } else { - // 33 bytes = confidential commitment - let commitment_bytes = - Vec::from_hex(parts[2]).result_context("parsing value commitment hex")?; - elements::confidential::Value::from_commitment(&commitment_bytes) - .result_context("decoding value commitment")? - }; - - Ok(ElementsUtxo { - script_pubkey, - asset, - value, - }) -} - pub fn cmd<'a>() -> clap::App<'a, 'a> { cmd::subcommand("sighash", "Compute signature hashes or signatures for use with Simplicity") .args(&cmd::opts_networks()) @@ -162,7 +117,7 @@ fn exec_inner( let input_utxos = input_utxos .iter() - .map(|utxo_str| parse_elements_utxo(utxo_str)) + .map(|utxo_str| super::parse_elements_utxo(utxo_str)) .collect::, Error>>()?; assert_eq!(input_utxos.len(), tx.input.len()); From fa80dbbc1b97e7d5e328048ad3d4464d9dd2688f Mon Sep 17 00:00:00 2001 From: Andrew Poelstra Date: Sat, 25 Oct 2025 14:49:35 +0000 Subject: [PATCH 3/7] simplicity: add `simplicity pset update-input` CLI call --- Cargo.toml | 2 +- src/bin/hal-simplicity/cmd/simplicity/mod.rs | 3 + .../hal-simplicity/cmd/simplicity/pset/mod.rs | 18 +++ .../cmd/simplicity/pset/update_input.rs | 138 ++++++++++++++++++ tests/cli.rs | 1 + 5 files changed, 161 insertions(+), 1 deletion(-) create mode 100644 src/bin/hal-simplicity/cmd/simplicity/pset/mod.rs create mode 100644 src/bin/hal-simplicity/cmd/simplicity/pset/update_input.rs diff --git a/Cargo.toml b/Cargo.toml index d41ca85..aab1f76 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -30,7 +30,7 @@ serde_json = "1.0.34" serde_yaml = "0.8.8" hex = "0.3.2" -elements = { version = "0.25.2", features = [ "serde" ] } +elements = { version = "0.25.2", features = [ "serde", "base64" ] } simplicity = { package = "simplicity-lang", version = "0.5.0", features = [ "base64", "serde" ] } [lints.clippy] diff --git a/src/bin/hal-simplicity/cmd/simplicity/mod.rs b/src/bin/hal-simplicity/cmd/simplicity/mod.rs index 1c65237..f4d373a 100644 --- a/src/bin/hal-simplicity/cmd/simplicity/mod.rs +++ b/src/bin/hal-simplicity/cmd/simplicity/mod.rs @@ -2,6 +2,7 @@ // SPDX-License-Identifier: CC0-1.0 mod info; +mod pset; mod sighash; use crate::cmd; @@ -34,12 +35,14 @@ impl ErrorExt for Result { pub fn subcommand<'a>() -> clap::App<'a, 'a> { cmd::subcommand_group("simplicity", "manipulate Simplicity programs") .subcommand(self::info::cmd()) + .subcommand(self::pset::cmd()) .subcommand(self::sighash::cmd()) } pub fn execute<'a>(matches: &clap::ArgMatches<'a>) { match matches.subcommand() { ("info", Some(m)) => self::info::exec(m), + ("pset", Some(m)) => self::pset::exec(m), ("sighash", Some(m)) => self::sighash::exec(m), (_, _) => unreachable!("clap prints help"), }; diff --git a/src/bin/hal-simplicity/cmd/simplicity/pset/mod.rs b/src/bin/hal-simplicity/cmd/simplicity/pset/mod.rs new file mode 100644 index 0000000..24f4dc5 --- /dev/null +++ b/src/bin/hal-simplicity/cmd/simplicity/pset/mod.rs @@ -0,0 +1,18 @@ +// Copyright 2025 Andrew Poelstra +// SPDX-License-Identifier: CC0-1.0 + +mod update_input; + +use crate::cmd; + +pub fn cmd<'a>() -> clap::App<'a, 'a> { + cmd::subcommand_group("pset", "manipulate PSETs for spending from Simplicity programs") + .subcommand(self::update_input::cmd()) +} + +pub fn exec<'a>(matches: &clap::ArgMatches<'a>) { + match matches.subcommand() { + ("update-input", Some(m)) => self::update_input::exec(m), + (_, _) => unreachable!("clap prints help"), + }; +} diff --git a/src/bin/hal-simplicity/cmd/simplicity/pset/update_input.rs b/src/bin/hal-simplicity/cmd/simplicity/pset/update_input.rs new file mode 100644 index 0000000..15542b8 --- /dev/null +++ b/src/bin/hal-simplicity/cmd/simplicity/pset/update_input.rs @@ -0,0 +1,138 @@ +// Copyright 2025 Andrew Poelstra +// SPDX-License-Identifier: CC0-1.0 + +use crate::cmd; + +use core::str::FromStr; +use std::collections::BTreeMap; + +use super::super::{Error, ErrorExt as _}; + +use elements::schnorr::XOnlyPublicKey; +use hal_simplicity::hal_simplicity::taproot_spend_info; +use serde::Serialize; + +#[derive(Serialize)] +struct UpdatedPset { + pset: String, + updated_values: Vec<&'static str>, +} + +pub fn cmd<'a>() -> clap::App<'a, 'a> { + cmd::subcommand("update-input", "Attach UTXO data to 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::opt("input-utxo", "the input's UTXO, in the form ::") + .short("i") + .takes_value(true) + .required(true), + cmd::opt("internal-key", "internal public key (hex)") + .short("p") + .takes_value(true) + .required(false), + cmd::opt("cmr", "CMR of the Simplicity program (hex)") + .short("c") + .takes_value(true) + .required(false), + // FIXME add merkle path, needed to compute nontrivial control blocks + ]) +} + +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 input_utxo = matches.value_of("input-utxo").expect("input-utxois mandatory"); + + let internal_key = matches.value_of("internal-key"); + let cmr = matches.value_of("cmr"); + + match exec_inner(pset_b64, input_idx, input_utxo, internal_key, cmr) { + Ok(info) => cmd::print_output(matches, &info), + Err(e) => cmd::print_output(matches, &e), + } +} + +#[allow(clippy::too_many_arguments)] +fn exec_inner( + pset_b64: &str, + input_idx: &str, + input_utxo: &str, + internal_key: Option<&str>, + cmr: Option<&str>, +) -> Result { + let mut pset: elements::pset::PartiallySignedTransaction = + pset_b64.parse().result_context("decoding PSET")?; + let input_idx: usize = input_idx.parse().result_context("parsing input-idx")?; + let input_utxo = super::super::parse_elements_utxo(input_utxo)?; + + let n_inputs = pset.n_inputs(); + let input = pset + .inputs_mut() + .get_mut(input_idx) + .ok_or_else(|| { + format!("index {} out-of-range for PSET with {} inputs", input_idx, n_inputs) + }) + .result_context("parsing input index")?; + + let cmr = cmr.map(simplicity::Cmr::from_str).transpose().result_context("parsing CMR")?; + let internal_key = internal_key + .map(XOnlyPublicKey::from_str) + .transpose() + .result_context("parsing internal key")?; + if cmr.is_some() && internal_key.is_none() { + return Err("internal key must be present if CMR is; PSET requires a control block for each CMR, which in turn requires the internal key. If you don't know the internal key, good chance it is the BIP-0341 'unspendable key' 50929b74c1a04954b78b4b6035e97a5e078a5a0f28ec96d547bfee9ace803ac0 or the web IDE's 'unspendable key' (highly discouraged for use in production) of f5919fa64ce45f8306849072b26c1bfdd2937e6b81774796ff372bd1eb5362d2") + .result_context("missing internal key"); + } + + if !input_utxo.script_pubkey.is_v1_p2tr() { + return Err("input UTXO does not appear to be a Taproot output") + .result_context("input UTXO"); + } + + let mut updated_values = vec![]; + if let Some(internal_key) = internal_key { + updated_values.push("tap_internal_key"); + input.tap_internal_key = Some(internal_key); + // FIXME should we check whether we're using the "bad" internal key + // from the web IDE, and warn or something? + if let Some(cmr) = cmr { + // Guess that the given program is the only Tapleaf. This is the case for addresses + // generated from the web IDE, and from `hal-simplicity simplicity info`, and for + // most "test" scenarios. We need to design an API to handle more general cases. + let spend_info = taproot_spend_info(internal_key, cmr); + if spend_info.output_key().as_inner().serialize() != input_utxo.script_pubkey[2..] { + // If our guess was wrong, at least error out.. + return Err(format!("CMR and internal key imply output key {}, which does not match input scriptPubKey {}", spend_info.output_key().as_inner(), input_utxo.script_pubkey)) + .result_context("input UTXO"); + } + + // FIXME these unwraps and clones should be fixed by a new rust-bitcoin taproot API + let script_ver = spend_info.as_script_map().keys().next().unwrap(); + let cb = spend_info.control_block(script_ver).unwrap(); + input.tap_merkle_root = spend_info.merkle_root(); + input.tap_scripts = BTreeMap::new(); + input.tap_scripts.insert(cb, script_ver.clone()); + updated_values.push("tap_merkle_root"); + updated_values.push("tap_scripts"); + } + } + + // FIXME should we bother erroring or warning if we clobber this or other fields? + input.witness_utxo = Some(elements::TxOut { + asset: input_utxo.asset, + value: input_utxo.value, + nonce: elements::confidential::Nonce::Null, // not in UTXO set, irrelevant to PSET + script_pubkey: input_utxo.script_pubkey, + witness: elements::TxOutWitness::empty(), // not in UTXO set, irrelevant to PSET + }); + updated_values.push("witness_utxo"); + + Ok(UpdatedPset { + pset: pset.to_string(), + updated_values, + }) +} diff --git a/tests/cli.rs b/tests/cli.rs index 62d6f5b..102478b 100644 --- a/tests/cli.rs +++ b/tests/cli.rs @@ -1045,6 +1045,7 @@ FLAGS: SUBCOMMANDS: info Parse a base64-encoded Simplicity program and decode it + pset manipulate PSETs for spending from Simplicity programs sighash Compute signature hashes or signatures for use with Simplicity "; assert_cmd(&["simplicity"], "", expected_help); From b151c9556a5065665252c3514ae2c2d8944ef4bc Mon Sep 17 00:00:00 2001 From: Andrew Poelstra Date: Mon, 27 Oct 2025 20:32:48 +0000 Subject: [PATCH 4/7] simplicity: allow providing PSETs in 'simplicity sighash' If you provide a PSET instead of a raw transaction, then attempt to extract a transaction from it. --- .../hal-simplicity/cmd/simplicity/sighash.rs | 104 ++++++++++++++---- 1 file changed, 82 insertions(+), 22 deletions(-) diff --git a/src/bin/hal-simplicity/cmd/simplicity/sighash.rs b/src/bin/hal-simplicity/cmd/simplicity/sighash.rs index 38eaa4b..4e8b461 100644 --- a/src/bin/hal-simplicity/cmd/simplicity/sighash.rs +++ b/src/bin/hal-simplicity/cmd/simplicity/sighash.rs @@ -5,15 +5,17 @@ use crate::cmd; use super::{Error, ErrorExt as _}; -use elements::hashes::Hash; +use elements::hashes::Hash as _; +use elements::pset::PartiallySignedTransaction; use hal_simplicity::simplicity::bitcoin::secp256k1::{ schnorr, Keypair, Message, Secp256k1, SecretKey, }; +use hal_simplicity::simplicity::elements; use hal_simplicity::simplicity::elements::hashes::sha256; use hal_simplicity::simplicity::elements::hex::FromHex; use hal_simplicity::simplicity::elements::taproot::ControlBlock; -use hal_simplicity::simplicity::elements::{self, Transaction}; -use hal_simplicity::simplicity::jet::elements::ElementsEnv; + +use hal_simplicity::simplicity::jet::elements::{ElementsEnv, ElementsUtxo}; use hal_simplicity::simplicity::Cmr; use serde::Serialize; @@ -35,7 +37,9 @@ pub fn cmd<'a>() -> clap::App<'a, 'a> { .takes_value(true) .required(true), cmd::arg("cmr", "CMR of the input program (hex)").takes_value(true).required(true), - cmd::arg("control-block", "Taproot control block of the input program (hex)").takes_value(true).required(true), + cmd::arg("control-block", "Taproot control block of the input program (hex)") + .takes_value(true) + .required(false), cmd::opt("genesis-hash", "genesis hash of the blockchain the transaction belongs to (hex)") .short("g") .required(false), @@ -55,7 +59,7 @@ pub fn cmd<'a>() -> clap::App<'a, 'a> { .short("i") .multiple(true) .number_of_values(1) - .required(true), + .required(false), ]) } @@ -63,12 +67,12 @@ pub fn exec<'a>(matches: &clap::ArgMatches<'a>) { let tx_hex = matches.value_of("tx").expect("tx mandatory"); let input_idx = matches.value_of("input-index").expect("input-idx is mandatory"); let cmr = matches.value_of("cmr").expect("cmr is mandatory"); - let control_block = matches.value_of("control-block").expect("control-block is mandatory"); + let control_block = matches.value_of("control-block"); let genesis_hash = matches.value_of("genesis-hash"); let secret_key = matches.value_of("secret-key"); let public_key = matches.value_of("public-key"); let signature = matches.value_of("signature"); - let input_utxos: Vec<_> = matches.values_of("input-utxo").unwrap().collect(); + let input_utxos: Option> = matches.values_of("input-utxo").map(|vals| vals.collect()); match exec_inner( tx_hex, @@ -79,7 +83,7 @@ pub fn exec<'a>(matches: &clap::ArgMatches<'a>) { secret_key, public_key, signature, - &input_utxos, + input_utxos.as_deref(), ) { Ok(info) => cmd::print_output(matches, &info), Err(e) => cmd::print_output(matches, &e), @@ -91,34 +95,90 @@ fn exec_inner( tx_hex: &str, input_idx: &str, cmr: &str, - control_block: &str, + control_block: Option<&str>, genesis_hash: Option<&str>, secret_key: Option<&str>, public_key: Option<&str>, signature: Option<&str>, - input_utxos: &[&str], + input_utxos: Option<&[&str]>, ) -> Result { let secp = Secp256k1::new(); + // Attempt to decode transaction as PSET first. If it succeeds, we can extract + // a lot of information from it. If not, we assume the transaction is hex and + // will give the user an error corresponding to this. + let pset = tx_hex.parse::().ok(); + // In the future we should attempt to parse as a Bitcoin program if parsing as // Elements fails. May be tricky/annoying in Rust since Program is a // different type from Program. - let tx_bytes = Vec::from_hex(tx_hex).result_context("parsing transaction hex")?; - let tx: Transaction = - elements::encode::deserialize(&tx_bytes).result_context("decoding transaction")?; + let tx = match pset { + Some(ref pset) => pset.extract_tx().result_context("extracting transaction from PSET")?, + None => { + let tx_bytes = Vec::from_hex(tx_hex).result_context("parsing transaction hex")?; + elements::encode::deserialize(&tx_bytes).result_context("decoding transaction")? + } + }; let input_idx: u32 = input_idx.parse().result_context("parsing input-idx")?; let cmr: Cmr = cmr.parse().result_context("parsing cmr")?; - let cb_bytes = Vec::from_hex(control_block).result_context("parsing control block hex")?; - // For txes from webide, the internal key in this control block will be the hardcoded - // value f5919fa64ce45f8306849072b26c1bfdd2937e6b81774796ff372bd1eb5362d2 - let control_block = - ControlBlock::from_slice(&cb_bytes).result_context("decoding control block")?; + // If the user specifies a control block, use it. Otherwise query the PSET. + let control_block = if let Some(cb) = control_block { + let cb_bytes = Vec::from_hex(cb).result_context("parsing control block hex")?; + // For txes from webide, the internal key in this control block will be the hardcoded + // value f5919fa64ce45f8306849072b26c1bfdd2937e6b81774796ff372bd1eb5362d2 + ControlBlock::from_slice(&cb_bytes).result_context("decoding control block")? + } else if let Some(ref pset) = pset { + let n_inputs = pset.n_inputs(); + let input = pset + .inputs() + .get(input_idx as usize) // cast u32->usize probably fine + .ok_or_else(|| { + format!("index {} out-of-range for PSET with {} inputs", input_idx, n_inputs) + }) + .result_context("parsing input index")?; - let input_utxos = input_utxos - .iter() - .map(|utxo_str| super::parse_elements_utxo(utxo_str)) - .collect::, Error>>()?; + let mut control_block = None; + for (cb, script_ver) in &input.tap_scripts { + if script_ver.1 == simplicity::leaf_version() && &script_ver.0[..] == cmr.as_ref() { + control_block = Some(cb.clone()); + } + } + match control_block { + Some(cb) => cb, + None => { + return Err(format!("could not find control block in PSET for CMR {}", cmr)) + .result_context("finding control block")? + } + } + } else { + return Err("with a raw transaction, control-block must be provided") + .result_context("computing control block"); + }; + + let input_utxos = if let Some(input_utxos) = input_utxos { + input_utxos + .iter() + .map(|utxo_str| super::parse_elements_utxo(utxo_str)) + .collect::, Error>>()? + } else if let Some(ref pset) = pset { + 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")? + } else { + return Err("with a raw transaction, input-utxos must be provided") + .result_context("computing control block"); + }; assert_eq!(input_utxos.len(), tx.input.len()); // Default to Bitcoin blockhash. From f60d0a0394755126d68cb3be8e5c65485384aa0c Mon Sep 17 00:00:00 2001 From: Andrew Poelstra Date: Mon, 27 Oct 2025 20:04:16 +0000 Subject: [PATCH 5/7] simplicity: add 'simplicity pset finalize' command This command takes a PSET where we've updated all the inputs (with 'simplicity pset update-input' for example), as well as a program and a valid witness. It executes the program, prunes it, then adds it to the PSET. Then you can extract and send the program with elements-cli. --- .../cmd/simplicity/pset/finalize.rs | 150 ++++++++++++++++++ .../hal-simplicity/cmd/simplicity/pset/mod.rs | 10 ++ .../cmd/simplicity/pset/update_input.rs | 8 +- 3 files changed, 161 insertions(+), 7 deletions(-) create mode 100644 src/bin/hal-simplicity/cmd/simplicity/pset/finalize.rs diff --git a/src/bin/hal-simplicity/cmd/simplicity/pset/finalize.rs b/src/bin/hal-simplicity/cmd/simplicity/pset/finalize.rs new file mode 100644 index 0000000..e528d42 --- /dev/null +++ b/src/bin/hal-simplicity/cmd/simplicity/pset/finalize.rs @@ -0,0 +1,150 @@ +// Copyright 2025 Andrew Poelstra +// SPDX-License-Identifier: CC0-1.0 + +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; + +pub fn cmd<'a>() -> clap::App<'a, 'a> { + cmd::subcommand("finalize", "Attach a Simplicity program and witness to 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), + } +} + +#[allow(clippy::too_many_arguments)] +fn exec_inner( + pset_b64: &str, + input_idx: &str, + program: &str, + witness: &str, + genesis_hash: Option<&str>, +) -> Result { + // 1. Parse everything. + let mut 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 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")?; + + 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. + let input = &mut pset.inputs_mut()[input_idx_usize]; + input.final_script_witness = Some(vec![witness, prog, tap_leaf.into_bytes(), cb_serialized]); + + let updated_values = vec!["final_script_witness"]; + + Ok(UpdatedPset { + pset: pset.to_string(), + updated_values, + }) +} diff --git a/src/bin/hal-simplicity/cmd/simplicity/pset/mod.rs b/src/bin/hal-simplicity/cmd/simplicity/pset/mod.rs index 24f4dc5..463c63d 100644 --- a/src/bin/hal-simplicity/cmd/simplicity/pset/mod.rs +++ b/src/bin/hal-simplicity/cmd/simplicity/pset/mod.rs @@ -1,17 +1,27 @@ // Copyright 2025 Andrew Poelstra // SPDX-License-Identifier: CC0-1.0 +mod finalize; mod update_input; use crate::cmd; +use serde::Serialize; + +#[derive(Serialize)] +struct UpdatedPset { + pset: String, + updated_values: Vec<&'static str>, +} pub fn cmd<'a>() -> clap::App<'a, 'a> { cmd::subcommand_group("pset", "manipulate PSETs for spending from Simplicity programs") + .subcommand(self::finalize::cmd()) .subcommand(self::update_input::cmd()) } pub fn exec<'a>(matches: &clap::ArgMatches<'a>) { match matches.subcommand() { + ("finalize", Some(m)) => self::finalize::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/update_input.rs b/src/bin/hal-simplicity/cmd/simplicity/pset/update_input.rs index 15542b8..ca6a275 100644 --- a/src/bin/hal-simplicity/cmd/simplicity/pset/update_input.rs +++ b/src/bin/hal-simplicity/cmd/simplicity/pset/update_input.rs @@ -7,16 +7,10 @@ use core::str::FromStr; use std::collections::BTreeMap; use super::super::{Error, ErrorExt as _}; +use super::UpdatedPset; use elements::schnorr::XOnlyPublicKey; use hal_simplicity::hal_simplicity::taproot_spend_info; -use serde::Serialize; - -#[derive(Serialize)] -struct UpdatedPset { - pset: String, - updated_values: Vec<&'static str>, -} pub fn cmd<'a>() -> clap::App<'a, 'a> { cmd::subcommand("update-input", "Attach UTXO data to a PSET input") From d7e3b0408bded34d13c48463a3fd9656af8f9f93 Mon Sep 17 00:00:00 2001 From: Andrew Poelstra Date: Tue, 4 Nov 2025 22:02:51 +0000 Subject: [PATCH 6/7] simplicity: add 'simplicity pset extract' command Now rather than extracting using elements-cli, by running elements-cli finalizepsbt true you can do it with hal-simplicity, with hal-simplicity simplicity pset extract --- .../cmd/simplicity/pset/extract.rs | 29 +++++++++++++++++++ .../hal-simplicity/cmd/simplicity/pset/mod.rs | 3 ++ 2 files changed, 32 insertions(+) create mode 100644 src/bin/hal-simplicity/cmd/simplicity/pset/extract.rs diff --git a/src/bin/hal-simplicity/cmd/simplicity/pset/extract.rs b/src/bin/hal-simplicity/cmd/simplicity/pset/extract.rs new file mode 100644 index 0000000..225767a --- /dev/null +++ b/src/bin/hal-simplicity/cmd/simplicity/pset/extract.rs @@ -0,0 +1,29 @@ +// Copyright 2025 Andrew Poelstra +// SPDX-License-Identifier: CC0-1.0 + +use elements::encode::serialize_hex; + +use super::super::{Error, ErrorExt as _}; +use crate::cmd; + +pub fn cmd<'a>() -> clap::App<'a, 'a> { + cmd::subcommand("extract", "extract a raw transaction from a completed PSET") + .args(&cmd::opts_networks()) + .args(&[cmd::arg("pset", "PSET to update (base64)").takes_value(true).required(true)]) +} + +pub fn exec<'a>(matches: &clap::ArgMatches<'a>) { + let pset_b64 = matches.value_of("pset").expect("tx mandatory"); + match exec_inner(pset_b64) { + Ok(info) => cmd::print_output(matches, &info), + Err(e) => cmd::print_output(matches, &e), + } +} + +fn exec_inner(pset_b64: &str) -> Result { + let pset: elements::pset::PartiallySignedTransaction = + pset_b64.parse().result_context("decoding PSET")?; + + let tx = pset.extract_tx().result_context("extracting transaction")?; + Ok(serialize_hex(&tx)) +} diff --git a/src/bin/hal-simplicity/cmd/simplicity/pset/mod.rs b/src/bin/hal-simplicity/cmd/simplicity/pset/mod.rs index 463c63d..4abbfcc 100644 --- a/src/bin/hal-simplicity/cmd/simplicity/pset/mod.rs +++ b/src/bin/hal-simplicity/cmd/simplicity/pset/mod.rs @@ -1,6 +1,7 @@ // Copyright 2025 Andrew Poelstra // SPDX-License-Identifier: CC0-1.0 +mod extract; mod finalize; mod update_input; @@ -15,12 +16,14 @@ struct UpdatedPset { pub fn cmd<'a>() -> clap::App<'a, 'a> { cmd::subcommand_group("pset", "manipulate PSETs for spending from Simplicity programs") + .subcommand(self::extract::cmd()) .subcommand(self::finalize::cmd()) .subcommand(self::update_input::cmd()) } pub fn exec<'a>(matches: &clap::ArgMatches<'a>) { match matches.subcommand() { + ("extract", Some(m)) => self::extract::exec(m), ("finalize", Some(m)) => self::finalize::exec(m), ("update-input", Some(m)) => self::update_input::exec(m), (_, _) => unreachable!("clap prints help"), From cacd1f7d8c85eb1829888e8ea48db63330f88774 Mon Sep 17 00:00:00 2001 From: Andrew Poelstra Date: Tue, 4 Nov 2025 22:13:33 +0000 Subject: [PATCH 7/7] simplicity: add 'simplicity pset create' command This should be compatible with any non-pegin non-issuance calls to elements-cli createpsbt. Co-authored-by: aider (anthropic/claude-sonnet-4-20250514) --- .../cmd/simplicity/pset/create.rs | 172 ++++++++++++++++++ .../hal-simplicity/cmd/simplicity/pset/mod.rs | 3 + 2 files changed, 175 insertions(+) create mode 100644 src/bin/hal-simplicity/cmd/simplicity/pset/create.rs diff --git a/src/bin/hal-simplicity/cmd/simplicity/pset/create.rs b/src/bin/hal-simplicity/cmd/simplicity/pset/create.rs new file mode 100644 index 0000000..2e17649 --- /dev/null +++ b/src/bin/hal-simplicity/cmd/simplicity/pset/create.rs @@ -0,0 +1,172 @@ +// Copyright 2025 Andrew Poelstra +// SPDX-License-Identifier: CC0-1.0 + +use super::super::{Error, ErrorExt as _}; +use super::UpdatedPset; +use crate::cmd; + +use elements::confidential; +use elements::pset::PartiallySignedTransaction; +use elements::{Address, AssetId, OutPoint, Transaction, TxIn, TxOut, Txid}; +use serde::Deserialize; + +use std::collections::HashMap; + +#[derive(Deserialize)] +struct InputSpec { + txid: Txid, + vout: u32, + #[serde(default)] + sequence: Option, +} + +#[derive(Deserialize)] +struct FlattenedOutputSpec { + address: String, + asset: AssetId, + #[serde(with = "elements::bitcoin::amount::serde::as_btc")] + amount: elements::bitcoin::Amount, +} + +#[derive(Deserialize)] +#[serde(untagged)] +enum OutputSpec { + Explicit { + address: String, + asset: AssetId, + #[serde(with = "elements::bitcoin::amount::serde::as_btc")] + amount: elements::bitcoin::Amount, + }, + Map(HashMap), +} + +impl OutputSpec { + fn flatten(self) -> Box>> { + match self { + Self::Map(map) => Box::new(map.into_iter().map(|(address, amount)| { + // Use liquid bitcoin asset as default for map format + let default_asset = AssetId::from_slice(&[ + 0x49, 0x9a, 0x81, 0x85, 0x45, 0xf6, 0xba, 0xe3, 0x9f, 0xc0, 0x3b, 0x63, 0x7f, + 0x2a, 0x4e, 0x1e, 0x64, 0xe5, 0x90, 0xca, 0xc1, 0xbc, 0x3a, 0x6f, 0x6d, 0x71, + 0xaa, 0x44, 0x43, 0x65, 0x4c, 0x14, + ]) + .expect("valid asset id"); + + Ok(FlattenedOutputSpec { + address, + asset: default_asset, + amount: elements::bitcoin::Amount::from_btc(amount) + .result_context("parsing amount")?, + }) + })), + Self::Explicit { + address, + asset, + amount, + } => Box::new( + Some(Ok(FlattenedOutputSpec { + address, + asset, + amount, + })) + .into_iter(), + ), + } + } +} + +pub fn cmd<'a>() -> clap::App<'a, 'a> { + cmd::subcommand("create", "create an empty PSET").args(&cmd::opts_networks()).args(&[ + cmd::arg( + "inputs", + "input outpoints (JSON array of objects containing txid, vout, sequence)", + ) + .takes_value(true) + .required(true), + cmd::arg("outputs", "outputs (JSON array of objects containing address, asset, amount)") + .takes_value(true) + .required(true), + ]) +} + +pub fn exec<'a>(matches: &clap::ArgMatches<'a>) { + let inputs_json = matches.value_of("inputs").expect("inputs mandatory"); + let outputs_json = matches.value_of("outputs").expect("inputs mandatory"); + + match exec_inner(inputs_json, outputs_json) { + Ok(info) => cmd::print_output(matches, &info), + Err(e) => cmd::print_output(matches, &e), + } +} + +fn exec_inner(inputs_json: &str, outputs_json: &str) -> Result { + // Parse inputs JSON + let input_specs: Vec = + serde_json::from_str(inputs_json).result_context("parsing inputs JSON")?; + + // Parse outputs JSON - support both array and map formats + let output_specs: Vec = + serde_json::from_str(outputs_json).result_context("parsing outputs JSON")?; + + // Create transaction inputs + let mut inputs = Vec::new(); + for input_spec in &input_specs { + let outpoint = OutPoint::new(input_spec.txid, input_spec.vout); + let sequence = elements::Sequence(input_spec.sequence.unwrap_or(0xffffffff)); + + inputs.push(TxIn { + previous_output: outpoint, + script_sig: elements::Script::new(), + sequence, + asset_issuance: Default::default(), + witness: Default::default(), + is_pegin: false, + }); + } + + // Create transaction outputs + let mut outputs = Vec::new(); + for output_spec in output_specs.into_iter().flat_map(OutputSpec::flatten) { + let output_spec = output_spec?; // serde has crappy error messages so we defer parsing and then have to unwrap errors + + let script_pubkey = match output_spec.address.as_str() { + "fee" => elements::Script::new(), + x => { + let addr = x.parse::
().result_context("parsing address")?; + if addr.is_blinded() { + return Err("confidential addresses are not yet supported") + .result_context("output address"); + } + addr.script_pubkey() + } + }; + + outputs.push(TxOut { + asset: confidential::Asset::Explicit(output_spec.asset), + value: confidential::Value::Explicit(output_spec.amount.to_sat()), + nonce: elements::confidential::Nonce::Null, + script_pubkey, + witness: elements::TxOutWitness::empty(), + }); + } + + // Create the transaction + let tx = Transaction { + version: 2, + lock_time: elements::LockTime::ZERO, + input: inputs, + output: outputs, + }; + + // Create PSET from transaction + let pset = PartiallySignedTransaction::from_tx(tx); + + Ok(UpdatedPset { + pset: pset.to_string(), + updated_values: vec![ + // FIXME we technically update a whole slew of fields; see the implementation + // of PartiallySignedTransaction::from_tx. Should we attempt to exhaustively + // list them here? Or list none? Or what? + ], + }) +} diff --git a/src/bin/hal-simplicity/cmd/simplicity/pset/mod.rs b/src/bin/hal-simplicity/cmd/simplicity/pset/mod.rs index 4abbfcc..aa97ee4 100644 --- a/src/bin/hal-simplicity/cmd/simplicity/pset/mod.rs +++ b/src/bin/hal-simplicity/cmd/simplicity/pset/mod.rs @@ -1,6 +1,7 @@ // Copyright 2025 Andrew Poelstra // SPDX-License-Identifier: CC0-1.0 +mod create; mod extract; mod finalize; mod update_input; @@ -16,6 +17,7 @@ struct UpdatedPset { pub fn cmd<'a>() -> clap::App<'a, 'a> { cmd::subcommand_group("pset", "manipulate PSETs for spending from Simplicity programs") + .subcommand(self::create::cmd()) .subcommand(self::extract::cmd()) .subcommand(self::finalize::cmd()) .subcommand(self::update_input::cmd()) @@ -23,6 +25,7 @@ pub fn cmd<'a>() -> clap::App<'a, 'a> { pub fn exec<'a>(matches: &clap::ArgMatches<'a>) { match matches.subcommand() { + ("create", Some(m)) => self::create::exec(m), ("extract", Some(m)) => self::extract::exec(m), ("finalize", Some(m)) => self::finalize::exec(m), ("update-input", Some(m)) => self::update_input::exec(m),