Skip to content

Commit bbc43fa

Browse files
feat(wasm-utxo-cli): add psbt create/add-input/add-output/sign subcommands
Four composable subcommands under `psbt`, replacing the earlier single-shot build-sign command: `psbt create`, `psbt add-input`, `psbt add-output`, `psbt sign`. Each reads/writes a PSBT as hex via stdin/stdout (or a file path), so they pipe together, e.g.: psbt create | psbt add-input - --network tzec --txid ... --descriptor pkh(<pubkey>) \ | psbt add-output - --address ... --network tzec \ | psbt sign - --network tzec --privkey ... --consensus-branch-id 0xc2d6d0b4 `add-input` and `sign` work for all networks, not just Zcash, using the wasm-utxo library helpers added in the parent commit: - `add-input` requires `--prev-tx` (a full previous transaction) unless `--network` is a value-committing network (Zcash/BCH-family) whose sighash already commits the input amount. - `sign` dispatches the sighash algorithm on `--network`: plain, FORKID (BCH family), or Zcash ZIP-243. Manually verified end-to-end for all three sighash families. This lets the indexer-utxo regtest fixture generator build+sign a transparent Zcash transaction off-node for zebrad's sendrawtransaction. Refs: T1-3672
1 parent fcd71bd commit bbc43fa

8 files changed

Lines changed: 411 additions & 2 deletions

File tree

packages/wasm-utxo/cli/README.md

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -125,6 +125,54 @@ The output displays a hierarchical tree view of the PSBT structure, including:
125125
- Per-output fields (scripts, derivation paths)
126126
- Decoded transaction details
127127

128+
#### Build and sign a transparent transaction
129+
130+
Four composable subcommands — `create`, `add-input`, `add-output`, `sign` — each read a PSBT
131+
from a file or stdin (`-`) and print the result as hex, so they pipe together into a full
132+
build-and-sign flow for a single-key transparent (non-segwit) transaction. Works for any
133+
network; the sighash algorithm used by `sign` (plain, FORKID, or Zcash ZIP-243) is selected by
134+
`--network`.
135+
136+
```bash
137+
wasm-utxo-cli psbt create [--version <VERSION>] [--lock-time <LOCK_TIME>]
138+
wasm-utxo-cli psbt add-input <PATH> --network <NETWORK> --txid <TXID> --vout <VOUT> \
139+
--value <VALUE> --script <SCRIPT_HEX> --descriptor <DESCRIPTOR> [--prev-tx <PREV_TX_HEX>]
140+
wasm-utxo-cli psbt add-output <PATH> (--address <ADDRESS> --network <NETWORK> | --script <SCRIPT_HEX>) --value <VALUE>
141+
wasm-utxo-cli psbt sign <PATH> --network <NETWORK> --privkey <PRIVKEY> [--consensus-branch-id <ID>]
142+
```
143+
144+
`add-input` requires `--prev-tx` (the full previous transaction, hex-encoded) unless
145+
`--network` is a value-committing network (Zcash, BCH family) whose sighash already commits the
146+
spent amount — see `Network::requires_prev_tx_for_legacy_input` in the `wasm-utxo` library.
147+
148+
**Examples:**
149+
150+
```bash
151+
# BTC: spend a P2PKH coinbase-style output, providing the full previous transaction
152+
wasm-utxo-cli psbt create \
153+
| wasm-utxo-cli psbt add-input - --network btc \
154+
--txid e6a6e7f5551af932bc3813d920c52d61ec39fbad2e5585a018cce7dcbdd4ec72 --vout 0 \
155+
--value 50000000 --script 76a914aeee1e6ae364e64b1b36acd53df55bd8d750485888ac \
156+
--descriptor "pkh(039ab0771c5f88913208a26f81ab8223e98d25176e4648a5a2bb8ff79cf1c5198b)" \
157+
--prev-tx 010000000100000000000000000000000000000000000000000000000000000000000000000000000000ffffffff0180f0fa02000000001976a914aeee1e6ae364e64b1b36acd53df55bd8d750485888ac00000000 \
158+
| wasm-utxo-cli psbt add-output - --script 76a914aeee1e6ae364e64b1b36acd53df55bd8d750485888ac --value 49990000 \
159+
| wasm-utxo-cli psbt sign - --network btc --privkey KzEGYtKcbhYwUWcZygbsqmF31f3iV7HC3iUQug7MBecwCz9hm1Tv
160+
# Output: a plain-sighash signed BTC transaction, hex-encoded
161+
162+
# Zcash: spend a transparent P2PKH coinbase output for regtest fixture generation
163+
# (e.g. for a zebrad-mined UTXO to broadcast via sendrawtransaction). No --prev-tx needed —
164+
# ZIP-243 sighash commits the input amount.
165+
wasm-utxo-cli psbt create --lock-time 0 \
166+
| wasm-utxo-cli psbt add-input - --network tzec \
167+
--txid a8c685478265f4c14dada651969c45a65e1aeb8cd6791f2f5bb6a1d9952104d9 --vout 0 \
168+
--value 50000000 --script 76a914aeee1e6ae364e64b1b36acd53df55bd8d750485888ac \
169+
--descriptor "pkh(039ab0771c5f88913208a26f81ab8223e98d25176e4648a5a2bb8ff79cf1c5198b)" \
170+
| wasm-utxo-cli psbt add-output - --script 76a914aeee1e6ae364e64b1b36acd53df55bd8d750485888ac --value 49990000 \
171+
| wasm-utxo-cli psbt sign - --network tzec --privkey KzEGYtKcbhYwUWcZygbsqmF31f3iV7HC3iUQug7MBecwCz9hm1Tv \
172+
--consensus-branch-id 0xc2d6d0b4
173+
# Output: a signed Zcash overwintered (NU5) transaction, hex-encoded
174+
```
175+
128176
### Supported Networks
129177

