Skip to content
Merged
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
71 changes: 71 additions & 0 deletions packages/wasm-utxo/cli/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,29 @@ wasm-utxo-cli address encode 0014e8df018c7e326cc253faac7e46cdc51e68542c42
# Output: bc1qar0srrr7xfkvy5l643lydnw9re59gtzzwf5mdq
```

#### Derive an address from a descriptor

```bash
wasm-utxo-cli address from-descriptor <DESCRIPTOR> --network <NETWORK>
```

The descriptor may name a plain public key or embed a private key directly (e.g. a WIF) —
either way, the resulting address is derived from the corresponding public key. Works for any
network, not just Bitcoin.

**Examples:**

```bash
# BTC: derive a P2PKH address from a public key
wasm-utxo-cli address from-descriptor "pkh(039ab0771c5f88913208a26f81ab8223e98d25176e4648a5a2bb8ff79cf1c5198b)" --network btc
# Output: 1GwwqAWsJzDHMZ9ceJqrJDfch4gsro4c3x

# Zcash: derive a t-address directly from a WIF private key (e.g. for zebrad's miner_address on
# regtest — Zcash regtest reuses testnet address prefixes, so pass --network tzec)
wasm-utxo-cli address from-descriptor "pkh(KzEGYtKcbhYwUWcZygbsqmF31f3iV7HC3iUQug7MBecwCz9hm1Tv)" --network tzec
# Output: tmRfJALRQfyWP6SPxFTxiHhSHYhxn7rwkLv
```

### PSBT Operations

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

#### Build and sign a transparent transaction

Four composable subcommands — `create`, `add-input`, `add-output`, `sign` — each read a PSBT
from a file or stdin (`-`) and print the result as hex, so they pipe together into a full
build-and-sign flow for a single-key transparent (non-segwit) transaction. Works for any
network; the sighash algorithm used by `sign` (plain, FORKID, or Zcash ZIP-243) is selected by
`--network`.

```bash
wasm-utxo-cli psbt create [--version <VERSION>] [--lock-time <LOCK_TIME>]
wasm-utxo-cli psbt add-input <PATH> --network <NETWORK> --txid <TXID> --vout <VOUT> \
--value <VALUE> --script <SCRIPT_HEX> --descriptor <DESCRIPTOR> [--prev-tx <PREV_TX_HEX>]
wasm-utxo-cli psbt add-output <PATH> (--address <ADDRESS> --network <NETWORK> | --script <SCRIPT_HEX>) --value <VALUE>
wasm-utxo-cli psbt sign <PATH> --network <NETWORK> --privkey <PRIVKEY> [--consensus-branch-id <ID>]
```

`add-input` requires `--prev-tx` (the full previous transaction, hex-encoded) unless
`--network` is a value-committing network (Zcash, BCH family) whose sighash already commits the
spent amount — see `Network::requires_prev_tx_for_legacy_input` in the `wasm-utxo` library.

**Examples:**

```bash
# BTC: spend a P2PKH coinbase-style output, providing the full previous transaction
wasm-utxo-cli psbt create \
| wasm-utxo-cli psbt add-input - --network btc \
--txid e6a6e7f5551af932bc3813d920c52d61ec39fbad2e5585a018cce7dcbdd4ec72 --vout 0 \
--value 50000000 --script 76a914aeee1e6ae364e64b1b36acd53df55bd8d750485888ac \
--descriptor "pkh(039ab0771c5f88913208a26f81ab8223e98d25176e4648a5a2bb8ff79cf1c5198b)" \
--prev-tx 010000000100000000000000000000000000000000000000000000000000000000000000000000000000ffffffff0180f0fa02000000001976a914aeee1e6ae364e64b1b36acd53df55bd8d750485888ac00000000 \
| wasm-utxo-cli psbt add-output - --script 76a914aeee1e6ae364e64b1b36acd53df55bd8d750485888ac --value 49990000 \
| wasm-utxo-cli psbt sign - --network btc --privkey KzEGYtKcbhYwUWcZygbsqmF31f3iV7HC3iUQug7MBecwCz9hm1Tv
# Output: a plain-sighash signed BTC transaction, hex-encoded

