Skip to content

Commit e351cc1

Browse files
committed
Merge #12: Update to use PSET and to allow signing
cacd1f7 simplicity: add 'simplicity pset create' command (Andrew Poelstra) d7e3b04 simplicity: add 'simplicity pset extract' command (Andrew Poelstra) f60d0a0 simplicity: add 'simplicity pset finalize' command (Andrew Poelstra) b151c95 simplicity: allow providing PSETs in 'simplicity sighash' (Andrew Poelstra) fa80dbb simplicity: add `simplicity pset update-input` CLI call (Andrew Poelstra) 5015d4a simplicity: move parse_elements_utxo up from sighash to mod (Andrew Poelstra) a183dc1 simplicity: allow parsing both programs and witnesses as hex or b64 (Andrew Poelstra) Pull request description: Introduces several new PSET-manipulation commands to allow using this tool without needing an Elements daemon (other than for blockchain data access, which can be obtained from web APIs instead). In particular we add: * `simplicity pset update-input` which attaches Simplicity-related data to a specific input * `simplicity pset finalize` which takes a Simplicity program and witness, executes and prunes the program, and attaches the data to the witness * `simplicity pset extract` which constructs a complete transaction from a finalized PSET This also extends `simplicity sighash` to work with PSETs rather than requiring the user copy/paste tons of stuff in the CLI interface. ACKs for top commit: delta1: utACK cacd1f7 Tree-SHA512: 4d8e8198824a7a9e381904777fd67a1e8d06aeb03a287bc5d63fe07e37d485260c76c0a2ab763929697b7a7b3c1450f340ca0815e8dd3fb5b5651064073aae0a
2 parents e1e1ab4 + cacd1f7 commit e351cc1

File tree

11 files changed

+681
-85
lines changed

11 files changed

+681
-85
lines changed

Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@ serde_json = "1.0.34"
3030
serde_yaml = "0.8.8"
3131
hex = "0.3.2"
3232

33-
elements = { version = "0.25.2", features = [ "serde" ] }
33+
elements = { version = "0.25.2", features = [ "serde", "base64" ] }
3434
simplicity = { package = "simplicity-lang", version = "0.5.0", features = [ "base64", "serde" ] }
3535

3636
[lints.clippy]

src/bin/hal-simplicity/cmd/simplicity/mod.rs

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,14 @@
22
// SPDX-License-Identifier: CC0-1.0
33

44
mod info;
5+
mod pset;
56
mod sighash;
67

78
use crate::cmd;
9+
use hal_simplicity::simplicity::bitcoin::{Amount, Denomination};
10+
use hal_simplicity::simplicity::elements::confidential;
11+
use hal_simplicity::simplicity::elements::hex::FromHex as _;
12+
use hal_simplicity::simplicity::jet::elements::ElementsUtxo;
813

914
use serde::Serialize;
1015

@@ -30,13 +35,59 @@ impl<T, E: core::fmt::Display> ErrorExt<T> for Result<T, E> {
3035
pub fn subcommand<'a>() -> clap::App<'a, 'a> {
3136
cmd::subcommand_group("simplicity", "manipulate Simplicity programs")
3237
.subcommand(self::info::cmd())
38+
.subcommand(self::pset::cmd())
3339
.subcommand(self::sighash::cmd())
3440
}
3541