130178
The CLI supports the following networks (use with `--network` flag):

packages/wasm-utxo/cli/src/input.rs

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,17 @@ pub fn parse_private_key(s: &str) -> Result<PrivateKey> {
1616
PrivateKey::from_slice(&bytes, BitcoinNetwork::Bitcoin).context("invalid private key bytes")
1717
}
1818

19+
/// Parse a `u32` given as decimal or `0x`-prefixed hex.
20+
pub fn parse_u32_flexible(s: &str) -> Result<u32> {
21+
let s = s.trim();
22+
if let Some(hex) = s.strip_prefix("0x").or_else(|| s.strip_prefix("0X")) {
23+
u32::from_str_radix(hex, 16).with_context(|| format!("invalid hex u32: {s}"))
24+
} else {
25+
s.parse::<u32>()
26+
.with_context(|| format!("invalid u32: {s}"))
27+
}
28+
}
29+
1930
/// Decode input bytes, attempting to interpret as base64, hex, or raw bytes
2031
pub fn decode_input(raw_bytes: &[u8]) -> Result<Vec<u8>> {
2132
// Try to interpret as text first (for base64/hex encoded input)
Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
use anyhow::{anyhow, bail, Context, Result};
2+
use std::path::PathBuf;
3+
use std::str::FromStr;
4+
use wasm_utxo::add_input_with_descriptor;
5+
use wasm_utxo::bitcoin::consensus::Decodable;
6+
use wasm_utxo::bitcoin::{ScriptBuf, Transaction, Txid};
7+
8+
use super::common::{print_psbt, read_psbt};
9+
use crate::network::NetworkArg;
10+
11+
#[allow(clippy::too_many_arguments)]
12+
pub fn handle_add_input_command(
13+
path: PathBuf,
14+
network: NetworkArg,
15+
txid: String,
16+
vout: u32,
17+
value: u64,
18+
script: String,
19+
descriptor: String,
20+
sequence: u32,
21+
prev_tx: Option<String>,
22+
) -> Result<()> {
23+
let network: wasm_utxo::Network = network.into();
24+
let mut psbt = read_psbt(&path)?;
25+
26+
let txid = Txid::from_str(&txid).context("invalid --txid")?;
27+
let script_pubkey = ScriptBuf::from(hex::decode(&script).context("invalid --script hex")?);
28+
29+
let non_witness_utxo = match prev_tx {
30+
Some(hex_str) => {
31+
let bytes = hex::decode(&hex_str).context("invalid --prev-tx hex")?;
32+
let tx = Transaction::consensus_decode(&mut bytes.as_slice())
33+
.context("invalid --prev-tx transaction")?;
34+
if tx.compute_txid() != txid {
35+
bail!(
36+
"--prev-tx txid {} does not match --txid {}",
37+
tx.compute_txid(),
38+
txid
39+
);
40+
}
41+
Some(tx)
42+
}
43+
None if network.requires_prev_tx_for_legacy_input() => {
44+
bail!(
45+
"--prev-tx is required for network {network} (only value-committing networks \
46+
like Zcash/BCH-family can omit it)"
47+
);
48+
}
49+
None => None,
50+
};
51+
52+
let index = psbt.inputs.len();
53+
add_input_with_descriptor(
54+
&mut psbt,
55+
index,
56+
txid,
57+
vout,
58+
value,
59+
script_pubkey,
60+
&descriptor,
61+
sequence,
62+
non_witness_utxo,
63+
)
64+
.map_err(|e| anyhow!(e))
65+
.with_context(|| format!("failed to add input {index}"))?;
66+
67+
print_psbt(&psbt);
68+
Ok(())
69+
}
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
use anyhow::{anyhow, bail, Context, Result};
2+
use std::path::PathBuf;
3+
use wasm_utxo::bitcoin::psbt::Output as PsbtOutput;
4+
use wasm_utxo::bitcoin::{Amount, ScriptBuf, TxOut};
5+
use wasm_utxo::psbt_ops::insert_output;
6+
use wasm_utxo::to_output_script_with_network;
7+
8+
use super::common::{print_psbt, read_psbt};
9+
use crate::network::NetworkArg;
10+
11+
pub fn handle_add_output_command(
12+
path: PathBuf,
13+
network: Option<NetworkArg>,
14+
address: Option<String>,
15+
script: Option<String>,
16+
value: u64,
17+
) -> Result<()> {
18+
let mut psbt = read_psbt(&path)?;
19+
20+
let script_pubkey = match (address, script) {
21+
(Some(address), None) => {
22+
let network = network
23+
.ok_or_else(|| anyhow!("--network is required when using --address"))?
24+
.into();
25+
to_output_script_with_network(&address, network).context("invalid --address")?
26+
}
27+
(None, Some(script)) => {
28+
ScriptBuf::from(hex::decode(&script).context("invalid --script hex")?)
29+
}
30+
_ => bail!("expected exactly one of --address or --script"),
31+
};
32+
33+
let index = psbt.outputs.len();
34+
insert_output(
35+
&mut psbt,
36+
index,
37+
TxOut {
38+
value: Amount::from_sat(value),
39+
script_pubkey,
40+
},
41+
PsbtOutput::default(),
42+
)
43+
.map_err(|e| anyhow!(e))
44+
.with_context(|| format!("failed to add output {index}"))?;
45+
46+
print_psbt(&psbt);
47+
Ok(())
48+
}
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
use anyhow::{Context, Result};
2+
use std::path::PathBuf;
3+
use wasm_utxo::bitcoin::psbt::Psbt;
4+
5+
use crate::input::{decode_input, read_input_bytes};
6+
7+
/// Read and deserialize a PSBT from a file path or stdin (`-`), auto-detecting
8+
/// hex/base64/raw encoding.
9+
pub fn read_psbt(path: &PathBuf) -> Result<Psbt> {
10+
let raw_bytes = read_input_bytes(path, "PSBT")?;
11+
let bytes = decode_input(&raw_bytes)?;
12+
Psbt::deserialize(&bytes).context("Failed to parse PSBT")
13+
}
14+
15+
/// Serialize a PSBT and print it as hex to stdout.
16+
pub fn print_psbt(psbt: &Psbt) {
17+
println!("{}", hex::encode(psbt.serialize()));
18+
}
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
use anyhow::Result;
2+
use wasm_utxo::bitcoin::locktime::absolute::LockTime;
3+
use wasm_utxo::bitcoin::psbt::Psbt;
4+
use wasm_utxo::bitcoin::transaction::{Transaction, Version};
5+
6+
use super::common::print_psbt;
7+
8+
pub fn handle_create_command(version: i32, lock_time: u32) -> Result<()> {
9+
let tx = Transaction {
10+
version: Version(version),
11+
lock_time: LockTime::from_consensus(lock_time),
12+
input: vec![],
13+
output: vec![],
14+
};
15+
let psbt = Psbt::from_unsigned_tx(tx).expect("empty transaction should be valid");
16+
print_psbt(&psbt);
17+
Ok(())
18+
}