# Zcash: spend a transparent P2PKH coinbase output for regtest fixture generation
# (e.g. for a zebrad-mined UTXO to broadcast via sendrawtransaction). No --prev-tx needed —
# ZIP-243 sighash commits the input amount.
wasm-utxo-cli psbt create --lock-time 0 \
| wasm-utxo-cli psbt add-input - --network tzec \
--txid a8c685478265f4c14dada651969c45a65e1aeb8cd6791f2f5bb6a1d9952104d9 --vout 0 \
--value 50000000 --script 76a914aeee1e6ae364e64b1b36acd53df55bd8d750485888ac \
--descriptor "pkh(039ab0771c5f88913208a26f81ab8223e98d25176e4648a5a2bb8ff79cf1c5198b)" \
| wasm-utxo-cli psbt add-output - --script 76a914aeee1e6ae364e64b1b36acd53df55bd8d750485888ac --value 49990000 \
| wasm-utxo-cli psbt sign - --network tzec --privkey KzEGYtKcbhYwUWcZygbsqmF31f3iV7HC3iUQug7MBecwCz9hm1Tv \
--consensus-branch-id 0xc2d6d0b4
# Output: a signed Zcash overwintered (NU5) transaction, hex-encoded
```

### Supported Networks

The CLI supports the following networks (use with `--network` flag):
Expand Down
27 changes: 25 additions & 2 deletions packages/wasm-utxo/cli/src/address.rs
Original file line number Diff line number Diff line change
@@ -1,7 +1,10 @@
use anyhow::{Context, Result};
use anyhow::{anyhow, Context, Result};
use clap::Subcommand;
use wasm_utxo::bitcoin::Script;
use wasm_utxo::{from_output_script_with_network, to_output_script_with_network, Network};
use wasm_utxo::{
from_output_script_with_network, script_pubkey_from_descriptor, to_output_script_with_network,
Network,
};

use crate::network::NetworkArg;

Expand All @@ -23,6 +26,15 @@ pub enum AddressCommand {
#[arg(short, long, value_enum)]
network: NetworkArg,
},
/// Print the address for a descriptor, which may embed a private key (e.g. `pkh(<wif>)`,
/// `wpkh(<wif>)`)
FromDescriptor {
/// Descriptor, e.g. `pkh(<privkey>)`
descriptor: String,
/// Network (btc, tbtc, ltc, bch, zec, tzec, etc.)
#[arg(short, long, value_enum)]
network: NetworkArg,
},
}

pub fn handle_command(command: AddressCommand) -> Result<()> {
Expand All @@ -44,5 +56,16 @@ pub fn handle_command(command: AddressCommand) -> Result<()> {
println!("{}", address);
Ok(())
}
AddressCommand::FromDescriptor {
descriptor,
network,
} => {
let network: Network = network.into();
let script = script_pubkey_from_descriptor(&descriptor).map_err(|e| anyhow!(e))?;
let address = from_output_script_with_network(&script, network)
.context("Failed to encode address")?;
println!("{}", address);
Ok(())
}
}
}
23 changes: 23 additions & 0 deletions packages/wasm-utxo/cli/src/input.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,29 @@ use base64::Engine;
use std::fs;
use std::io::{self, Read};
use std::path::PathBuf;
use std::str::FromStr;
use wasm_utxo::bitcoin::network::Network as BitcoinNetwork;
use wasm_utxo::bitcoin::PrivateKey;

/// Parse a private key given as WIF or raw hex bytes.
pub fn parse_private_key(s: &str) -> Result<PrivateKey> {
if let Ok(pk) = PrivateKey::from_str(s) {
return Ok(pk);
}
let bytes = hex::decode(s).context("private key must be WIF or hex")?;
PrivateKey::from_slice(&bytes, BitcoinNetwork::Bitcoin).context("invalid private key bytes")
}

/// Parse a `u32` given as decimal or `0x`-prefixed hex.
pub fn parse_u32_flexible(s: &str) -> Result<u32> {
let s = s.trim();
if let Some(hex) = s.strip_prefix("0x").or_else(|| s.strip_prefix("0X")) {
u32::from_str_radix(hex, 16).with_context(|| format!("invalid hex u32: {s}"))
} else {
s.parse::<u32>()
.with_context(|| format!("invalid u32: {s}"))
}
}

/// Decode input bytes, attempting to interpret as base64, hex, or raw bytes
pub fn decode_input(raw_bytes: &[u8]) -> Result<Vec<u8>> {
Expand Down
69 changes: 69 additions & 0 deletions packages/wasm-utxo/cli/src/psbt/add_input.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
use anyhow::{anyhow, bail, Context, Result};
use std::path::PathBuf;
use std::str::FromStr;
use wasm_utxo::add_input_with_descriptor;
use wasm_utxo::bitcoin::consensus::Decodable;
use wasm_utxo::bitcoin::{ScriptBuf, Transaction, Txid};

use super::common::{print_psbt, read_psbt};
use crate::network::NetworkArg;

#[allow(clippy::too_many_arguments)]
pub fn handle_add_input_command(
path: PathBuf,
network: NetworkArg,
txid: String,
vout: u32,
value: u64,
script: String,
descriptor: String,
sequence: u32,
prev_tx: Option<String>,
) -> Result<()> {
let network: wasm_utxo::Network = network.into();
let mut psbt = read_psbt(&path)?;

let txid = Txid::from_str(&txid).context("invalid --txid")?;
let script_pubkey = ScriptBuf::from(hex::decode(&script).context("invalid --script hex")?);

let non_witness_utxo = match prev_tx {
Some(hex_str) => {
let bytes = hex::decode(&hex_str).context("invalid --prev-tx hex")?;
let tx = Transaction::consensus_decode(&mut bytes.as_slice())
.context("invalid --prev-tx transaction")?;
if tx.compute_txid() != txid {
bail!(
"--prev-tx txid {} does not match --txid {}",
tx.compute_txid(),
txid
);
}
Some(tx)
}
None if network.requires_prev_tx_for_legacy_input() => {
bail!(
"--prev-tx is required for network {network} (only value-committing networks \
like Zcash/BCH-family can omit it)"
);
}
None => None,
};

let index = psbt.inputs.len();
add_input_with_descriptor(
&mut psbt,
index,
txid,
vout,
value,
script_pubkey,
&descriptor,
sequence,
non_witness_utxo,
)
.map_err(|e| anyhow!(e))
.with_context(|| format!("failed to add input {index}"))?;

print_psbt(&psbt);
Ok(())
}
48 changes: 48 additions & 0 deletions packages/wasm-utxo/cli/src/psbt/add_output.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
use anyhow::{anyhow, bail, Context, Result};
use std::path::PathBuf;
use wasm_utxo::bitcoin::psbt::Output as PsbtOutput;
use wasm_utxo::bitcoin::{Amount, ScriptBuf, TxOut};
use wasm_utxo::psbt_ops::insert_output;
use wasm_utxo::to_output_script_with_network;

use super::common::{print_psbt, read_psbt};
use crate::network::NetworkArg;

pub fn handle_add_output_command(
path: PathBuf,
network: Option<NetworkArg>,
address: Option<String>,
script: Option<String>,
value: u64,
) -> Result<()> {
let mut psbt = read_psbt(&path)?;

let script_pubkey = match (address, script) {
(Some(address), None) => {
let network = network
.ok_or_else(|| anyhow!("--network is required when using --address"))?
.into();
to_output_script_with_network(&address, network).context("invalid --address")?
}
(None, Some(script)) => {
ScriptBuf::from(hex::decode(&script).context("invalid --script hex")?)
}
_ => bail!("expected exactly one of --address or --script"),
};

let index = psbt.outputs.len();
insert_output(
&mut psbt,
index,
TxOut {
value: Amount::from_sat(value),
script_pubkey,
},
PsbtOutput::default(),
)
.map_err(|e| anyhow!(e))
.with_context(|| format!("failed to add output {index}"))?;

print_psbt(&psbt);
Ok(())
}
18 changes: 18 additions & 0 deletions packages/wasm-utxo/cli/src/psbt/common.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
use anyhow::{Context, Result};
use std::path::PathBuf;
use wasm_utxo::bitcoin::psbt::Psbt;

use crate::input::{decode_input, read_input_bytes};

/// Read and deserialize a PSBT from a file path or stdin (`-`), auto-detecting
/// hex/base64/raw encoding.
pub fn read_psbt(path: &PathBuf) -> Result<Psbt> {
let raw_bytes = read_input_bytes(path, "PSBT")?;
let bytes = decode_input(&raw_bytes)?;
Psbt::deserialize(&bytes).context("Failed to parse PSBT")
}

/// Serialize a PSBT and print it as hex to stdout.
pub fn print_psbt(psbt: &Psbt) {
println!("{}", hex::encode(psbt.serialize()));
}
18 changes: 18 additions & 0 deletions packages/wasm-utxo/cli/src/psbt/create.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
use anyhow::Result;
use wasm_utxo::bitcoin::locktime::absolute::LockTime;
use wasm_utxo::bitcoin::psbt::Psbt;
use wasm_utxo::bitcoin::transaction::{Transaction, Version};

use super::common::print_psbt;

pub fn handle_create_command(version: i32, lock_time: u32) -> Result<()> {
let tx = Transaction {
version: Version(version),
lock_time: LockTime::from_consensus(lock_time),
input: vec![],
output: vec![],
};
let psbt = Psbt::from_unsigned_tx(tx).expect("empty transaction should be valid");
print_psbt(&psbt);
Ok(())
}
Loading
Loading