From 051a512fa6b3bd44b10d4c5115876bf29787d896 Mon Sep 17 00:00:00 2001 From: Veetrag Jain Date: Tue, 11 Aug 2026 22:45:11 +0530 Subject: [PATCH] feat(wasm-utxo): add CLI support for building and signing Zcash v6 (Ironwood) shielding transactions Ticket: CSHLD-1432 --- packages/wasm-utxo/Cargo.lock | 1 + packages/wasm-utxo/Cargo.toml | 6 + packages/wasm-utxo/cli/Cargo.toml | 3 +- packages/wasm-utxo/cli/README.md | 45 ++++ .../cli/src/psbt/add_shielded_output.rs | 54 ++++ .../cli/src/psbt/combine_ironwood_proof.rs | 37 +++ .../wasm-utxo/cli/src/psbt/create_zcash_v6.rs | 25 ++ packages/wasm-utxo/cli/src/psbt/mod.rs | 126 +++++++++ .../wasm-utxo/cli/src/psbt/sign_v6_input.rs | 43 +++ .../bitgo_psbt/zcash_psbt.rs | 246 +++++++++++++++++- .../wasm-utxo/src/zcash/ironwood_build.rs | 26 ++ 11 files changed, 609 insertions(+), 3 deletions(-) create mode 100644 packages/wasm-utxo/cli/src/psbt/add_shielded_output.rs create mode 100644 packages/wasm-utxo/cli/src/psbt/combine_ironwood_proof.rs create mode 100644 packages/wasm-utxo/cli/src/psbt/create_zcash_v6.rs create mode 100644 packages/wasm-utxo/cli/src/psbt/sign_v6_input.rs diff --git a/packages/wasm-utxo/Cargo.lock b/packages/wasm-utxo/Cargo.lock index 20a031fdd36..e9b6e5f224c 100644 --- a/packages/wasm-utxo/Cargo.lock +++ b/packages/wasm-utxo/Cargo.lock @@ -3234,6 +3234,7 @@ dependencies = [ "hex", "num-bigint", "ptree", + "rand", "serde", "serde_json", "wasm-utxo", diff --git a/packages/wasm-utxo/Cargo.toml b/packages/wasm-utxo/Cargo.toml index 00c9d2b0f17..e0804815496 100644 --- a/packages/wasm-utxo/Cargo.toml +++ b/packages/wasm-utxo/Cargo.toml @@ -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" diff --git a/packages/wasm-utxo/cli/Cargo.toml b/packages/wasm-utxo/cli/Cargo.toml index fc60c03f0df..4bc6f1905ae 100644 --- a/packages/wasm-utxo/cli/Cargo.toml +++ b/packages/wasm-utxo/cli/Cargo.toml @@ -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" @@ -18,3 +18,4 @@ serde_json = "1.0" num-bigint = "0.4" colored = "2.1" ptree = "0.5" +rand = "0.8" diff --git a/packages/wasm-utxo/cli/README.md b/packages/wasm-utxo/cli/README.md index 0dff389cdb8..2bf9bb862b3 100644 --- a/packages/wasm-utxo/cli/README.md +++ b/packages/wasm-utxo/cli/README.md @@ -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 --consensus-branch-id [--lock-time ] [--expiry-height ] +wasm-utxo-cli psbt add-input --network --txid --vout --value --script --descriptor +wasm-utxo-cli psbt add-output (--address
--network | --script ) --value +wasm-utxo-cli psbt add-shielded-output --network --recipient --value --anchor [--ovk ] [--memo ] +wasm-utxo-cli psbt sign-v6-input --network --index --privkey +wasm-utxo-cli psbt combine-ironwood-proof --network (--proof | --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): diff --git a/packages/wasm-utxo/cli/src/psbt/add_shielded_output.rs b/packages/wasm-utxo/cli/src/psbt/add_shielded_output.rs new file mode 100644 index 00000000000..797fe4e4aaf --- /dev/null +++ b/packages/wasm-utxo/cli/src/psbt/add_shielded_output.rs @@ -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, + memo: Option, +) -> 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| 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| 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| 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| 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(()) +} diff --git a/packages/wasm-utxo/cli/src/psbt/combine_ironwood_proof.rs b/packages/wasm-utxo/cli/src/psbt/combine_ironwood_proof.rs new file mode 100644 index 00000000000..9324a93097e --- /dev/null +++ b/packages/wasm-utxo/cli/src/psbt/combine_ironwood_proof.rs @@ -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, + 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(()) +} diff --git a/packages/wasm-utxo/cli/src/psbt/create_zcash_v6.rs b/packages/wasm-utxo/cli/src/psbt/create_zcash_v6.rs new file mode 100644 index 00000000000..ffcc51f2849 --- /dev/null +++ b/packages/wasm-utxo/cli/src/psbt/create_zcash_v6.rs @@ -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(()) +} diff --git a/packages/wasm-utxo/cli/src/psbt/mod.rs b/packages/wasm-utxo/cli/src/psbt/mod.rs index 034da488698..4c369f0ccca 100644 --- a/packages/wasm-utxo/cli/src/psbt/mod.rs +++ b/packages/wasm-utxo/cli/src/psbt/mod.rs @@ -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 { @@ -88,6 +92,83 @@ pub enum PsbtCommand { #[arg(long, short, value_enum)] network: Option, }, + /// 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, + /// Memo field, hex-encoded (512 bytes; default: all-zero) + #[arg(long)] + memo: Option, + }, + /// 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, + /// 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 @@ -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, diff --git a/packages/wasm-utxo/cli/src/psbt/sign_v6_input.rs b/packages/wasm-utxo/cli/src/psbt/sign_v6_input.rs new file mode 100644 index 00000000000..d8f3c2bbbda --- /dev/null +++ b/packages/wasm-utxo/cli/src/psbt/sign_v6_input.rs @@ -0,0 +1,43 @@ +use anyhow::{anyhow, Context, Result}; +use std::path::PathBuf; +use wasm_utxo::bitcoin::secp256k1::{Message, PublicKey as SecpPublicKey, Secp256k1}; +use wasm_utxo::bitcoin::{CompressedPublicKey, PublicKey}; +use wasm_utxo::fixed_script_wallet::bitgo_psbt::ZcashBitGoPsbt; +use wasm_utxo::Network; + +use crate::input::{decode_input, parse_private_key, read_input_bytes}; + +pub fn handle_sign_v6_input_command( + path: PathBuf, + network: Network, + index: usize, + privkey: String, +) -> Result<()> { + let raw_bytes = read_input_bytes(&path, "PSBT")?; + let bytes = decode_input(&raw_bytes)?; + let mut psbt = ZcashBitGoPsbt::deserialize(&bytes, network) + .map_err(|e| anyhow!("Failed to parse v6 PSBT: {e}"))?; + + let privkey = parse_private_key(&privkey)?; + let secp = Secp256k1::new(); + let secp_pubkey = SecpPublicKey::from_secret_key(&secp, &privkey.inner); + let pubkey = PublicKey::from(CompressedPublicKey(secp_pubkey)); + + let sighash = psbt + .v6_transparent_sighash(index) + .map_err(|e| anyhow!(e)) + .context("failed to compute v6 transparent sighash")?; + let msg = Message::from_digest(sighash); + let mut der = secp + .sign_ecdsa(&msg, &privkey.inner) + .serialize_der() + .to_vec(); + der.push(0x01); // SIGHASH_ALL + + psbt.add_v6_transparent_signature(index, pubkey, &der) + .map_err(|e| anyhow!(e)) + .context("failed to add v6 transparent signature")?; + + println!("{}", hex::encode(psbt.serialize().map_err(|e| anyhow!(e))?)); + Ok(()) +} diff --git a/packages/wasm-utxo/src/fixed_script_wallet/bitgo_psbt/zcash_psbt.rs b/packages/wasm-utxo/src/fixed_script_wallet/bitgo_psbt/zcash_psbt.rs index 4da87797301..5ca470c3882 100644 --- a/packages/wasm-utxo/src/fixed_script_wallet/bitgo_psbt/zcash_psbt.rs +++ b/packages/wasm-utxo/src/fixed_script_wallet/bitgo_psbt/zcash_psbt.rs @@ -672,6 +672,42 @@ impl ZcashBitGoPsbt { z } + /// Create an empty Zcash **v6 (Ironwood)** shielding PSBT without embedding any xpubs. + /// + /// [`Self::new_v6`] always stamps the PSBT with a [`RootWalletKeys`][crate::fixed_script_wallet::RootWalletKeys]'s + /// xpubs, which standalone tooling (e.g. the CLI) that builds/signs one key at a time rather + /// than through a fixed-script wallet has no use for and no wallet keys to supply. The xpubs + /// are purely informational metadata — every other v6 method reads/writes only the + /// proprietary-map fields this constructor also sets — so omitting them is safe. + pub fn new_v6_bare( + network: crate::Network, + consensus_branch_id: u32, + lock_time: Option, + expiry_height: Option, + ) -> Self { + use miniscript::bitcoin::absolute::LockTime; + use miniscript::bitcoin::transaction::Version; + + let version_group_id = crate::zcash::transaction::ZCASH_IRONWOOD_VERSION_GROUP_ID; + let expiry_height = expiry_height.unwrap_or(0); + let tx = Transaction { + version: Version(6), + lock_time: LockTime::from_consensus(lock_time.unwrap_or(0)), + input: vec![], + output: vec![], + }; + let mut psbt = Psbt::from_unsigned_tx(tx).expect("empty transaction should be valid"); + super::propkv::set_zec_v6_params(&mut psbt, version_group_id, expiry_height); + super::propkv::set_zec_v6_consensus_branch_id(&mut psbt, consensus_branch_id); + Self { + psbt, + network, + version_group_id: Some(version_group_id), + expiry_height: Some(expiry_height), + sapling_fields: Vec::new(), + } + } + /// Whether this PSBT is a v6 (Ironwood) PSBT. pub fn is_ironwood_v6(&self) -> bool { self.version_group_id == Some(crate::zcash::transaction::ZCASH_IRONWOOD_VERSION_GROUP_ID) @@ -1254,6 +1290,36 @@ impl ZcashBitGoPsbt { crate::zcash::v6::encode_v6_transaction(&tx).map_err(|e| e.to_string()) } + /// Like [`Self::combine_ironwood_proof`], but produces the `zkproof` itself (via + /// [`crate::zcash::ironwood_build::create_proof`]) rather than requiring it from an external + /// proof service. Behind the `orchard-proving` feature — standalone tooling (e.g. the CLI) + /// that has no external proof service to call can enable it; the shipped WASM build must not, + /// since it would link the halo2 circuit into every consumer. Consumes `self`. + #[cfg(feature = "orchard-proving")] + pub fn combine_ironwood_proof_locally( + self, + mut rng: R, + ) -> Result, String> { + use crate::zcash::ironwood_build; + + let transparent = self.finalized_transparent_tx()?; + + let (amounts, scripts) = self.transparent_input_amounts_and_scripts()?; + let action_bundle = self.ironwood_action_data()?; + let sig_tx = self.to_v6_transaction(self.psbt.unsigned_tx.clone(), Some(action_bundle))?; + let sighash = crate::zcash::v6::compute_v6_sig_digest(&sig_tx, &amounts, &scripts); + + let mut pczt = self.ironwood_pczt()?; + ironwood_build::finalize_shield_io(&mut pczt, sighash, &mut rng) + .map_err(|e| e.to_string())?; + ironwood_build::create_proof(&mut pczt, &mut rng).map_err(|e| e.to_string())?; + let full_bundle = + ironwood_build::combine(&pczt, sighash, &mut rng).map_err(|e| e.to_string())?; + + let tx = self.to_v6_transaction(transparent, Some(full_bundle))?; + crate::zcash::v6::encode_v6_transaction(&tx).map_err(|e| e.to_string()) + } + /// Mark this v6 PSBT as extracted by dropping its PCZT, making [`Self::combine_ironwood_proof`] /// terminal for callers that cannot consume the PSBT by value (the wasm bindings, which must /// clone). Any later Ironwood operation then fails with "no Ironwood PCZT stored in PSBT" @@ -1272,10 +1338,33 @@ impl ZcashBitGoPsbt { } /// Deserialize a v6 PSBT produced by [`Self::serialize_v6`], restoring the v6 header params from - /// the proprietary map. + /// the proprietary map. Requires the Ironwood PCZT (i.e. [`Self::add_ironwood_output`] must + /// already have been called) — use [`Self::deserialize_v6_pre_shield`] to load a v6 PSBT + /// that doesn't have one yet. pub fn deserialize_v6( bytes: &[u8], network: crate::Network, + ) -> Result { + Self::decode_v6(bytes, network, true) + } + + /// Deserialize a v6 PSBT that may not yet carry its Ironwood PCZT — for tooling (e.g. the + /// CLI) that builds the transparent skeleton (inputs/outputs) across separate + /// serialize/deserialize round-trips before calling [`Self::add_ironwood_output`], unlike the + /// in-process microservice flow [`Self::deserialize_v6`] otherwise assumes. Still validates + /// the v6 params and consensus branch id, so a bare transparent skeleton round-trips exactly + /// like a fully-shielded one. + pub fn deserialize_v6_pre_shield( + bytes: &[u8], + network: crate::Network, + ) -> Result { + Self::decode_v6(bytes, network, false) + } + + fn decode_v6( + bytes: &[u8], + network: crate::Network, + require_pczt: bool, ) -> Result { let psbt = Psbt::deserialize(bytes)?; let (version_group_id, expiry_height) = super::propkv::get_zec_v6_params(&psbt) @@ -1301,7 +1390,7 @@ impl ZcashBitGoPsbt { "v6 PSBT is missing its consensus branch id".to_string(), )); } - if super::propkv::get_ironwood_pczt(&psbt).is_none() { + if require_pczt && super::propkv::get_ironwood_pczt(&psbt).is_none() { return Err(super::DeserializeError::Network( "v6 PSBT is missing its Ironwood PCZT".to_string(), )); @@ -1575,6 +1664,124 @@ mod ironwood_v6_tests { ); } + /// `combine_ironwood_proof_locally` (the `orchard-proving` path) produces a *real* Halo2 proof + /// that verifies — unlike [`build_sign_combine_produces_valid_v6_tx`]'s canonical-length + /// all-zero placeholder, which is only structurally valid, not cryptographically. This is what + /// makes the CLI's locally-combined transaction actually broadcastable. + #[cfg(feature = "orchard-proving")] + #[test] + fn combine_ironwood_proof_locally_produces_a_verifying_proof() { + use nonempty::NonEmpty; + use orchard::bundle::{Authorized, Bundle as OrchardBundle, BundleVersion}; + use orchard::circuit::{OrchardCircuitVersion, VerifyingKey}; + use orchard::note::{ExtractedNoteCommitment, Nullifier, TransmittedNoteCiphertext}; + use orchard::primitives::redpallas::{Binding, Signature, SpendAuth, VerificationKey}; + use orchard::value::ValueCommitment; + use orchard::{Action as OrchardAction, Proof}; + + let seed = "ironwood_v6_local_proof"; + let wallet_keys = RootWalletKeys::new(get_test_wallet_keys(seed)); + let nu6_3 = NetworkUpgrade::Nu6_3.testnet_activation_height(); + + let mut psbt = BitGoPsbt::new_zcash_v6_at_height( + Network::ZcashTestnet, + &wallet_keys, + nu6_3, + None, + None, + ) + .unwrap(); + psbt.add_wallet_input( + Txid::from_byte_array([0x66u8; 32]), + 0, + 200_000_000, + &wallet_keys, + ScriptId { chain: 0, index: 0 }, + WalletInputOptions::default(), + ) + .unwrap(); + psbt.add_wallet_output(0, 1, 99_900_000, &wallet_keys) + .unwrap(); + let BitGoPsbt::Zcash(mut z, _) = psbt else { + panic!("expected Zcash PSBT"); + }; + z.add_ironwood_output( + &test_recipient(), + 100_000_000, + None, + &Anchor::empty_tree().to_bytes(), + &[0u8; 512], + OsRng, + ) + .unwrap(); + + let secp = Secp256k1::new(); + let sighash = z.v6_transparent_sighash(0).unwrap(); + let msg = Message::from_digest(sighash); + for i in [0usize, 2] { + let sk = signing_secret_keys(seed, 0, 0)[i]; + let secp_pk = crate::bitcoin::secp256k1::PublicKey::from_secret_key(&secp, &sk); + let pubkey = PublicKey::from(CompressedPublicKey(secp_pk)); + let mut der = secp.sign_ecdsa(&msg, &sk).serialize_der().to_vec(); + der.push(0x01); + z.add_v6_transparent_signature(0, pubkey, &der).unwrap(); + } + + let raw = z.combine_ironwood_proof_locally(OsRng).unwrap(); + let tx = crate::zcash::v6::decode_v6_transaction(&raw).unwrap(); + let bundle = tx.ironwood_bundle.as_ref().unwrap(); + + // Reconstruct the fully-authorized orchard bundle from the on-wire action data, so we can + // ask orchard itself to verify the proof against a freshly-built verifying key. + let actions = bundle + .actions + .iter() + .zip(&bundle.spend_auth_sigs) + .map(|(a, sig)| { + let cv_net = + Option::from(ValueCommitment::from_bytes(&a.cv)).expect("valid cv_net"); + let rk = VerificationKey::::try_from(a.rk).expect("valid rk"); + let cmx = + Option::from(ExtractedNoteCommitment::from_bytes(&a.cmx)).expect("valid cmx"); + let nf = Option::from(Nullifier::from_bytes(&a.nullifier)).expect("valid nf"); + let encrypted_note = TransmittedNoteCiphertext { + epk_bytes: a.ephemeral_key, + enc_ciphertext: a.enc_ciphertext, + out_ciphertext: a.out_ciphertext, + }; + OrchardAction::from_parts( + nf, + rk, + cmx, + encrypted_note, + cv_net, + Signature::::from(*sig), + ) + .expect("valid action") + }) + .collect::>(); + let actions = NonEmpty::from_vec(actions).expect("at least one action"); + + let authorization = Authorized::from_parts( + Proof::new(bundle.proof.clone()), + Signature::::from(bundle.binding_sig), + ); + let orchard_bundle = OrchardBundle::::try_from_parts( + actions, + BundleVersion::ironwood_v3().default_flags(), + bundle.value_balance, + Option::from(Anchor::from_bytes(bundle.anchor)).expect("valid anchor"), + authorization, + BundleVersion::ironwood_v3(), + ) + .expect("valid authorized bundle"); + + let vk = VerifyingKey::build(OrchardCircuitVersion::PostNu6_3); + orchard_bundle + .verify_proof(&vk) + .expect("locally-produced proof verifies"); + } + /// End-to-end: the server builds a **keyless** v6 shielding PSBT — one transparent input, one /// Ironwood output, exactly the shape `add_ironwood_output(.., ovk: None, ..)` produces — and /// hands its serialized bytes to "the client". The client deserializes, derives its `ovk` on the @@ -2574,6 +2781,41 @@ mod ironwood_v6_tests { ); } + /// `deserialize_v6_pre_shield` accepts exactly the bytes `deserialize_v6` rejects in the test + /// above — a v6 PSBT with its transparent skeleton but no PCZT yet — while still enforcing the + /// v6 params/branch id checks both share. + #[test] + fn deserialize_v6_pre_shield_accepts_a_missing_pczt() { + let z = build_shield_psbt("v6_pre_shield"); + let mut psbt = z.psbt.clone(); + psbt.proprietary.retain(|k, _| { + !(k.prefix == crate::fixed_script_wallet::bitgo_psbt::propkv::BITGO_ZEC_V6 + && k.subtype == 0x01) + }); + let bytes = psbt.serialize(); + + let err = ZcashBitGoPsbt::deserialize_v6(&bytes, Network::ZcashTestnet).unwrap_err(); + assert!(err.to_string().contains("missing its Ironwood PCZT")); + + let round = ZcashBitGoPsbt::deserialize_v6_pre_shield(&bytes, Network::ZcashTestnet) + .expect("pre-shield deserialize accepts a missing PCZT"); + assert!(round.is_ironwood_v6()); + assert!(round.ironwood_shielded_output_info().unwrap().is_none()); + + // Still rejects a v6 PSBT missing its branch id — that check isn't gated by `require_pczt`. + let mut psbt_no_branch = z.psbt.clone(); + psbt_no_branch.proprietary.retain(|k, _| { + !(k.prefix == crate::fixed_script_wallet::bitgo_psbt::propkv::BITGO_ZEC_V6 + && k.subtype == 0x00) + }); + let err = ZcashBitGoPsbt::deserialize_v6_pre_shield( + &psbt_no_branch.serialize(), + Network::ZcashTestnet, + ) + .unwrap_err(); + assert!(err.to_string().contains("missing its consensus branch id")); + } + /// Build a PCZT whose action data equals the given bundle's, with all witness fields absent — /// enough for the effects-only (action-data) view the sighash/txid need. Used to reconstruct /// the on-chain fixture's shielded state inside a PSBT. diff --git a/packages/wasm-utxo/src/zcash/ironwood_build.rs b/packages/wasm-utxo/src/zcash/ironwood_build.rs index 8fbbc6609b5..d3c2b2672c6 100644 --- a/packages/wasm-utxo/src/zcash/ironwood_build.rs +++ b/packages/wasm-utxo/src/zcash/ironwood_build.rs @@ -94,6 +94,8 @@ pub enum IronwoodBuildError { NoteCommitmentMismatch, /// The bitgo/user key bytes are not a valid secp256k1 public/private key. BadKey(String), + /// Local proof generation (`orchard-proving` feature) failed. + Prove(String), } impl core::fmt::Display for IronwoodBuildError { @@ -129,6 +131,7 @@ impl core::fmt::Display for IronwoodBuildError { not commit to the action's cmx; refusing to recompute out_ciphertext from it" ), Self::BadKey(e) => write!(f, "ironwood-build: invalid key: {e}"), + Self::Prove(e) => write!(f, "ironwood-build: proof generation failed: {e}"), } } } @@ -186,6 +189,29 @@ pub fn finalize_shield_io( .map_err(|e| IronwoodBuildError::Finalize(e.to_string())) } +/// Prover: attach a real Halo2 proof to the PCZT bundle in place, using a freshly-built proving +/// key for the bundle's own circuit version. +/// +/// Behind the `orchard-proving` feature (pulls in halo2's circuit/proving-key machinery — heavy, +/// so it's opt-in). Building the proving key is expensive; callers proving many bundles in one +/// process should build it once via `orchard::circuit::ProvingKey::build` and call +/// `bundle.create_proof` directly rather than this convenience wrapper. +/// +/// Order relative to [`finalize_shield_io`] does not matter — the two set disjoint PCZT fields +/// (`zkproof` here, `spend_auth_sig`/`bsk` there) — but both must run before [`combine`]. +#[cfg(feature = "orchard-proving")] +pub fn create_proof( + bundle: &mut PcztBundle, + rng: R, +) -> Result<(), IronwoodBuildError> { + use orchard::circuit::ProvingKey; + + let pk = ProvingKey::build(BundleVersion::ironwood_v3().circuit_version()); + bundle + .create_proof(&pk, rng) + .map_err(|e| IronwoodBuildError::Prove(e.to_string())) +} + /// Map an orchard action (in any authorization state) to the v6 wire action. /// /// The action-data fields are authorization-independent, so this serves both the effects-only