packages/wasm-utxo/cli/src/psbt/mod.rs

Lines changed: 130 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,22 @@
11
use anyhow::Result;
2-
use clap::Subcommand;
2+
use clap::{ArgGroup, Subcommand};
3+
use std::path::PathBuf;
34

45
use crate::network::NetworkArg;
56

7+
mod add_input;
8+
mod add_output;
9+
mod common;
10+
mod create;
611
mod parse;
12+
mod sign;
713

814
#[derive(Subcommand)]
915
pub enum PsbtCommand {
1016
/// Parse a PSBT file and display its contents
1117
Parse {
1218
/// Path to the PSBT file (use '-' to read from stdin)
13-
path: std::path::PathBuf,
19+
path: PathBuf,
1420
/// Network for address formatting
1521
#[arg(long, short, value_enum)]
1622
network: NetworkArg,
@@ -21,6 +27,90 @@ pub enum PsbtCommand {
2127
#[arg(long)]
2228
raw: bool,
2329
},
30+
/// Create an empty PSBT. Prints the PSBT as hex to stdout.
31+
Create {
32+
/// Transaction version (default: 4, required for Zcash overwintered txs)
33+
#[arg(long, default_value_t = 4)]
34+
version: i32,
35+
/// Transaction lock time (default: 0)
36+
#[arg(long, default_value_t = 0)]
37+
lock_time: u32,
38+
},
39+
/// Add an input spending `{txid, vout, value, scriptPubKey}` to a PSBT, given a definite
40+
/// descriptor for the output (e.g. `pkh(<pubkey>)`, `wpkh(<pubkey>)`). Prints the updated
41+
/// PSBT as hex to stdout.
42+
AddInput {
43+
/// Path to the PSBT file (use '-' to read from stdin)
44+
path: PathBuf,
45+
/// Network (determines whether --prev-tx is required; e.g. tzec for Zcash regtest)
46+
#[arg(long, short, value_enum)]
47+
network: NetworkArg,
48+
/// Transaction ID of the output being spent
49+
#[arg(long)]
50+
txid: String,
51+
/// Output index being spent
52+
#[arg(long)]
53+
vout: u32,
54+
/// Value in satoshis of the output being spent
55+
#[arg(long)]
56+
value: u64,
57+
/// scriptPubKey of the output being spent, hex-encoded
58+
#[arg(long)]
59+
script: String,
60+
/// Definite descriptor for the output being spent, with concrete keys
61+
/// (e.g. `pkh(<pubkey>)`, `wpkh(<pubkey>)`); populates bip32_derivation/tap_key_origins
62+
#[arg(long)]
63+
descriptor: String,
64+
/// Sequence number (default: 0xFFFFFFFE)
65+
#[arg(long, default_value_t = 0xFFFFFFFE)]
66+
sequence: u32,
67+
/// Full previous transaction, hex-encoded (BIP174-safe path). Required unless --network
68+
/// is a value-committing network (Zcash, BCH-family) whose sighash already commits the
69+
/// input amount, making it safe to sign from --value/--script alone.
70+
#[arg(long = "prev-tx")]
71+
prev_tx: Option<String>,
72+
},
73+
/// Add an output to a PSBT. Prints the updated PSBT as hex to stdout.
74+
#[command(group(ArgGroup::new("target").required(true).args(["address", "script"])))]
75+
AddOutput {
76+
/// Path to the PSBT file (use '-' to read from stdin)
77+
path: PathBuf,
78+
/// Output address (requires --network)
79+
#[arg(long)]
80+
address: Option<String>,
81+
/// Output script, hex-encoded
82+
#[arg(long)]
83+
script: Option<String>,
84+
/// Value in satoshis
85+
#[arg(long)]
86+
value: u64,
87+
/// Network for --address (e.g. tzec for Zcash regtest, which reuses testnet prefixes)
88+
#[arg(long, short, value_enum)]
89+
network: Option<NetworkArg>,
90+
},
91+
/// Sign all inputs with a single private key, then finalize and extract. The sighash
92+
/// algorithm is selected by --network: plain for BTC-like networks, FORKID for the
93+
/// BCH family, or Zcash ZIP-243. Prints the signed wire hex to stdout (overwintered
94+
/// format for Zcash).
95+
Sign {
96+
/// Path to the PSBT file (use '-' to read from stdin)
97+
path: PathBuf,
98+
/// Network (selects the sighash algorithm: plain, FORKID, or Zcash ZIP-243)
99+
#[arg(long, short, value_enum)]
100+
network: NetworkArg,
101+
/// Controlling private key for all inputs (WIF or hex)
102+
#[arg(long)]
103+
privkey: String,
104+
/// Zcash consensus branch ID, hex (0x...) or decimal (required for Zcash, unused otherwise)
105+
#[arg(long)]
106+
consensus_branch_id: Option<String>,
107+
/// Zcash version group ID, hex or decimal (default: Sapling 0x892F2085; Zcash only)
108+
#[arg(long)]
109+
version_group_id: Option<String>,
110+
/// Transaction expiry height (default: 0, no expiry; Zcash only)
111+
#[arg(long, default_value_t = 0)]
112+
expiry_height: u32,
113+
},
24114
}
25115

26116
pub fn handle_command(command: PsbtCommand) -> Result<()> {
@@ -31,5 +121,43 @@ pub fn handle_command(command: PsbtCommand) -> Result<()> {
31121
raw,
32122
network,
33123
} => parse::handle_parse_command(path, no_color, raw, network.into()),
124+
PsbtCommand::Create { version, lock_time } => {
125+
create::handle_create_command(version, lock_time)
126+
}
127+
PsbtCommand::AddInput {
128+
path,
129+
network,
130+
txid,
131+
vout,
132+
value,
133+
script,
134+
descriptor,
135+
sequence,
136+
prev_tx,
137+
} => add_input::handle_add_input_command(
138+
path, network, txid, vout, value, script, descriptor, sequence, prev_tx,
139+
),
140+
PsbtCommand::AddOutput {
141+
path,
142+
address,
143+
script,
144+
value,
145+
network,
146+
} => add_output::handle_add_output_command(path, network, address, script, value),
147+
PsbtCommand::Sign {
148+
path,
149+
network,
150+
privkey,
151+
consensus_branch_id,
152+
version_group_id,
153+
expiry_height,
154+
} => sign::handle_sign_command(
155+
path,
156+
network,
157+
privkey,
158+
consensus_branch_id,
159+
version_group_id,
160+
expiry_height,
161+
),
34162
}
35163
}

0 commit comments

Comments
 (0)