Skip to content

Commit 5b5297d

Browse files
Merge pull request #311 from BitGo/otto/T1-3672-transparent-signing-and-psbt
feat(wasm-utxo-cli): add psbt create/add-input/add-output/sign subcommands
2 parents f628a43 + bbc43fa commit 5b5297d

16 files changed

Lines changed: 1107 additions & 130 deletions

File tree

packages/wasm-utxo/cli/README.md

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,29 @@ wasm-utxo-cli address encode 0014e8df018c7e326cc253faac7e46cdc51e68542c42
6868
# Output: bc1qar0srrr7xfkvy5l643lydnw9re59gtzzwf5mdq
6969
```
7070

71+
#### Derive an address from a descriptor
72+
73+
```bash
74+
wasm-utxo-cli address from-descriptor <DESCRIPTOR> --network <NETWORK>
75+
```
76+
77+
The descriptor may name a plain public key or embed a private key directly (e.g. a WIF) —
78+
either way, the resulting address is derived from the corresponding public key. Works for any
79+
network, not just Bitcoin.
80+
81+
**Examples:**
82+
83+
```bash
84+
# BTC: derive a P2PKH address from a public key
85+
wasm-utxo-cli address from-descriptor "pkh(039ab0771c5f88913208a26f81ab8223e98d25176e4648a5a2bb8ff79cf1c5198b)" --network btc
86+
# Output: 1GwwqAWsJzDHMZ9ceJqrJDfch4gsro4c3x
87+
88+
# Zcash: derive a t-address directly from a WIF private key (e.g. for zebrad's miner_address on
89+
# regtest — Zcash regtest reuses testnet address prefixes, so pass --network tzec)
90+
wasm-utxo-cli address from-descriptor "pkh(KzEGYtKcbhYwUWcZygbsqmF31f3iV7HC3iUQug7MBecwCz9hm1Tv)" --network tzec
91+
# Output: tmRfJALRQfyWP6SPxFTxiHhSHYhxn7rwkLv
92+
```
93+
7194
### PSBT Operations
7295

7396
#### Parse and inspect a PSBT
@@ -102,6 +125,54 @@ The output displays a hierarchical tree view of the PSBT structure, including:
102125
- Per-output fields (scripts, derivation paths)
103126
- Decoded transaction details
104127

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+
105176
### Supported Networks
106177

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

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

Lines changed: 25 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,10 @@
1-
use anyhow::{Context, Result};
1+
use anyhow::{anyhow, Context, Result};
22
use clap::Subcommand;
33
use wasm_utxo::bitcoin::Script;
4-
use wasm_utxo::{from_output_script_with_network, to_output_script_with_network, Network};
4+
use wasm_utxo::{
5+
from_output_script_with_network, script_pubkey_from_descriptor, to_output_script_with_network,
6+
Network,
7+
};
58

69
use crate::network::NetworkArg;
710

@@ -23,6 +26,15 @@ pub enum AddressCommand {
2326
#[arg(short, long, value_enum)]
2427
network: NetworkArg,
2528
},
29+
/// Print the address for a descriptor, which may embed a private key (e.g. `pkh(<wif>)`,
30+
/// `wpkh(<wif>)`)
31+
FromDescriptor {
32+
/// Descriptor, e.g. `pkh(<privkey>)`
33+
descriptor: String,
34+
/// Network (btc, tbtc, ltc, bch, zec, tzec, etc.)
35+
#[arg(short, long, value_enum)]
36+
network: NetworkArg,
37+
},
2638
}
2739

2840
pub fn handle_command(command: AddressCommand) -> Result<()> {
@@ -44,5 +56,16 @@ pub fn handle_command(command: AddressCommand) -> Result<()> {
4456
println!("{}", address);
4557
Ok(())
4658
}
59+
AddressCommand::FromDescriptor {
60+
descriptor,
61+
network,
62+
} => {
63+
let network: Network = network.into();
64+
let script = script_pubkey_from_descriptor(&descriptor).map_err(|e| anyhow!(e))?;
65+
let address = from_output_script_with_network(&script, network)
66+
.context("Failed to encode address")?;
67+
println!("{}", address);
68+
Ok(())
69+
}
4770
}
4871
}

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

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,29 @@ use base64::Engine;
33
use std::fs;
44
use std::io::{self, Read};
55
use std::path::PathBuf;
6+
use std::str::FromStr;
7+
use wasm_utxo::bitcoin::network::Network as BitcoinNetwork;
8+
use wasm_utxo::bitcoin::PrivateKey;
9+
10+
/// Parse a private key given as WIF or raw hex bytes.
11+
pub fn parse_private_key(s: &str) -> Result<PrivateKey> {
12+
if let Ok(pk) = PrivateKey::from_str(s) {
13+
return Ok(pk);
14+
}
15+
let bytes = hex::decode(s).context("private key must be WIF or hex")?;
16+
PrivateKey::from_slice(&bytes, BitcoinNetwork::Bitcoin).context("invalid private key bytes")
17+
}
18+
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+
}
629

730
/// Decode input bytes, attempting to interpret as base64, hex, or raw bytes
831
pub fn decode_input(raw_bytes: &[u8]) -> Result<Vec<u8>> {
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+
}

0 commit comments

Comments
 (0)