3642
pub fn execute<'a>(matches: &clap::ArgMatches<'a>) {
3743
match matches.subcommand() {
3844
("info", Some(m)) => self::info::exec(m),
45+
("pset", Some(m)) => self::pset::exec(m),
3946
("sighash", Some(m)) => self::sighash::exec(m),
4047
(_, _) => unreachable!("clap prints help"),
4148
};
4249
}
50+
51+
fn parse_elements_utxo(s: &str) -> Result<ElementsUtxo, Error> {
52+
let parts: Vec<&str> = s.split(':').collect();
53+
if parts.len() != 3 {
54+
return Err(Error {
55+
context: "parsing input UTXO",
56+
error: "expected format <scriptPubKey>:<asset>:<value>".to_string(),
57+
});
58+
}
59+
// Parse scriptPubKey
60+
let script_pubkey: elements::Script =
61+
parts[0].parse().result_context("parsing scriptPubKey hex")?;
62+
63+
// Parse asset - try as explicit AssetId first, then as confidential commitment
64+
let asset = if parts[1].len() == 64 {
65+
// 32 bytes = explicit AssetId
66+
let asset_id: elements::AssetId = parts[1].parse().result_context("parsing asset hex")?;
67+
confidential::Asset::Explicit(asset_id)
68+
} else {
69+
// Parse anything except 32 bytes as a confidential commitment (which must be 33 bytes)
70+
let commitment_bytes =
71+
Vec::from_hex(parts[1]).result_context("parsing asset commitment hex")?;
72+
elements::confidential::Asset::from_commitment(&commitment_bytes)
73+
.result_context("decoding asset commitment")?
74+
};
75+
76+
// Parse value - try as BTC decimal first, then as confidential commitment
77+
let value = if let Ok(btc_amount) = Amount::from_str_in(parts[2], Denomination::Bitcoin) {
78+
// Explicit value in BTC
79+
elements::confidential::Value::Explicit(btc_amount.to_sat())
80+
} else {
81+
// 33 bytes = confidential commitment
82+
let commitment_bytes =
83+
Vec::from_hex(parts[2]).result_context("parsing value commitment hex")?;
84+
elements::confidential::Value::from_commitment(&commitment_bytes)
85+
.result_context("decoding value commitment")?
86+
};
87+
88+
Ok(ElementsUtxo {
89+
script_pubkey,
90+
asset,
91+
value,
92+
})
93+
}
Lines changed: 172 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,172 @@
1+
// Copyright 2025 Andrew Poelstra
2+
// SPDX-License-Identifier: CC0-1.0
3+
4+
use super::super::{Error, ErrorExt as _};
5+
use super::UpdatedPset;
6+
use crate::cmd;
7+
8+
use elements::confidential;
9+
use elements::pset::PartiallySignedTransaction;
10+
use elements::{Address, AssetId, OutPoint, Transaction, TxIn, TxOut, Txid};
11+
use serde::Deserialize;
12+
13+
use std::collections::HashMap;
14+
15+
#[derive(Deserialize)]
16+
struct InputSpec {
17+
txid: Txid,
18+
vout: u32,
19+
#[serde(default)]
20+
sequence: Option<u32>,
21+
}
22+
23+
#[derive(Deserialize)]
24+
struct FlattenedOutputSpec {
25+
address: String,
26+
asset: AssetId,
27+
#[serde(with = "elements::bitcoin::amount::serde::as_btc")]
28+
amount: elements::bitcoin::Amount,
29+
}
30+
31+
#[derive(Deserialize)]
32+
#[serde(untagged)]
33+
enum OutputSpec {
34+
Explicit {
35+
address: String,
36+
asset: AssetId,
37+
#[serde(with = "elements::bitcoin::amount::serde::as_btc")]
38+
amount: elements::bitcoin::Amount,
39+
},
40+
Map(HashMap<String, f64>),
41+
}
42+
43+
impl OutputSpec {
44+
fn flatten(self) -> Box<dyn Iterator<Item = Result<FlattenedOutputSpec, Error>>> {
45+
match self {
46+
Self::Map(map) => Box::new(map.into_iter().map(|(address, amount)| {
47+
// Use liquid bitcoin asset as default for map format
48+
let default_asset = AssetId::from_slice(&[
49+
0x49, 0x9a, 0x81, 0x85, 0x45, 0xf6, 0xba, 0xe3, 0x9f, 0xc0, 0x3b, 0x63, 0x7f,
50+
0x2a, 0x4e, 0x1e, 0x64, 0xe5, 0x90, 0xca, 0xc1, 0xbc, 0x3a, 0x6f, 0x6d, 0x71,
51+
0xaa, 0x44, 0x43, 0x65, 0x4c, 0x14,
52+
])
53+
.expect("valid asset id");
54+
55+
Ok(FlattenedOutputSpec {
56+
address,
57+
asset: default_asset,
58+
amount: elements::bitcoin::Amount::from_btc(amount)
59+
.result_context("parsing amount")?,
60+
})
61+
})),
62+
Self::Explicit {
63+
address,
64+
asset,
65+
amount,
66+
} => Box::new(
67+
Some(Ok(FlattenedOutputSpec {
68+
address,
69+
asset,
70+
amount,
71+
}))
72+
.into_iter(),
73+
),
74+
}
75+
}
76+
}
77+
78+
pub fn cmd<'a>() -> clap::App<'a, 'a> {
79+
cmd::subcommand("create", "create an empty PSET").args(&cmd::opts_networks()).args(&[
80+
cmd::arg(
81+
"inputs",
82+
"input outpoints (JSON array of objects containing txid, vout, sequence)",
83+
)
84+
.takes_value(true)
85+
.required(true),
86+
cmd::arg("outputs", "outputs (JSON array of objects containing address, asset, amount)")
87+
.takes_value(true)
88+
.required(true),
89+
])
90+
}
91+
92+
pub fn exec<'a>(matches: &clap::ArgMatches<'a>) {
93+
let inputs_json = matches.value_of("inputs").expect("inputs mandatory");
94+
let outputs_json = matches.value_of("outputs").expect("inputs mandatory");
95+
96+
match exec_inner(inputs_json, outputs_json) {
97+
Ok(info) => cmd::print_output(matches, &info),
98+
Err(e) => cmd::print_output(matches, &e),
99+
}
100+
}
101+
102+
fn exec_inner(inputs_json: &str, outputs_json: &str) -> Result<UpdatedPset, Error> {
103+
// Parse inputs JSON
104+
let input_specs: Vec<InputSpec> =
105+
serde_json::from_str(inputs_json).result_context("parsing inputs JSON")?;
106+
107+
// Parse outputs JSON - support both array and map formats
108+
let output_specs: Vec<OutputSpec> =
109+
serde_json::from_str(outputs_json).result_context("parsing outputs JSON")?;
110+
111+
// Create transaction inputs
112+
let mut inputs = Vec::new();
113+
for input_spec in &input_specs {
114+
let outpoint = OutPoint::new(input_spec.txid, input_spec.vout);
115+
let sequence = elements::Sequence(input_spec.sequence.unwrap_or(0xffffffff));
116+
117+
inputs.push(TxIn {
118+
previous_output: outpoint,
119+
script_sig: elements::Script::new(),
120+
sequence,
121+
asset_issuance: Default::default(),
122+
witness: Default::default(),
123+
is_pegin: false,
124+
});
125+
}
126+
127+
// Create transaction outputs
128+
let mut outputs = Vec::new();
129+
for output_spec in output_specs.into_iter().flat_map(OutputSpec::flatten) {
130+
let output_spec = output_spec?; // serde has crappy error messages so we defer parsing and then have to unwrap errors
131+
132+
let script_pubkey = match output_spec.address.as_str() {
133+
"fee" => elements::Script::new(),
134+
x => {
135+
let addr = x.parse::<Address>().result_context("parsing address")?;
136+
if addr.is_blinded() {
137+
return Err("confidential addresses are not yet supported")
138+
.result_context("output address");
139+
}
140+
addr.script_pubkey()
141+
}
142+
};
143+
144+
outputs.push(TxOut {
145+
asset: confidential::Asset::Explicit(output_spec.asset),
146+
value: confidential::Value::Explicit(output_spec.amount.to_sat()),
147+
nonce: elements::confidential::Nonce::Null,
148+
script_pubkey,
149+
witness: elements::TxOutWitness::empty(),
150+
});
151+
}
152+
153+
// Create the transaction
154+
let tx = Transaction {
155+
version: 2,
156+
lock_time: elements::LockTime::ZERO,
157+
input: inputs,
158+
output: outputs,
159+
};
160+
161+
// Create PSET from transaction
162+
let pset = PartiallySignedTransaction::from_tx(tx);
163+
164+
Ok(UpdatedPset {
165+
pset: pset.to_string(),
166+
updated_values: vec![
167+
// FIXME we technically update a whole slew of fields; see the implementation
168+
// of PartiallySignedTransaction::from_tx. Should we attempt to exhaustively
169+
// list them here? Or list none? Or what?
170+
],
171+
})
172+
}
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
// Copyright 2025 Andrew Poelstra
2+
// SPDX-License-Identifier: CC0-1.0
3+
4+
use elements::encode::serialize_hex;
5+
6+
use super::super::{Error, ErrorExt as _};
7+
use crate::cmd;
8+
9+
pub fn cmd<'a>() -> clap::App<'a, 'a> {
10+
cmd::subcommand("extract", "extract a raw transaction from a completed PSET")
11+
.args(&cmd::opts_networks())
12+
.args(&[cmd::arg("pset", "PSET to update (base64)").takes_value(true).required(true)])
13+
}
14+
15+
pub fn exec<'a>(matches: &clap::ArgMatches<'a>) {
16+
let pset_b64 = matches.value_of("pset").expect("tx mandatory");
17+
match exec_inner(pset_b64) {
18+
Ok(info) => cmd::print_output(matches, &info),
19+
Err(e) => cmd::print_output(matches, &e),
20+
}
21+
}
22+
23+
fn exec_inner(pset_b64: &str) -> Result<String, Error> {
24+
let pset: elements::pset::PartiallySignedTransaction =
25+
pset_b64.parse().result_context("decoding PSET")?;
26+
27+
let tx = pset.extract_tx().result_context("extracting transaction")?;
28+
Ok(serialize_hex(&tx))
29+
}

0 commit comments

Comments
 (0)