Skip to content
Draft
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
1 change: 1 addition & 0 deletions packages/wasm-utxo/Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

6 changes: 6 additions & 0 deletions packages/wasm-utxo/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,12 @@ unexpected_cfgs = { level = "warn", check-cfg = [
[features]
default = []
inspect = ["dep:num-bigint", "dep:serde_json", "dep:hex"]
# Pulls in orchard's halo2 circuit/proving-key machinery so this crate can produce its own
# Ironwood zk-proofs locally, instead of relying on an external proof service. Heavy (halo2
# circuit synthesis + proving-key generation), so it's opt-in and off by default — the shipped
# WASM build (browser consumers) must not link the circuit. Standalone tooling (the CLI) enables
# it explicitly.
orchard-proving = ["orchard/circuit"]

[dependencies]
wasm-bindgen = "0.2"
Expand Down
3 changes: 2 additions & 1 deletion packages/wasm-utxo/cli/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ name = "wasm-utxo-cli"
path = "src/main.rs"

[dependencies]
wasm-utxo = { path = "..", features = ["inspect"] }
wasm-utxo = { path = "..", features = ["inspect", "orchard-proving"] }
clap = { version = "4.5", features = ["derive"] }
anyhow = "1.0"
hex = "0.4"
Expand All @@ -18,3 +18,4 @@ serde_json = "1.0"
num-bigint = "0.4"
colored = "2.1"
ptree = "0.5"
rand = "0.8"
45 changes: 45 additions & 0 deletions packages/wasm-utxo/cli/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -173,6 +173,51 @@ wasm-utxo-cli psbt create --lock-time 0 \
# Output: a signed Zcash overwintered (NU5) transaction, hex-encoded
```

#### Build and sign a Zcash v6 (Ironwood) shielding transaction

Six composable subcommands — `create-zcash-v6`, `add-input`, `add-output`, `add-shielded-output`,
`sign-v6-input`, `combine-ironwood-proof` — 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 **transparent →
shielded** ("shielding") v6 transaction.

```bash
wasm-utxo-cli psbt create-zcash-v6 --network <NETWORK> --consensus-branch-id <ID> [--lock-time <LOCK_TIME>] [--expiry-height <HEIGHT>]
wasm-utxo-cli psbt add-input <PATH> --network <NETWORK> --txid <TXID> --vout <VOUT> --value <VALUE> --script <SCRIPT_HEX> --descriptor <DESCRIPTOR>
wasm-utxo-cli psbt add-output <PATH> (--address <ADDRESS> --network <NETWORK> | --script <SCRIPT_HEX>) --value <VALUE>
wasm-utxo-cli psbt add-shielded-output <PATH> --network <NETWORK> --recipient <HEX43> --value <ZATOSHI> --anchor <HEX32> [--ovk <HEX32>] [--memo <HEX512>]
wasm-utxo-cli psbt sign-v6-input <PATH> --network <NETWORK> --index <INDEX> --privkey <PRIVKEY>
wasm-utxo-cli psbt combine-ironwood-proof <PATH> --network <NETWORK> (--proof <HEX> | --local-proof)
```

`add-output` is optional (a fully-shielding transaction may spend its whole input to the shielded
output plus fee). `add-shielded-output` supports exactly one shielded output. `sign-v6-input` is
called once per required signature — each spent transparent input's redeem script must be a 2-of-3
CHECKMULTISIG script (BitGo's fixed-script wallet shape); a plain single-key (`pkh(...)`) input is
not supported on this path. `combine-ironwood-proof` either splices in a proof obtained from an
external prover (`--proof`) or produces one locally (`--local-proof`, heavier — builds a halo2
proving key and synthesizes the circuit).

**Example:**

```bash
# Zcash testnet: shield 1.9 TAZ from a 2-of-3 P2SH multisig transparent input into an
# Ironwood/Orchard note, leaving no transparent change (fee = input - shielded amount).
wasm-utxo-cli psbt create-zcash-v6 --network tzec --consensus-branch-id 0x37a5165b --expiry-height 4253200 \
| wasm-utxo-cli psbt add-input - --network tzec \
--txid 1ebd1da314f021d7c7b2ced6c0340067ebf3ce422bf8c53daa626d72cbd9fe73 --vout 1 \
--value 2000000 --script a914ed68766fe37d9e2325758ed209ac78db505425a987 \
--descriptor "sh(multi(2,023b4221b042fa25af6609d7e65d322fcb64c497b79ffc8f1891ea6b23d4e7d84a,02feaf8248a2f8dcc34f2e2f520201801bb88d20ab549baf47b48bc9f2f4dfcc93,030b82f01fd53e7dabe2d904938d64294e3352e9e836240af6ba2cfb9df8f837da))" \
| wasm-utxo-cli psbt add-shielded-output - --network tzec \
--recipient 4559029c0b5dbf941c5ad181a5fe8f45b34630f29d0c8dd8dc1cc3573386f416cb324133156d723df5e62d \
--value 1900000 --anchor 179fa4ebcadd3006a14b0ea80380e6e14287e453fc468fa93c7f73c88f87b408 \
| wasm-utxo-cli psbt sign-v6-input - --network tzec --index 0 --privkey cQ2ws3NRbFQVR3LUDxZoF1gvCHYM215QsiQ1gCHygJi1Jvdp1qzK \
| wasm-utxo-cli psbt sign-v6-input - --network tzec --index 0 --privkey cU7jx2bsp3Vj3DDi2v9vFJLuU777M9TcpFFa6Ga9qkKBsT4vbJHf \
| wasm-utxo-cli psbt combine-ironwood-proof - --network tzec --local-proof
# Output: a broadcast-ready Zcash v6 (Ironwood) transaction, hex-encoded. This exact command was
# submitted to a live Zcash testnet node and accepted into its mempool
# (txid aa7d9d9401cf70901cf76c81cab06e7001879607f60e1a2ffa4d4afa4a786238).
```

### Supported Networks

The CLI supports the following networks (use with `--network` flag):
Expand Down
54 changes: 54 additions & 0 deletions packages/wasm-utxo/cli/src/psbt/add_shielded_output.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
use anyhow::{anyhow, Context, Result};
use rand::rngs::OsRng;
use std::path::PathBuf;
use wasm_utxo::fixed_script_wallet::bitgo_psbt::ZcashBitGoPsbt;
use wasm_utxo::Network;

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

#[allow(clippy::too_many_arguments)]
pub fn handle_add_shielded_output_command(
path: PathBuf,
network: Network,
recipient: String,
value: u64,
anchor: String,
ovk: Option<String>,
memo: Option<String>,
) -> Result<()> {
let raw_bytes = read_input_bytes(&path, "PSBT")?;
let bytes = decode_input(&raw_bytes)?;
let mut psbt = ZcashBitGoPsbt::deserialize_v6_pre_shield(&bytes, network)
.map_err(|e| anyhow!("Failed to parse v6 PSBT: {e}"))?;

let recipient: [u8; 43] = hex::decode(&recipient)
.context("invalid --recipient hex")?
.try_into()
.map_err(|v: Vec<u8>| anyhow!("--recipient must be 43 bytes, got {}", v.len()))?;
let anchor: [u8; 32] = hex::decode(&anchor)
.context("invalid --anchor hex")?
.try_into()
.map_err(|v: Vec<u8>| anyhow!("--anchor must be 32 bytes, got {}", v.len()))?;
let ovk = ovk
.map(|s| {
hex::decode(&s)
.context("invalid --ovk hex")?
.try_into()
.map_err(|v: Vec<u8>| anyhow!("--ovk must be 32 bytes, got {}", v.len()))
})
.transpose()?;
let memo: [u8; 512] = match memo {
Some(s) => hex::decode(&s)
.context("invalid --memo hex")?
.try_into()
.map_err(|v: Vec<u8>| anyhow!("--memo must be 512 bytes, got {}", v.len()))?,
None => [0u8; 512],
};

psbt.add_ironwood_output(&recipient, value, ovk, &anchor, &memo, OsRng)
.map_err(|e| anyhow!(e))
.context("failed to add shielded output")?;

println!("{}", hex::encode(psbt.serialize().map_err(|e| anyhow!(e))?));
Ok(())
}
37 changes: 37 additions & 0 deletions packages/wasm-utxo/cli/src/psbt/combine_ironwood_proof.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
use anyhow::{anyhow, bail, Context, Result};
use rand::rngs::OsRng;
use std::path::PathBuf;
use wasm_utxo::fixed_script_wallet::bitgo_psbt::ZcashBitGoPsbt;
use wasm_utxo::Network;

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

/// Transaction Extractor role: finalize the transparent inputs, splice in the proof (external, via
/// `--proof`, or produced locally via `--local-proof`), and produce the broadcast-ready v6
/// transaction bytes. Prints the raw transaction as hex.
pub fn handle_combine_ironwood_proof_command(
path: PathBuf,
network: Network,
proof: Option<String>,
local_proof: bool,
) -> Result<()> {
let raw_bytes = read_input_bytes(&path, "PSBT")?;
let bytes = decode_input(&raw_bytes)?;
let psbt = ZcashBitGoPsbt::deserialize(&bytes, network)
.map_err(|e| anyhow!("Failed to parse v6 PSBT: {e}"))?;

let tx_bytes = match (proof, local_proof) {
(Some(_), true) => bail!("expected exactly one of --proof or --local-proof"),
(Some(proof), false) => {
let proof = hex::decode(&proof).context("invalid --proof hex")?;
psbt.combine_ironwood_proof(proof, OsRng)
}
(None, true) => psbt.combine_ironwood_proof_locally(OsRng),
(None, false) => bail!("expected exactly one of --proof or --local-proof"),
}
.map_err(|e| anyhow!(e))
.context("failed to combine Ironwood proof")?;

println!("{}", hex::encode(tx_bytes));
Ok(())
}
25 changes: 25 additions & 0 deletions packages/wasm-utxo/cli/src/psbt/create_zcash_v6.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
use anyhow::{anyhow, Context, Result};
use wasm_utxo::fixed_script_wallet::bitgo_psbt::ZcashBitGoPsbt;
use wasm_utxo::Network;

use crate::input::parse_u32_flexible;

pub fn handle_create_zcash_v6_command(
network: Network,
consensus_branch_id: String,
lock_time: u32,
expiry_height: u32,
) -> Result<()> {
let consensus_branch_id =
parse_u32_flexible(&consensus_branch_id).context("invalid --consensus-branch-id")?;

let psbt = ZcashBitGoPsbt::new_v6_bare(
network,
consensus_branch_id,
Some(lock_time),
Some(expiry_height),
);

println!("{}", hex::encode(psbt.serialize().map_err(|e| anyhow!(e))?));
Ok(())
}
126 changes: 126 additions & 0 deletions packages/wasm-utxo/cli/src/psbt/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,14 @@ use crate::network::NetworkArg;

mod add_input;
mod add_output;
mod add_shielded_output;
mod combine_ironwood_proof;
mod common;
mod create;
mod create_zcash_v6;
mod parse;
mod sign;
mod sign_v6_input;

#[derive(Subcommand)]
pub enum PsbtCommand {
Expand Down Expand Up @@ -88,6 +92,83 @@ pub enum PsbtCommand {
#[arg(long, short, value_enum)]
network: Option<NetworkArg>,
},
/// Create an empty Zcash **v6 (Ironwood) shielding** PSBT, without embedding any xpubs.
/// Prints the PSBT as hex to stdout. Follow with `add-input` (transparent inputs),
/// `add-output` (transparent outputs), and `add-shielded-output` (the Ironwood output).
CreateZcashV6 {
/// Network (must be zec or tzec)
#[arg(long, short, value_enum)]
network: NetworkArg,
/// Zcash consensus branch ID, hex (0x...) or decimal — must be at or after NU6.3
/// (Ironwood) activation
#[arg(long)]
consensus_branch_id: String,
/// Transaction lock time (default: 0)
#[arg(long, default_value_t = 0)]
lock_time: u32,
/// Transaction expiry height (default: 0, no expiry)
#[arg(long, default_value_t = 0)]
expiry_height: u32,
},
/// Add the shielded (Ironwood) output to a v6 PSBT — the Constructor role. Exactly one
/// shielded output is supported. Prints the updated PSBT as hex to stdout.
AddShieldedOutput {
/// Path to the PSBT file (use '-' to read from stdin)
path: PathBuf,
/// Network (must be zec or tzec)
#[arg(long, short, value_enum)]
network: NetworkArg,
/// Raw 43-byte Orchard/Ironwood recipient address, hex-encoded
#[arg(long)]
recipient: String,
/// Value in zatoshi
#[arg(long)]
value: u64,
/// Current Ironwood note-commitment-tree root, hex-encoded (32 bytes)
#[arg(long)]
anchor: String,
/// Raw outgoing viewing key, hex-encoded (32 bytes)
#[arg(long)]
ovk: Option<String>,
/// Memo field, hex-encoded (512 bytes; default: all-zero)
#[arg(long)]
memo: Option<String>,
},
/// Sign one transparent input of a v6 PSBT with a single private key, over the ZIP-244
/// transparent sighash. Call once per required signature (2-of-3). Prints the updated PSBT
/// as hex to stdout.
SignV6Input {
/// Path to the PSBT file (use '-' to read from stdin)
path: PathBuf,
/// Network (must be zec or tzec)
#[arg(long, short, value_enum)]
network: NetworkArg,
/// Index of the transparent input to sign
#[arg(long)]
index: usize,
/// Controlling private key (WIF or hex)
#[arg(long)]
privkey: String,
},
/// Transaction Extractor role: given the external prover's proof bytes, finalize the
/// transparent inputs and splice in the shielded bundle to produce the broadcast-ready v6
/// transaction. The PSBT must already carry every transparent input's signatures (via
/// `sign-v6-input`) and the shielded output (via `add-shielded-output`). Prints the raw
/// transaction as hex to stdout.
CombineIronwoodProof {
/// Path to the PSBT file (use '-' to read from stdin)
path: PathBuf,
/// Network (must be zec or tzec)
#[arg(long, short, value_enum)]
network: NetworkArg,
/// Halo2 proof bytes from the external proof service, hex-encoded
#[arg(long)]
proof: Option<String>,
/// Produce the proof locally instead of supplying one via --proof (heavier: builds a
/// halo2 proving key and synthesizes the circuit)
#[arg(long)]
local_proof: bool,
},
/// Sign all inputs with a single private key, then finalize and extract. The sighash
/// algorithm is selected by --network: plain for BTC-like networks, FORKID for the
/// BCH family, or Zcash ZIP-243. Prints the signed wire hex to stdout (overwintered
Expand Down Expand Up @@ -144,6 +225,51 @@ pub fn handle_command(command: PsbtCommand) -> Result<()> {
value,
network,
} => add_output::handle_add_output_command(path, network, address, script, value),
PsbtCommand::CreateZcashV6 {
network,
consensus_branch_id,
lock_time,
expiry_height,
} => create_zcash_v6::handle_create_zcash_v6_command(
network.into(),
consensus_branch_id,
lock_time,
expiry_height,
),
PsbtCommand::AddShieldedOutput {
path,
network,
recipient,
value,
anchor,
ovk,
memo,
} => add_shielded_output::handle_add_shielded_output_command(
path,
network.into(),
recipient,
value,
anchor,
ovk,
memo,
),
PsbtCommand::SignV6Input {
path,
network,
index,
privkey,
} => sign_v6_input::handle_sign_v6_input_command(path, network.into(), index, privkey),
PsbtCommand::CombineIronwoodProof {
path,
network,
proof,
local_proof,
} => combine_ironwood_proof::handle_combine_ironwood_proof_command(
path,
network.into(),
proof,
local_proof,
),
PsbtCommand::Sign {
path,
network,
Expand Down
Loading
Loading