Skip to content
Open
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
2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down
51 changes: 51 additions & 0 deletions src/bin/hal-simplicity/cmd/simplicity/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,14 @@
// SPDX-License-Identifier: CC0-1.0

mod info;
mod pset;
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;

Expand All @@ -30,13 +35,59 @@ impl<T, E: core::fmt::Display> ErrorExt<T> for Result<T, E> {
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"),
};
}

fn parse_elements_utxo(s: &str) -> Result<ElementsUtxo, Error> {
let parts: Vec<&str> = s.split(':').collect();
if parts.len() != 3 {
return Err(Error {
context: "parsing input UTXO",
error: "expected format <scriptPubKey>:<asset>:<value>".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,
})
}
172 changes: 172 additions & 0 deletions src/bin/hal-simplicity/cmd/simplicity/pset/create.rs
Original file line number Diff line number Diff line change
@@ -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<u32>,
}

#[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<String, f64>),
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Does this need a follow-up to support non-policy assets?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think so. This is the same as the elements-cli createpsbt interface. If you want non-policy assets then you need to use the verbose form of the command in the other enum variant.

}

impl OutputSpec {
fn flatten(self) -> Box<dyn Iterator<Item = Result<FlattenedOutputSpec, Error>>> {
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<UpdatedPset, Error> {
// Parse inputs JSON
let input_specs: Vec<InputSpec> =
serde_json::from_str(inputs_json).result_context("parsing inputs JSON")?;

// Parse outputs JSON - support both array and map formats
let output_specs: Vec<OutputSpec> =
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::<Address>().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?
],
})
}
29 changes: 29 additions & 0 deletions src/bin/hal-simplicity/cmd/simplicity/pset/extract.rs
Original file line number Diff line number Diff line change
@@ -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<String, Error> {
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))
}
Loading