From e54d6d279ce0682e1a0a8d26cd50dbac8d466330 Mon Sep 17 00:00:00 2001 From: Veetrag Jain Date: Fri, 23 Oct 2026 13:06:29 +0530 Subject: [PATCH] feat(wasm-utxo): CLI and inspect support for Ironwood spends Ticket: CSHLD-1891 --- packages/wasm-utxo/Cargo.lock | 3 + packages/wasm-utxo/cli/Cargo.toml | 6 + packages/wasm-utxo/cli/README.md | 78 ++++ .../cli/src/psbt/add_shielded_input.rs | 247 +++++++++++ .../cli/src/psbt/add_shielded_output.rs | 7 +- .../cli/src/psbt/add_spend_signature.rs | 43 ++ .../wasm-utxo/cli/src/psbt/build_witness.rs | 97 +++++ packages/wasm-utxo/cli/src/psbt/common.rs | 96 ++++- packages/wasm-utxo/cli/src/psbt/mod.rs | 211 ++++++++++ .../wasm-utxo/cli/src/psbt/out_ciphertexts.rs | 51 +++ packages/wasm-utxo/cli/src/psbt/prove.rs | 70 +++ .../cli/src/psbt/set_spend_randomizer.rs | 49 +++ .../cli/tests/ironwood_spend_flow.rs | 285 +++++++++++++ packages/wasm-utxo/src/inspect/zcash.rs | 398 +++++++++++++++--- 14 files changed, 1586 insertions(+), 55 deletions(-) create mode 100644 packages/wasm-utxo/cli/src/psbt/add_shielded_input.rs create mode 100644 packages/wasm-utxo/cli/src/psbt/add_spend_signature.rs create mode 100644 packages/wasm-utxo/cli/src/psbt/build_witness.rs create mode 100644 packages/wasm-utxo/cli/src/psbt/out_ciphertexts.rs create mode 100644 packages/wasm-utxo/cli/src/psbt/prove.rs create mode 100644 packages/wasm-utxo/cli/src/psbt/set_spend_randomizer.rs create mode 100644 packages/wasm-utxo/cli/tests/ironwood_spend_flow.rs diff --git a/packages/wasm-utxo/Cargo.lock b/packages/wasm-utxo/Cargo.lock index d968d62183a..edff78a4e1d 100644 --- a/packages/wasm-utxo/Cargo.lock +++ b/packages/wasm-utxo/Cargo.lock @@ -3799,8 +3799,11 @@ dependencies = [ "base64 0.21.7", "clap", "colored", + "ff 0.13.1", "hex", "num-bigint 0.4.6", + "orchard 0.15.0", + "pasta_curves 0.5.1", "ptree", "rand 0.8.6", "serde", diff --git a/packages/wasm-utxo/cli/Cargo.toml b/packages/wasm-utxo/cli/Cargo.toml index 4bc6f1905ae..4e14d63d2cf 100644 --- a/packages/wasm-utxo/cli/Cargo.toml +++ b/packages/wasm-utxo/cli/Cargo.toml @@ -9,6 +9,12 @@ path = "src/main.rs" [dependencies] wasm-utxo = { path = "..", features = ["inspect", "orchard-proving"] } +# The CLI is the stand-in for the key holders (the FVK holder and the FROST signer), so it +# works with orchard key types directly; the version must match wasm-utxo's own dependency. +orchard = { version = "0.15", default-features = false, features = ["std"] } +# Matching wasm-utxo's own pins: the randomizer scalars are Pallas field elements. +ff = "0.13" +pasta_curves = "0.5" clap = { version = "4.5", features = ["derive"] } anyhow = "1.0" hex = "0.4" diff --git a/packages/wasm-utxo/cli/README.md b/packages/wasm-utxo/cli/README.md index 2bf9bb862b3..f93aab2954d 100644 --- a/packages/wasm-utxo/cli/README.md +++ b/packages/wasm-utxo/cli/README.md @@ -218,6 +218,84 @@ wasm-utxo-cli psbt create-zcash-v6 --network tzec --consensus-branch-id 0x37a516 # (txid aa7d9d9401cf70901cf76c81cab06e7001879607f60e1a2ffa4d4afa4a786238). ``` +#### Spend shielded notes in a Zcash v6 (Ironwood) transaction + +Six more subcommands — `add-shielded-input`, `out-ciphertexts`, `set-spend-randomizer`, +`add-spend-signature`, `prove`, `build-witness` — drive the **spend** flow: a deshield +(shielded → transparent) or a shielded → shielded transfer. A spend PSBT has no transparent +inputs; the shielded notes fund it. Local key files stand in for the key holders: `--fvk-file` +(96-byte hex full viewing key) for the viewing-key holder and the prover, `--sk-file` (32-byte +hex spending key) for the spend-auth signer. + +```bash +wasm-utxo-cli psbt add-shielded-input --network --notes-file [--fvk-file ] [--anchor ] +wasm-utxo-cli psbt out-ciphertexts --network --fvk-file +wasm-utxo-cli psbt set-spend-randomizer --network --action-index --fvk-file [--alpha ] +wasm-utxo-cli psbt add-spend-signature --network --action-index (--sk-file --alpha | --signature ) +wasm-utxo-cli psbt prove --network --alpha :... (--request | --fvk-file ) +wasm-utxo-cli psbt build-witness --cmx --position --anchor (--auth-path | --shard-file --cap-file --shard-height --leaf-count ) +``` + +`add-shielded-input` takes the notes as JSON — `{"anchor", "spends": [{"recipient", "value", +"rho", "rseed", "cmx", "witness": {"position", "authPath"}, "nullifier"?}], "outputs"?: +[{"recipient", "amount", "memo"?, "unifiedAddress"?}]}`, bytes hex-encoded. Missing +nullifiers are derived locally from `--fvk-file`. The actions are shuffled, so the action map +it prints on stderr gives the `--action-index` for the later steps. `out-ciphertexts` passes the +PSBT through unchanged when no real shielded output is pending (a deshield with no shielded +change). The PSBT never stores a spend's randomizer `alpha`, so keep the one +`set-spend-randomizer` prints and pass it to `add-spend-signature` and `prove`. + +**Example (deshield — spending shielded notes back to a transparent address):** + +```bash +# 1. Build the transparent skeleton (no transparent inputs — a spend PSBT funds itself). +wasm-utxo-cli psbt create-zcash-v6 --network tzec --consensus-branch-id 0x37a5165b \ + | wasm-utxo-cli psbt add-output - --script a914ed68766fe37d9e2325758ed209ac78db505425a987 \ + --value 99999000 \ + > psbt.hex + +# 2. Constructor role, keyless: add the shielded spends from the notes file. The action map on +# stderr maps each spend and output to its action index. +wasm-utxo-cli psbt add-shielded-input psbt.hex --network tzec --notes-file notes.json \ + --fvk-file fvk.hex > psbt2.hex 2> action-map.json + +# 3. Viewing-key holder round: re-encrypt every pending output's out_ciphertext under the FVK's +# ovk (the stand-in for the KMS out-ciphertext step). +wasm-utxo-cli psbt out-ciphertexts psbt2.hex --network tzec --fvk-file fvk.hex > psbt3.hex + +# 4. FROST stand-in rounds: install each spend's randomized key, then sign over the spend +# sighash. Keep the alphas — `prove` needs them later. +wasm-utxo-cli psbt set-spend-randomizer psbt3.hex --network tzec \ + --action-index 0 --fvk-file fvk.hex --alpha > psbt4.hex +wasm-utxo-cli psbt add-spend-signature psbt4.hex --network tzec \ + --action-index 0 --alpha --sk-file sk.hex > psbt5.hex + +# 5. Prover round: either print the alpha-patched proof request for an external prover... +wasm-utxo-cli psbt prove psbt5.hex --network tzec --alpha 0: --request > proof-request.hex +# ...or produce the Halo2 proof locally and combine it into the broadcast-ready transaction. +wasm-utxo-cli psbt prove psbt5.hex --network tzec --alpha 0: --fvk-file fvk.hex > proof.hex +wasm-utxo-cli psbt combine-ironwood-proof psbt5.hex --network tzec --proof "$(cat proof.hex)" + +# Inspect any stage: the `ironwood` node shows the input kind, what is still pending, the +# key-free verification verdict, and whether the PSBT is ready for its proof. +wasm-utxo-cli psbt parse psbt5.hex --network tzec +``` + +The witness each spend needs can be built from a note commitment and its sibling path (or from +a pruned shard + cap, as a note-commitment tree store would supply it): + +```bash +wasm-utxo-cli psbt build-witness --cmx --position 0 --anchor \ + --auth-path '[ "", ... 32 entries ... ]' +``` + +**Fixture generation.** The TypeScript spend fixtures (`test/fixtures/zcash/ironwood_spend_*.json`) +are produced by the `#[ignore]`d Rust generator +(`cargo test --features orchard-proving generate_ts_spend_fixtures -- --ignored --nocapture`). +The spend-flow commands reproduce the same steps interactively: a fixture's `request` object is a +valid `--notes-file`, and steps 3–5 above produce its out-ciphertexts, randomizers, signatures, +proof request and proof. + ### Supported Networks The CLI supports the following networks (use with `--network` flag): diff --git a/packages/wasm-utxo/cli/src/psbt/add_shielded_input.rs b/packages/wasm-utxo/cli/src/psbt/add_shielded_input.rs new file mode 100644 index 00000000000..10462cf35f7 --- /dev/null +++ b/packages/wasm-utxo/cli/src/psbt/add_shielded_input.rs @@ -0,0 +1,247 @@ +//! Constructor role for the **spend** flow: add the shielded spends (and optionally shielded +//! outputs) to a v6 PSBT as a single keyless orchard PCZT. +//! +//! The notes come from a JSON file in the shape the build service produces (the TypeScript +//! spend fixtures' `request` object is one) — the note plaintexts, their witnesses, and either +//! their nullifiers or enough plaintext to derive them locally from the `--fvk-file` (the +//! stand-in for the viewing-key holder's `generate-nullifiers` round): +//! +//! ```json +//! { +//! "anchor": "", +//! "spends": [{ +//! "recipient": "", "value": 100000000, +//! "rho": "", "rseed": "", "cmx": "", +//! "witness": { "position": 0, "authPath": ["", "..."] }, +//! "nullifier": "" +//! }], +//! "outputs": [{ "recipient": "", "amount": 90000000 }] +//! } +//! ``` +//! +//! `authPath` is either the 32 sibling hashes (leaf-to-root) or their flattened 1024-byte hex +//! encoding. `value` and `amount` may be JSON numbers or decimal strings. `nullifier` is +//! optional when `--fvk-file` is given; each output may also carry a `memo` (512-byte hex, +//! ZIP-302 no-memo by default) and a `unifiedAddress`. Prints the updated PSBT as hex to +//! stdout, with the action map on stderr (the actions are shuffled, so the `--action-index` +//! arguments of the later steps must come from it). + +use anyhow::{anyhow, bail, Context, Result}; +use rand::rngs::OsRng; +use serde::Deserialize; +use std::path::PathBuf; +use wasm_utxo::fixed_script_wallet::bitgo_psbt::zcash::IronwoodOutputRequest; +use wasm_utxo::zcash::ironwood_build::{IronwoodWitness, WitnessAuthPath}; +use wasm_utxo::zcash::ironwood_construct::IronwoodKeylessSpendSpec; +use wasm_utxo::Network; + +use crate::input::read_input_bytes; +use crate::psbt::common::{ + auth_path_from_flat_hex, auth_path_from_hashes, parse_fvk_file, parse_hex, print_zcash_psbt, + read_zcash_psbt, zip302_no_memo, +}; + +#[derive(Deserialize)] +struct NotesFile { + anchor: Option, + spends: Vec, + #[serde(default)] + outputs: Vec, +} + +#[derive(Deserialize)] +struct NoteSpend { + recipient: String, + value: JsonU64, + rho: String, + rseed: String, + cmx: String, + witness: NoteWitness, + nullifier: Option, +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +struct NoteWitness { + position: u32, + auth_path: AuthPath, +} + +#[derive(Deserialize)] +#[serde(untagged)] +enum AuthPath { + Hashes(Vec), + Flattened(String), +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +struct NoteOutput { + recipient: String, + amount: JsonU64, + memo: Option, + unified_address: Option, +} + +/// A `u64` as a JSON number or a decimal string (the fixtures write values as strings to stay +/// bigint-safe). +#[derive(Deserialize)] +#[serde(untagged)] +enum JsonU64 { + Number(u64), + Text(String), +} + +impl JsonU64 { + fn parse(&self, what: &str) -> Result { + match self { + JsonU64::Number(number) => Ok(*number), + JsonU64::Text(text) => text + .trim() + .parse() + .with_context(|| format!("{what} is not an unsigned integer")), + } + } +} + +pub fn handle_add_shielded_input_command( + path: PathBuf, + network: Network, + notes_file: PathBuf, + fvk_file: Option, + anchor: Option, +) -> Result<()> { + let mut psbt = read_zcash_psbt(&path, network)?; + + let notes = read_input_bytes(¬es_file, "notes file")?; + let notes: NotesFile = + serde_json::from_slice(¬es).context("notes file is not a valid notes JSON")?; + + let anchor: [u8; 32] = match (&anchor, ¬es.anchor) { + (Some(anchor), _) => parse_hex(anchor, "--anchor")?, + (None, Some(anchor)) => parse_hex(anchor, "notes anchor")?, + (None, None) => { + bail!("an anchor is required: pass --anchor or put \"anchor\" in the notes file") + } + }; + if notes.spends.is_empty() { + bail!( + "the notes file carries no spends; output-only builds are the shielding flow \ + (add-shielded-output)" + ); + } + let fvk = fvk_file.map(|path| parse_fvk_file(&path)).transpose()?; + + let spends = notes + .spends + .iter() + .enumerate() + .map(|(i, spend)| parse_spend(spend, &format!("spend {i}"), fvk.as_ref())) + .collect::>>()?; + let outputs = notes + .outputs + .iter() + .enumerate() + .map(|(i, output)| parse_output(output, &format!("output {i}"))) + .collect::>>()?; + + let map = psbt + .add_shielded_spends(&spends, &outputs, &anchor, OsRng) + .map_err(|e| anyhow!("failed to add the shielded spends: {e}"))?; + + // The actions are shuffled, so the later steps' --action-index arguments must come from + // this map. It goes to stderr, keeping stdout to the PSBT hex alone. + eprintln!( + "{}", + serde_json::json!({ + "spendActionIndices": map.spend_action_indices(), + "outputActionIndices": map.output_action_indices(), + }) + ); + print_zcash_psbt(&psbt) +} + +/// One spent note. The nullifier is taken from the entry or derived locally from the note +/// plaintext under the stand-in FVK. +fn parse_spend( + spend: &NoteSpend, + what: &str, + fvk: Option<&orchard::keys::FullViewingKey>, +) -> Result { + let recipient = parse_hex(&spend.recipient, &format!("{what} recipient"))?; + let value = spend.value.parse(&format!("{what} value"))?; + let rho = parse_hex(&spend.rho, &format!("{what} rho"))?; + let rseed = parse_hex(&spend.rseed, &format!("{what} rseed"))?; + let cmx = parse_hex(&spend.cmx, &format!("{what} cmx"))?; + let auth_path: WitnessAuthPath = match &spend.witness.auth_path { + AuthPath::Hashes(hashes) => auth_path_from_hashes(hashes, &format!("{what} authPath"))?, + AuthPath::Flattened(flattened) => { + auth_path_from_flat_hex(flattened, &format!("{what} authPath"))? + } + }; + let nullifier = match (&spend.nullifier, fvk) { + (Some(nullifier), _) => parse_hex(nullifier, &format!("{what} nullifier"))?, + (None, Some(fvk)) => derive_nullifier(fvk, &recipient, value, &rho, &rseed) + .with_context(|| format!("{what}: deriving the nullifier locally failed"))?, + (None, None) => bail!("{what} has no \"nullifier\"; pass --fvk-file to derive it"), + }; + + Ok(IronwoodKeylessSpendSpec { + recipient, + value, + rho, + rseed, + cmx, + witness: IronwoodWitness { + position: spend.witness.position, + auth_path, + }, + nullifier, + }) +} + +/// One requested shielded output. There is no `ovk`: the viewing-key holder owns encryption in +/// the spend flow. +fn parse_output(output: &NoteOutput, what: &str) -> Result { + Ok(IronwoodOutputRequest { + recipient: parse_hex(&output.recipient, &format!("{what} recipient"))?, + amount: output.amount.parse(&format!("{what} amount"))?, + ovk: None, + memo: match &output.memo { + Some(memo) => parse_hex(memo, &format!("{what} memo"))?, + None => zip302_no_memo(), + }, + unified_address: output.unified_address.clone(), + }) +} + +/// The nullifier of the note the plaintext describes, under the stand-in FVK — the local +/// equivalent of the viewing-key holder's `generate-nullifiers` round. +fn derive_nullifier( + fvk: &orchard::keys::FullViewingKey, + recipient: &[u8; 43], + value: u64, + rho: &[u8; 32], + rseed: &[u8; 32], +) -> Result<[u8; 32]> { + use orchard::bundle::BundleVersion; + use orchard::note::{Note, RandomSeed, Rho}; + use orchard::value::NoteValue; + use orchard::Address; + + let address = Option::
::from(Address::from_raw_address_bytes(recipient)) + .context("the spend recipient is not a valid Orchard address")?; + let rho = Option::::from(Rho::from_bytes(rho)) + .context("the spend rho is not a canonical field element")?; + let rseed = Option::::from(RandomSeed::from_bytes(*rseed, &rho)) + .context("the spend rseed is not canonical")?; + let note = Option::::from(Note::from_parts( + address, + NoteValue::from_raw(value), + rho, + rseed, + BundleVersion::ironwood_v3().note_version(), + )) + .context("the spend plaintext does not form a valid note")?; + Ok(note.nullifier(fvk).to_bytes()) +} diff --git a/packages/wasm-utxo/cli/src/psbt/add_shielded_output.rs b/packages/wasm-utxo/cli/src/psbt/add_shielded_output.rs index 75983e35f44..57b4be538bb 100644 --- a/packages/wasm-utxo/cli/src/psbt/add_shielded_output.rs +++ b/packages/wasm-utxo/cli/src/psbt/add_shielded_output.rs @@ -5,6 +5,7 @@ use wasm_utxo::fixed_script_wallet::bitgo_psbt::ZcashBitGoPsbt; use wasm_utxo::Network; use crate::input::{decode_input, read_input_bytes}; +use crate::psbt::common::zip302_no_memo; #[allow(clippy::too_many_arguments)] pub fn handle_add_shielded_output_command( @@ -19,8 +20,8 @@ pub fn handle_add_shielded_output_command( ) -> 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 mut psbt = ZcashBitGoPsbt::deserialize(&bytes, network) + .map_err(|e| anyhow!("Failed to parse PSBT: {e}"))?; let recipient: [u8; 43] = hex::decode(&recipient) .context("invalid --recipient hex")? @@ -43,7 +44,7 @@ pub fn handle_add_shielded_output_command( .context("invalid --memo hex")? .try_into() .map_err(|v: Vec| anyhow!("--memo must be 512 bytes, got {}", v.len()))?, - None => [0u8; 512], + None => zip302_no_memo(), }; psbt.add_ironwood_output( diff --git a/packages/wasm-utxo/cli/src/psbt/add_spend_signature.rs b/packages/wasm-utxo/cli/src/psbt/add_spend_signature.rs new file mode 100644 index 00000000000..cd15c3df60e --- /dev/null +++ b/packages/wasm-utxo/cli/src/psbt/add_spend_signature.rs @@ -0,0 +1,43 @@ +//! The FROST stand-in's signing round, one spend at a time: sign the PSBT's ZIP-244 spend +//! sighash with the spend's randomized authorizing key (`ask.randomize(alpha)`, from +//! `--sk-file` and `--alpha`) — or ingest an externally produced signature (`--signature`, the +//! FROST case). The alpha is the one `set-spend-randomizer` installed for this action; the +//! PSBT never stores it. Prints the updated PSBT as hex to stdout. + +use anyhow::{anyhow, bail, Result}; +use rand::rngs::OsRng; +use std::path::PathBuf; +use wasm_utxo::Network; + +use crate::psbt::common::{ + parse_alpha, parse_hex, parse_spending_key_file, print_zcash_psbt, read_zcash_psbt, +}; + +pub fn handle_add_spend_signature_command( + path: PathBuf, + network: Network, + action_index: usize, + alpha: Option, + sk_file: Option, + signature: Option, +) -> Result<()> { + let mut psbt = read_zcash_psbt(&path, network)?; + + let signature: [u8; 64] = match (sk_file, alpha, signature) { + (Some(sk_file), Some(alpha), None) => { + let (_, alpha) = parse_alpha(&alpha, "--alpha")?; + let sk = parse_spending_key_file(&sk_file)?; + let ask = orchard::keys::SpendAuthorizingKey::from(&sk); + let sighash = psbt + .spend_sighash() + .map_err(|e| anyhow!("failed to compute the spend sighash: {e}"))?; + <[u8; 64]>::from(&ask.randomize(&alpha).sign(OsRng, &sighash)) + } + (None, None, Some(signature)) => parse_hex(&signature, "--signature")?, + _ => bail!("expected either --sk-file with --alpha, or --signature"), + }; + + psbt.add_spend_signatures(&[(action_index, signature)]) + .map_err(|e| anyhow!("failed to add the spend signature: {e}"))?; + print_zcash_psbt(&psbt) +} diff --git a/packages/wasm-utxo/cli/src/psbt/build_witness.rs b/packages/wasm-utxo/cli/src/psbt/build_witness.rs new file mode 100644 index 00000000000..560aace0863 --- /dev/null +++ b/packages/wasm-utxo/cli/src/psbt/build_witness.rs @@ -0,0 +1,97 @@ +//! Build (and validate) an Ironwood Merkle witness for a note commitment — either from the +//! full 32-entry sibling path, or from a pruned shard + cap as a real note-commitment tree +//! store would supply it. Prints the witness as JSON to stdout: +//! `{"position": , "authPath": ["" × 32]}` — the shape `add-shielded-input`'s +//! notes file takes. + +use anyhow::{anyhow, bail, Context, Result}; +use std::path::PathBuf; +use wasm_utxo::zcash::ironwood_build::{ + build_ironwood_witness, build_ironwood_witness_from_shard, ShardTreeNode, ShardWitnessInput, + WitnessAuthPath, +}; + +use crate::input::read_input_bytes; +use crate::psbt::common::{auth_path_from_flat_hex, auth_path_from_hashes, parse_hex}; + +pub struct BuildWitnessArgs { + pub cmx: String, + pub position: u32, + pub anchor: String, + pub auth_path: Option, + pub shard_file: Option, + pub cap_file: Option, + pub shard_height: Option, + pub leaf_count: Option, +} + +pub fn handle_build_witness_command(args: BuildWitnessArgs) -> Result<()> { + let cmx: [u8; 32] = parse_hex(&args.cmx, "--cmx")?; + let anchor: [u8; 32] = parse_hex(&args.anchor, "--anchor")?; + + let witness = match args { + BuildWitnessArgs { + auth_path: Some(auth_path), + shard_file: None, + .. + } => { + let auth_path = parse_auth_path(&auth_path)?; + build_ironwood_witness(&cmx, args.position, &auth_path, &anchor) + .map_err(|e| anyhow!("building the witness failed: {e}"))? + } + BuildWitnessArgs { + auth_path: None, + shard_file: Some(shard_file), + cap_file: Some(cap_file), + shard_height: Some(shard_height), + leaf_count: Some(leaf_count), + .. + } => { + let shard = read_shard_tree_node(&shard_file).context("--shard-file")?; + let cap = read_shard_tree_node(&cap_file).context("--cap-file")?; + let input = ShardWitnessInput { + position: args.position, + shard_height, + leaf_count, + cmx: &cmx, + shard: &shard, + cap: &cap, + }; + build_ironwood_witness_from_shard(&input, &anchor) + .map_err(|e| anyhow!("building the witness from the shard failed: {e}"))? + } + _ => bail!( + "expected exactly one witness source: --auth-path, or --shard-file + --cap-file \ + (with --shard-height and --leaf-count)" + ), + }; + + println!( + "{}", + serde_json::json!({ + "position": witness.position, + "authPath": witness.auth_path.iter().map(hex::encode).collect::>(), + }) + ); + Ok(()) +} + +/// The auth path: a JSON array of 32 hex hashes, 32 comma-separated hex hashes, or the +/// flattened 1024-byte hex encoding. +fn parse_auth_path(auth_path: &str) -> Result { + if auth_path.trim_start().starts_with('[') { + let hashes: Vec = serde_json::from_str(auth_path) + .context("--auth-path is not a JSON array of hex hashes")?; + auth_path_from_hashes(&hashes, "--auth-path") + } else if auth_path.contains(',') { + let hashes: Vec<&str> = auth_path.split(',').collect(); + auth_path_from_hashes(&hashes, "--auth-path") + } else { + auth_path_from_flat_hex(auth_path, "--auth-path") + } +} + +fn read_shard_tree_node(path: &PathBuf) -> Result { + let text = read_input_bytes(path, "shard node")?; + serde_json::from_slice(&text).context("the shard node is not valid ShardTreeNode JSON") +} diff --git a/packages/wasm-utxo/cli/src/psbt/common.rs b/packages/wasm-utxo/cli/src/psbt/common.rs index a3825639cb2..0884cfbd7b3 100644 --- a/packages/wasm-utxo/cli/src/psbt/common.rs +++ b/packages/wasm-utxo/cli/src/psbt/common.rs @@ -1,6 +1,10 @@ -use anyhow::{Context, Result}; +use anyhow::{anyhow, bail, Context, Result}; +use ff::PrimeField; use std::path::PathBuf; use wasm_utxo::bitcoin::psbt::Psbt; +use wasm_utxo::fixed_script_wallet::bitgo_psbt::ZcashBitGoPsbt; +use wasm_utxo::zcash::ironwood_build::{MemoBytes, WitnessAuthPath, IRONWOOD_MERKLE_DEPTH}; +use wasm_utxo::Network; use crate::input::{decode_input, read_input_bytes}; @@ -16,3 +20,93 @@ pub fn read_psbt(path: &PathBuf) -> Result { pub fn print_psbt(psbt: &Psbt) { println!("{}", hex::encode(psbt.serialize())); } + +/// Read and deserialize a Zcash PSBT from a file path or stdin (`-`), auto-detecting +/// hex/base64/raw encoding. +pub fn read_zcash_psbt(path: &PathBuf, network: Network) -> Result { + let raw_bytes = read_input_bytes(path, "PSBT")?; + let bytes = decode_input(&raw_bytes)?; + ZcashBitGoPsbt::deserialize(&bytes, network).map_err(|e| anyhow!("Failed to parse PSBT: {e}")) +} + +/// Serialize a Zcash PSBT and print it as hex to stdout. +pub fn print_zcash_psbt(psbt: &ZcashBitGoPsbt) -> Result<()> { + println!("{}", hex::encode(psbt.serialize().map_err(|e| anyhow!(e))?)); + Ok(()) +} + +/// Parse a fixed-length hex string, tolerating a `0x` prefix and surrounding whitespace, and +/// naming the field in the error. +pub fn parse_hex(input: &str, what: &str) -> Result<[u8; N]> { + let bytes = hex::decode(input.trim().trim_start_matches("0x")) + .with_context(|| format!("{what} is not hex"))?; + bytes + .try_into() + .map_err(|v: Vec| anyhow!("{what} must be {N} bytes, got {}", v.len())) +} + +/// Read a fixed-length hex string from a file (or `-` for stdin). +fn read_hex_file(path: &PathBuf, what: &str) -> Result<[u8; N]> { + let text = read_input_bytes(path, what)?; + let text = String::from_utf8(text).with_context(|| format!("{what} file is not UTF-8 text"))?; + parse_hex(&text, what) +} + +/// Parse a 96-byte full viewing key (`ak ‖ nk ‖ rivk`, orchard's `FullViewingKey::to_bytes` +/// layout) from a hex file — the stand-in key holder for the prover/ciphertext rounds. +pub fn parse_fvk_file(path: &PathBuf) -> Result { + let bytes = read_hex_file::<96>(path, "full viewing key")?; + orchard::keys::FullViewingKey::from_bytes(&bytes) + .context("full viewing key bytes are not a valid FVK") +} + +/// Parse a 32-byte spending key from a hex file — the stand-in key holder for the +/// spend-auth signing round. +pub fn parse_spending_key_file(path: &PathBuf) -> Result { + let bytes = read_hex_file::<32>(path, "spending key")?; + Option::::from(orchard::keys::SpendingKey::from_bytes(bytes)) + .context("spending key bytes are not a valid key") +} + +/// Parse a spend randomizer `alpha`: 32 hex bytes encoding a canonical Pallas scalar. Returns +/// both the bytes and the scalar. +pub fn parse_alpha(input: &str, what: &str) -> Result<([u8; 32], pasta_curves::pallas::Scalar)> { + let bytes = parse_hex::<32>(input, what)?; + let scalar = pasta_curves::pallas::Scalar::from_repr(bytes) + .into_option() + .with_context(|| format!("{what} is not a canonical scalar"))?; + Ok((bytes, scalar)) +} + +/// A witness auth path from its 32 sibling hashes (leaf-to-root), each hex-encoded. +pub fn auth_path_from_hashes>(hashes: &[S], what: &str) -> Result { + if hashes.len() != IRONWOOD_MERKLE_DEPTH { + bail!( + "{what} must have exactly {IRONWOOD_MERKLE_DEPTH} entries, got {}", + hashes.len() + ); + } + let mut auth_path: WitnessAuthPath = [[0u8; 32]; IRONWOOD_MERKLE_DEPTH]; + for (level, (node, hash)) in auth_path.iter_mut().zip(hashes).enumerate() { + *node = parse_hex(hash.as_ref(), &format!("{what}[{level}]"))?; + } + Ok(auth_path) +} + +/// A witness auth path from the flattened hex encoding of its 32 sibling hashes. +pub fn auth_path_from_flat_hex(input: &str, what: &str) -> Result { + let flattened = parse_hex::<{ IRONWOOD_MERKLE_DEPTH * 32 }>(input, what)?; + let mut auth_path: WitnessAuthPath = [[0u8; 32]; IRONWOOD_MERKLE_DEPTH]; + for (node, chunk) in auth_path.iter_mut().zip(flattened.chunks_exact(32)) { + node.copy_from_slice(chunk); + } + Ok(auth_path) +} + +/// The ZIP-302 "no memo" encoding: the `0xf6` marker byte followed by zeros — not the +/// all-zeros memo, which decodes as a text memo holding the empty string. +pub fn zip302_no_memo() -> MemoBytes { + let mut memo = [0u8; 512]; + memo[0] = 0xf6; + memo +} diff --git a/packages/wasm-utxo/cli/src/psbt/mod.rs b/packages/wasm-utxo/cli/src/psbt/mod.rs index a3bcc9d2346..e8c166d1b25 100644 --- a/packages/wasm-utxo/cli/src/psbt/mod.rs +++ b/packages/wasm-utxo/cli/src/psbt/mod.rs @@ -6,13 +6,19 @@ use crate::network::NetworkArg; mod add_input; mod add_output; +mod add_shielded_input; mod add_shielded_output; +mod add_spend_signature; +mod build_witness; mod combine_ironwood_proof; mod common; mod create; mod create_zcash_v6; mod extract_transparent_only; +mod out_ciphertexts; mod parse; +mod prove; +mod set_spend_randomizer; mod sign; mod sign_v6_input; @@ -140,6 +146,139 @@ pub enum PsbtCommand { #[arg(long)] unified_address: Option, }, + /// Constructor role for the **spend** flow: add the shielded spends (and optionally + /// shielded outputs) to a v6 PSBT as a single keyless orchard PCZT, from a JSON notes + /// file. The PSBT must not carry transparent inputs, and only one call is supported per + /// PSBT. Prints the updated PSBT as hex to stdout, with the action map on stderr. + AddShieldedInput { + /// 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, + /// Notes file (JSON): `{"anchor", "spends": [{"recipient", "value", "rho", "rseed", + /// "cmx", "witness": {"position", "authPath"}, "nullifier"?}], "outputs"?: [{"recipient", + /// "amount", "memo"?, "unifiedAddress"?}]}`, bytes hex-encoded + #[arg(long)] + notes_file: PathBuf, + /// Full viewing key file (96-byte hex): derives any missing nullifiers locally + #[arg(long)] + fvk_file: Option, + /// Current Ironwood note-commitment-tree root, hex-encoded (32 bytes). Overrides the + /// anchor in the notes file. + #[arg(long)] + anchor: Option, + }, + /// The viewing-key holder's ciphertext round: request the pending real outputs, re-encrypt + /// each under the FVK's external `ovk`, and apply the response. A PSBT with nothing pending + /// passes through unchanged. Prints the PSBT as hex to stdout, with the served action + /// indices on stderr. + OutCiphertexts { + /// 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, + /// Full viewing key file (96-byte hex) + #[arg(long)] + fvk_file: PathBuf, + }, + /// Install the randomized signing key `rk = ak.randomize(alpha)` for one real spend (the + /// FROST stand-in). Only `rk` is stored; the alpha is needed again for + /// `add-spend-signature` and `prove`. With no `--alpha`, a fresh random one is generated. + /// Prints the updated PSBT as hex to stdout, with the alpha on stderr. + SetSpendRandomizer { + /// 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, + /// Action index of the real spend (from `add-shielded-input`'s action map) + #[arg(long)] + action_index: usize, + /// Full viewing key file (96-byte hex); its `ak` checks `rk == ak.randomize(alpha)` + #[arg(long)] + fvk_file: PathBuf, + /// The randomizer scalar, hex-encoded (32 bytes). Random when omitted. + #[arg(long)] + alpha: Option, + }, + /// Sign one real spend's ZIP-244 spend sighash: locally with the spending key stand-in + /// (`--sk-file` + `--alpha`, signing `ask.randomize(alpha)`) or ingest an externally + /// produced RedPallas signature (`--signature`, the FROST case). Prints the updated PSBT as + /// hex to stdout. + #[command(group(ArgGroup::new("signer").required(true).args(["sk_file", "signature"])))] + AddSpendSignature { + /// 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, + /// Action index of the real spend + #[arg(long)] + action_index: usize, + /// The randomizer scalar `set-spend-randomizer` installed for this action, hex (32 + /// bytes). Required with --sk-file. + #[arg(long, requires = "sk_file")] + alpha: Option, + /// Spending key file (32-byte hex): signs locally with `ask.randomize(alpha)` + #[arg(long, requires = "alpha")] + sk_file: Option, + /// An externally produced 64-byte RedPallas signature, hex-encoded + #[arg(long)] + signature: Option, + }, + /// The prover role: with `--request`, print the alpha-patched PCZT bytes an external prover + /// consumes; otherwise produce the Halo2 proof locally and print the proof hex for + /// `combine-ironwood-proof --proof`. One `--alpha :` per real spend — + /// the PSBT never stores the randomizers. + Prove { + /// 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, + /// One randomizer per real spend, as `:` (repeatable) + #[arg(long = "alpha")] + alphas: Vec, + /// Full viewing key file (96-byte hex); required for local proving + #[arg(long, required_unless_present = "request")] + fvk_file: Option, + /// Print the alpha-patched proof request (the PCZT bytes) instead of proving locally + #[arg(long, conflicts_with = "fvk_file")] + request: bool, + }, + /// Build (and validate) an Ironwood Merkle witness for a note commitment — from the full + /// 32-entry sibling path or from a pruned shard + cap. Prints + /// `{"position": , "authPath": ["" × 32]}` as JSON to stdout. + #[command(group(ArgGroup::new("source").required(true).args(["auth_path", "shard_file"])))] + BuildWitness { + /// The note commitment being witnessed, hex-encoded (32 bytes) + #[arg(long)] + cmx: String, + /// The leaf's position in the note-commitment tree + #[arg(long)] + position: u32, + /// The expected note-commitment-tree root, hex-encoded (32 bytes) + #[arg(long)] + anchor: String, + /// The 32 sibling hashes: a JSON array of hex strings, 32 comma-separated hex hashes, + /// or the flattened 1024-byte hex encoding + #[arg(long)] + auth_path: Option, + /// Pruned shard subtree containing --position (ShardTreeNode JSON) + #[arg(long, requires_all = ["cap_file", "shard_height", "leaf_count"])] + shard_file: Option, + /// Pruned cap above every shard (ShardTreeNode JSON) + #[arg(long, requires = "shard_file")] + cap_file: Option, + /// The shard root's level (its height in the tree) + #[arg(long, requires = "shard_file")] + shard_height: Option, + /// Total leaves committed to the tree as of --anchor + #[arg(long, requires = "shard_file")] + leaf_count: 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. @@ -271,6 +410,78 @@ pub fn handle_command(command: PsbtCommand) -> Result<()> { memo, unified_address, ), + PsbtCommand::AddShieldedInput { + path, + network, + notes_file, + fvk_file, + anchor, + } => add_shielded_input::handle_add_shielded_input_command( + path, + network.into(), + notes_file, + fvk_file, + anchor, + ), + PsbtCommand::OutCiphertexts { + path, + network, + fvk_file, + } => out_ciphertexts::handle_out_ciphertexts_command(path, network.into(), fvk_file), + PsbtCommand::SetSpendRandomizer { + path, + network, + action_index, + fvk_file, + alpha, + } => set_spend_randomizer::handle_set_spend_randomizer_command( + path, + network.into(), + action_index, + fvk_file, + alpha, + ), + PsbtCommand::AddSpendSignature { + path, + network, + action_index, + alpha, + sk_file, + signature, + } => add_spend_signature::handle_add_spend_signature_command( + path, + network.into(), + action_index, + alpha, + sk_file, + signature, + ), + PsbtCommand::Prove { + path, + network, + alphas, + fvk_file, + request, + } => prove::handle_prove_command(path, network.into(), alphas, fvk_file, request), + PsbtCommand::BuildWitness { + cmx, + position, + anchor, + auth_path, + shard_file, + cap_file, + shard_height, + leaf_count, + } => build_witness::handle_build_witness_command(build_witness::BuildWitnessArgs { + cmx, + position, + anchor, + auth_path, + shard_file, + cap_file, + shard_height, + leaf_count, + }), PsbtCommand::SignV6Input { path, network, diff --git a/packages/wasm-utxo/cli/src/psbt/out_ciphertexts.rs b/packages/wasm-utxo/cli/src/psbt/out_ciphertexts.rs new file mode 100644 index 00000000000..5019776657b --- /dev/null +++ b/packages/wasm-utxo/cli/src/psbt/out_ciphertexts.rs @@ -0,0 +1,51 @@ +//! The viewing-key holder's ciphertext round (the stand-in for the out-ciphertext step): +//! request the pending real outputs, re-encrypt each under the FVK's external `ovk`, and +//! apply the response in one atomic batch. A bundle with nothing pending (a deshield with no +//! shielded outputs, or a repeated round) passes through unchanged. Prints the PSBT as hex to +//! stdout, with the action indices served on stderr. + +use anyhow::{anyhow, Result}; +use rand::rngs::OsRng; +use std::path::PathBuf; +use wasm_utxo::fixed_script_wallet::bitgo_psbt::zcash::IronwoodPsbtError; +use wasm_utxo::zcash::ironwood_build::compute_out_ciphertext; +use wasm_utxo::zcash::ironwood_pczt::deserialize_pczt; +use wasm_utxo::Network; + +use crate::psbt::common::{parse_fvk_file, print_zcash_psbt, read_zcash_psbt}; + +pub fn handle_out_ciphertexts_command( + path: PathBuf, + network: Network, + fvk_file: PathBuf, +) -> Result<()> { + let mut psbt = read_zcash_psbt(&path, network)?; + let fvk = parse_fvk_file(&fvk_file)?; + + let (pczt_bytes, pending) = match psbt.out_ciphertext_request() { + Ok(request) => request, + Err(IronwoodPsbtError::NothingPending) => { + eprintln!("{}", serde_json::json!({ "actionIndices": [] })); + return print_zcash_psbt(&psbt); + } + Err(e) => return Err(anyhow!("failed to build the out-ciphertext request: {e}")), + }; + let pczt = deserialize_pczt(&pczt_bytes) + .map_err(|e| anyhow!("the stored PCZT does not deserialize: {e}"))?; + let ovk = *fvk.to_ovk(orchard::keys::Scope::External).as_ref(); + let entries = pending + .iter() + .map(|&action_index| { + let out_ciphertext = compute_out_ciphertext(&pczt, action_index, ovk, &mut OsRng) + .map_err(|e| { + anyhow!("action {action_index}: computing the out-ciphertext failed: {e}") + })?; + Ok((action_index, out_ciphertext)) + }) + .collect::>>()?; + + psbt.add_out_ciphertexts(&entries) + .map_err(|e| anyhow!("failed to apply the out-ciphertexts: {e}"))?; + eprintln!("{}", serde_json::json!({ "actionIndices": pending })); + print_zcash_psbt(&psbt) +} diff --git a/packages/wasm-utxo/cli/src/psbt/prove.rs b/packages/wasm-utxo/cli/src/psbt/prove.rs new file mode 100644 index 00000000000..238d512105c --- /dev/null +++ b/packages/wasm-utxo/cli/src/psbt/prove.rs @@ -0,0 +1,70 @@ +//! The prover role for the spend flow: hand the prover what it needs. The PSBT never stores +//! the randomizers, so the alphas come in via `--alpha :`, exactly one +//! per real spend. +//! +//! Both modes first build the gated proof request, so a PSBT that is not ready for its proof +//! (pending ciphertexts, randomizers or signatures, or missing alphas) fails with a typed error +//! before any proving work. With `--request`, prints that request — the alpha-patched PCZT +//! bytes an external prover (the KMS stand-in) consumes. Without it, produces the Halo2 proof +//! locally (the `orchard-proving` feature) and prints the proof hex to feed straight into +//! `combine-ironwood-proof --proof`. Either way nothing is stored: the proof and the alphas +//! never sit in the PSBT. + +use anyhow::{anyhow, Context, Result}; +use rand::rngs::OsRng; +use std::path::PathBuf; +use wasm_utxo::Network; + +use crate::psbt::common::{parse_fvk_file, parse_hex, read_zcash_psbt}; + +pub fn handle_prove_command( + path: PathBuf, + network: Network, + alphas: Vec, + fvk_file: Option, + request: bool, +) -> Result<()> { + let psbt = read_zcash_psbt(&path, network)?; + + let alphas = alphas + .iter() + .map(|alpha| parse_alpha_arg(alpha)) + .collect::>>()?; + + let request_bytes = psbt + .proof_request(&alphas) + .map_err(|e| anyhow!("failed to build the proof request: {e}"))?; + if request { + println!("{}", hex::encode(request_bytes)); + return Ok(()); + } + + // Local proof: the stand-in prover patches the FVK and the alphas into a working copy, + // proves, and the proof is sliced out of the produced transaction. The PSBT itself is + // untouched — combine is a separate, explicit step. + let fvk_file = fvk_file.context("local proving needs --fvk-file (the stand-in key holder)")?; + let fvk = parse_fvk_file(&fvk_file)?; + let raw = psbt + .combine_ironwood_proof_locally_with_alphas(&alphas, Some(&fvk.to_bytes()), OsRng) + .map_err(|e| anyhow!("local proving failed: {e}"))?; + let tx = wasm_utxo::zcash::v6::decode_v6_transaction(&raw) + .map_err(|e| anyhow!("the locally produced transaction does not decode: {e}"))?; + let proof = tx + .ironwood_bundle + .context("the produced transaction carries no Ironwood bundle")? + .proof; + println!("{}", hex::encode(proof)); + Ok(()) +} + +/// Parse one `--alpha` argument: `:`. +fn parse_alpha_arg(alpha: &str) -> Result<(usize, [u8; 32])> { + let (index, hex_part) = alpha + .split_once(':') + .ok_or_else(|| anyhow!("--alpha expects :, got \"{alpha}\""))?; + let index: usize = index + .trim() + .parse() + .with_context(|| format!("--alpha has an invalid action index: \"{index}\""))?; + Ok((index, parse_hex(hex_part, &format!("--alpha {index}"))?)) +} diff --git a/packages/wasm-utxo/cli/src/psbt/set_spend_randomizer.rs b/packages/wasm-utxo/cli/src/psbt/set_spend_randomizer.rs new file mode 100644 index 00000000000..cc49c7bc6e2 --- /dev/null +++ b/packages/wasm-utxo/cli/src/psbt/set_spend_randomizer.rs @@ -0,0 +1,49 @@ +//! The FROST stand-in's randomizer round, one spend at a time: install +//! `rk = ak.randomize(alpha)` for one real spend. Only `rk` is stored — the PSBT never +//! carries `alpha` or the wallet's `ak`, so the alpha must be kept for the later +//! `add-spend-signature` and `prove` steps. When `--alpha` is omitted a fresh random scalar +//! is generated. Prints the updated PSBT as hex to stdout, with the alpha on stderr. + +use anyhow::{anyhow, Result}; +use ff::{Field, PrimeField}; +use orchard::primitives::redpallas::{SpendAuth, VerificationKey}; +use rand::rngs::OsRng; +use std::path::PathBuf; +use wasm_utxo::Network; + +use crate::psbt::common::{parse_alpha, parse_fvk_file, print_zcash_psbt, read_zcash_psbt}; + +pub fn handle_set_spend_randomizer_command( + path: PathBuf, + network: Network, + action_index: usize, + fvk_file: PathBuf, + alpha: Option, +) -> Result<()> { + let mut psbt = read_zcash_psbt(&path, network)?; + let fvk = parse_fvk_file(&fvk_file)?; + + // The FVK's first 32 bytes are the spend-validating key `ak`. + let ak_bytes: [u8; 32] = fvk.to_bytes()[..32].try_into().expect("FVK is 96 bytes"); + let ak = VerificationKey::::try_from(ak_bytes) + .map_err(|e| anyhow!("the FVK's ak is not a canonical verification key: {e}"))?; + + let (alpha, alpha_scalar) = match alpha { + Some(alpha) => parse_alpha(&alpha, "--alpha")?, + None => { + let scalar = pasta_curves::pallas::Scalar::random(&mut OsRng); + (scalar.to_repr(), scalar) + } + }; + let rk: [u8; 32] = ak.randomize(&alpha_scalar).into(); + + // The library rejects a zero or non-canonical alpha and checks `rk` against `ak`. + psbt.add_spend_randomizers(&[(action_index, alpha, rk)], Some(&ak_bytes)) + .map_err(|e| anyhow!("failed to install the randomizer: {e}"))?; + + eprintln!( + "{}", + serde_json::json!({ "actionIndex": action_index, "alpha": hex::encode(alpha) }) + ); + print_zcash_psbt(&psbt) +} diff --git a/packages/wasm-utxo/cli/tests/ironwood_spend_flow.rs b/packages/wasm-utxo/cli/tests/ironwood_spend_flow.rs new file mode 100644 index 00000000000..5db5fa23cb4 --- /dev/null +++ b/packages/wasm-utxo/cli/tests/ironwood_spend_flow.rs @@ -0,0 +1,285 @@ +//! End-to-end runs of the Ironwood spend-flow subcommands against the TypeScript spend +//! fixtures' requests: build → out-ciphertexts → randomizers → signatures → proof request. +//! Local proving is left to the library's own `orchard-proving` tests. + +use std::io::Write; +use std::path::{Path, PathBuf}; +use std::process::{Command, Output, Stdio}; + +use orchard::keys::{FullViewingKey, SpendingKey}; +use serde_json::Value; + +/// The wallet key the fixtures were generated with. +const SPENDING_KEY: [u8; 32] = [7u8; 32]; +/// The NU6.3 testnet consensus branch id. +const BRANCH_ID: &str = "0x37a5165b"; + +fn fixture(name: &str) -> Value { + let path = Path::new(env!("CARGO_MANIFEST_DIR")) + .join("../test/fixtures/zcash") + .join(format!("ironwood_spend_{name}.json")); + serde_json::from_slice(&std::fs::read(path).unwrap()).unwrap() +} + +/// A fresh scratch directory holding the stand-in key files. +fn scratch_dir(name: &str) -> PathBuf { + let dir = std::env::temp_dir().join(format!("wasm-utxo-cli-{name}-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let sk = Option::::from(SpendingKey::from_bytes(SPENDING_KEY)).unwrap(); + std::fs::write(dir.join("sk.hex"), hex::encode(SPENDING_KEY)).unwrap(); + std::fs::write( + dir.join("fvk.hex"), + hex::encode(FullViewingKey::from(&sk).to_bytes()), + ) + .unwrap(); + dir +} + +fn run(args: &[&str], stdin: &str) -> Output { + let mut child = Command::new(env!("CARGO_BIN_EXE_wasm-utxo-cli")) + .args(args) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .unwrap(); + child + .stdin + .take() + .unwrap() + .write_all(stdin.as_bytes()) + .unwrap(); + child.wait_with_output().unwrap() +} + +/// Run a subcommand that must succeed; returns its (stdout, stderr), trimmed. +fn ok(args: &[&str], stdin: &str) -> (String, String) { + let output = run(args, stdin); + let stdout = String::from_utf8(output.stdout).unwrap(); + let stderr = String::from_utf8(output.stderr).unwrap(); + assert!( + output.status.success(), + "{args:?} failed:\n{stderr}\n{stdout}" + ); + (stdout.trim().to_string(), stderr.trim().to_string()) +} + +fn indices(value: &Value) -> Vec { + value + .as_array() + .unwrap() + .iter() + .map(|index| index.as_u64().unwrap() as usize) + .collect() +} + +/// The empty v6 skeleton, optionally paying a transparent output. +fn skeleton(transparent_output: Option<&Value>) -> String { + let (psbt, _) = ok( + &[ + "psbt", + "create-zcash-v6", + "--network", + "tzec", + "--consensus-branch-id", + BRANCH_ID, + ], + "", + ); + let Some(output) = transparent_output else { + return psbt; + }; + ok( + &[ + "psbt", + "add-output", + "-", + "--script", + output["script"].as_str().unwrap(), + "--value", + output["value"].as_str().unwrap(), + ], + &psbt, + ) + .0 +} + +/// `add-shielded-input` from a notes file; returns the PSBT and the action map. +fn add_shielded_input(dir: &Path, psbt: &str, notes: &Value) -> (String, Value) { + let notes_path = dir.join("notes.json"); + std::fs::write(¬es_path, notes.to_string()).unwrap(); + let (psbt, map) = ok( + &[ + "psbt", + "add-shielded-input", + "-", + "--network", + "tzec", + "--notes-file", + notes_path.to_str().unwrap(), + "--fvk-file", + dir.join("fvk.hex").to_str().unwrap(), + ], + psbt, + ); + (psbt, serde_json::from_str(&map).unwrap()) +} + +/// A shielded→shielded transfer through every round up to the proof request, with the +/// nullifiers derived locally from the FVK. +#[test] +fn transfer_runs_through_the_proof_request() { + let dir = scratch_dir("transfer"); + let fvk = dir.join("fvk.hex"); + let fvk = fvk.to_str().unwrap(); + let sk = dir.join("sk.hex"); + let sk = sk.to_str().unwrap(); + + let mut notes = fixture("transfer")["request"].clone(); + for spend in notes["spends"].as_array_mut().unwrap() { + spend.as_object_mut().unwrap().remove("nullifier"); + } + let (psbt, map) = add_shielded_input(&dir, &skeleton(None), ¬es); + let spends = indices(&map["spendActionIndices"]); + assert_eq!(spends.len(), 2); + + let (psbt, served) = ok( + &[ + "psbt", + "out-ciphertexts", + "-", + "--network", + "tzec", + "--fvk-file", + fvk, + ], + &psbt, + ); + let served: Value = serde_json::from_str(&served).unwrap(); + let mut expected = indices(&map["outputActionIndices"]); + expected.sort_unstable(); + assert_eq!(indices(&served["actionIndices"]), expected); + + let mut psbt = psbt; + let mut alphas = Vec::new(); + for &action_index in &spends { + let index = action_index.to_string(); + let (next, randomizer) = ok( + &[ + "psbt", + "set-spend-randomizer", + "-", + "--network", + "tzec", + "--action-index", + &index, + "--fvk-file", + fvk, + ], + &psbt, + ); + let randomizer: Value = serde_json::from_str(&randomizer).unwrap(); + alphas.push((index, randomizer["alpha"].as_str().unwrap().to_string())); + psbt = next; + } + + // Proving before signing fails with a typed error, before any proving work. + let alpha_args: Vec = alphas + .iter() + .map(|(index, alpha)| format!("{index}:{alpha}")) + .collect(); + let mut prove_args = vec!["psbt", "prove", "-", "--network", "tzec", "--request"]; + for alpha in &alpha_args { + prove_args.extend(["--alpha", alpha]); + } + let unsigned = run(&prove_args, &psbt); + assert!(!unsigned.status.success()); + + for (index, alpha) in &alphas { + psbt = ok( + &[ + "psbt", + "add-spend-signature", + "-", + "--network", + "tzec", + "--action-index", + index, + "--alpha", + alpha, + "--sk-file", + sk, + ], + &psbt, + ) + .0; + } + + let (request, _) = ok(&prove_args, &psbt); + assert!(!hex::decode(request).unwrap().is_empty()); + + // A proof request without the alphas is rejected: the PSBT never stores them. + let missing = run( + &["psbt", "prove", "-", "--network", "tzec", "--request"], + &psbt, + ); + assert!(!missing.status.success()); + + let (parsed, _) = ok( + &["psbt", "parse", "-", "--network", "tzec", "--no-color"], + &psbt, + ); + assert!(parsed.contains("ready_for_proof")); + assert!(parsed.contains("installed")); +} + +/// A deshield has no shielded outputs: the ciphertext round passes the PSBT through unchanged. +#[test] +fn deshield_out_ciphertexts_passes_through() { + let dir = scratch_dir("deshield"); + let fixture = fixture("deshield"); + let skeleton = skeleton(Some(&fixture["request"]["transparentOutput"])); + let (psbt, map) = add_shielded_input(&dir, &skeleton, &fixture["request"]); + assert!(indices(&map["outputActionIndices"]).is_empty()); + + let (unchanged, served) = ok( + &[ + "psbt", + "out-ciphertexts", + "-", + "--network", + "tzec", + "--fvk-file", + dir.join("fvk.hex").to_str().unwrap(), + ], + &psbt, + ); + assert_eq!(unchanged, psbt); + let served: Value = serde_json::from_str(&served).unwrap(); + assert!(indices(&served["actionIndices"]).is_empty()); +} + +/// The signer flags are mutually exclusive, and `--alpha` only goes with `--sk-file`. +#[test] +fn add_spend_signature_rejects_mixed_signer_flags() { + let base = [ + "psbt", + "add-spend-signature", + "-", + "--network", + "tzec", + "--action-index", + "0", + ]; + let signature = "00".repeat(64); + let alpha = "01".repeat(32); + for extra in [ + vec!["--signature", signature.as_str(), "--alpha", alpha.as_str()], + vec!["--sk-file", "sk.hex"], + vec![], + ] { + let args: Vec<&str> = base.iter().copied().chain(extra).collect(); + let output = run(&args, ""); + assert!(!output.status.success(), "{args:?} should be rejected"); + } +} diff --git a/packages/wasm-utxo/src/inspect/zcash.rs b/packages/wasm-utxo/src/inspect/zcash.rs index f8ba98e4eee..fb126b62640 100644 --- a/packages/wasm-utxo/src/inspect/zcash.rs +++ b/packages/wasm-utxo/src/inspect/zcash.rs @@ -8,6 +8,8 @@ use super::psbt::{ proprietary_to_nodes, psbt_inputs_to_node, psbt_outputs_to_node, tx_inputs_to_node, tx_outputs_to_node, tx_to_node, xpubs_to_node, }; +use crate::fixed_script_wallet::bitgo_psbt::zcash::ironwood_spend::IronwoodInputKind; +use crate::fixed_script_wallet::bitgo_psbt::zcash::IronwoodPsbtError; use crate::fixed_script_wallet::bitgo_psbt::ZcashBitGoPsbt; use crate::networks::Network; use crate::zcash::transaction::ZcashTransactionParts; @@ -178,69 +180,128 @@ fn transparent_signing_state(inputs: &[crate::bitcoin::psbt::Input]) -> &'static "half_signed" } -/// The `ironwood` node: every real shielded output's value/recipient, every real shielded spend's -/// value/nullifier (when the bundle spends notes), and the bundle's -/// action-data (commitments, ciphertexts, flags, value balance, anchor) read from the PCZT stored -/// in the proprietary map. Reports an error message inline rather than aborting the whole parse if -/// the PCZT is malformed or missing (e.g. a v6 PSBT that had `add_ironwood_output`/ -/// `add_ironwood_outputs` never called, or was already extracted via `combine_ironwood_proof`). +/// The `ironwood` node: the shielded workflow's state and the key-free verifier's verdict. +/// +/// - **Workflow state** (from [`ZcashBitGoPsbt::shielded_status`]): the `input_kind`, what is +/// still pending (out-ciphertexts, randomizers, spend signatures) and whether the bundle is +/// ready for its proof. +/// - **Verification** (from [`ZcashBitGoPsbt::verify_shielded_bundle`], with no caller key +/// material — the inspector is unauthenticated): the real spends with their randomizer / +/// signature state and Unified Addresses, and the real outputs. `failed` names the failing +/// check; `not_applicable` when no PCZT is stored (a transparent-only v6 PSBT, or one +/// already extracted). +/// - The action-data view (action count, flags, value balance, anchor) the ZIP-244 txid +/// commits to. +/// +/// Reports errors inline rather than aborting the whole parse. fn ironwood_shielded_state_to_node(zcash_psbt: &ZcashBitGoPsbt) -> Node { let mut node = Node::new("ironwood", Primitive::None); - match zcash_psbt.ironwood_shielded_outputs_info() { - Ok(outputs) if outputs.is_empty() => { + // The workflow state: where this PSBT stands in the build → ciphertexts → sign → prove + // sequence. + match zcash_psbt.shielded_status() { + Ok(status) => { node.add_child(Node::new( - "shielded_outputs", - Primitive::String("none".to_string()), + "input_kind", + Primitive::String( + match status.input_kind { + IronwoodInputKind::Transparent => "transparent", + IronwoodInputKind::Shielded => "shielded", + } + .to_string(), + ), + )); + add_pending( + &mut node, + "pending_out_ciphertexts", + &status.pending_out_ciphertexts, + ); + add_pending( + &mut node, + "pending_randomizers", + &status.pending_randomizers, + ); + add_pending( + &mut node, + "pending_spend_signatures", + &status.pending_spend_signatures, + ); + node.add_child(Node::new( + "ready_for_proof", + Primitive::Boolean(status.ready_for_proof), )); } - Ok(outputs) => { - let mut outputs_node = Node::new("shielded_outputs", Primitive::None); - for (action_index, value, recipient) in outputs { - let mut output_node = Node::new("shielded_output", Primitive::None); - output_node.add_child(Node::new( - "action_index", - Primitive::U64(action_index as u64), - )); - output_node.add_child(Node::new("value", Primitive::U64(value))); - output_node.add_child(Node::new( - "recipient", - Primitive::Buffer(recipient.to_vec()), - )); - outputs_node.add_child(output_node); - } - node.add_child(outputs_node); - } - Err(e) => { - node.add_child(Node::new("shielded_output_error", Primitive::String(e))); - } + Err(e) => node.add_child(Node::new("status_error", Primitive::String(e.to_string()))), } - // Only rendered for bundles that spend real notes, so shielding-only output is unchanged. - match zcash_psbt.ironwood_shielded_spends_info() { - Ok(spends) if spends.is_empty() => {} - Ok(spends) => { - let mut spends_node = Node::new("shielded_spends", Primitive::None); - for spend in spends { - let mut spend_node = Node::new("shielded_spend", Primitive::None); - spend_node.add_child(Node::new( - "action_index", - Primitive::U64(spend.action_index as u64), - )); - spend_node.add_child(Node::new("value", Primitive::U64(spend.value))); - spend_node.add_child(Node::new( - "nullifier", - Primitive::Buffer(spend.nullifier.to_vec()), - )); - spends_node.add_child(spend_node); + // The key-free verifier's verdict. No caller key material: an inspector can see what the + // bundle commits to, not whose wallet it spends from. + match zcash_psbt.verify_shielded_bundle(None, &[]) { + Ok(report) => { + let mut verification = Node::new("verification", Primitive::String("ok".to_string())); + if !report.spends.is_empty() { + let mut spends = Node::new("spends", Primitive::None); + for spend in &report.spends { + let mut spend_node = Node::new("spend", Primitive::None); + spend_node.add_child(Node::new( + "action_index", + Primitive::U64(spend.action_index as u64), + )); + spend_node.add_child(Node::new("value", Primitive::U64(spend.value))); + spend_node.add_child(Node::new( + "address", + Primitive::String(spend.address.clone()), + )); + spend_node.add_child(Node::new( + "randomizer", + Primitive::String( + if spend.rk_replaced { + "installed" + } else { + "placeholder" + } + .to_string(), + ), + )); + spend_node.add_child(Node::new("signed", Primitive::Boolean(spend.signed))); + spends.add_child(spend_node); + } + verification.add_child(spends); } - node.add_child(spends_node); + if !report.outputs.is_empty() { + let mut outputs = Node::new("outputs", Primitive::None); + for output in &report.outputs { + let mut output_node = Node::new("output", Primitive::None); + output_node.add_child(Node::new( + "action_index", + Primitive::U64(output.action_index as u64), + )); + output_node.add_child(Node::new("value", Primitive::U64(output.value))); + output_node.add_child(Node::new( + "recipient", + Primitive::Buffer(output.recipient.to_vec()), + )); + outputs.add_child(output_node); + } + verification.add_child(outputs); + } + node.add_child(verification); + } + Err(IronwoodPsbtError::NotSpendBundle) => { + node.add_child(Node::new( + "verification", + Primitive::String("not_applicable".to_string()), + )); } Err(e) => { - node.add_child(Node::new("shielded_spend_error", Primitive::String(e))); + let mut verification = + Node::new("verification", Primitive::String("failed".to_string())); + verification.add_child(Node::new("error", Primitive::String(e.to_string()))); + node.add_child(verification); } } + // The action-data view: what the ZIP-244 txid commits to. match zcash_psbt.ironwood_action_data() { Ok(bundle) => { node.add_child(Node::new( @@ -267,6 +328,20 @@ fn ironwood_shielded_state_to_node(zcash_psbt: &ZcashBitGoPsbt) -> Node { node } +/// Attach a pending-index list: a "none" marker when empty, otherwise one `index` child per +/// action. +fn add_pending(node: &mut Node, label: &str, indices: &[usize]) { + if indices.is_empty() { + node.add_child(Node::new(label, Primitive::String("none".to_string()))); + return; + } + let mut list = Node::new(label, Primitive::None); + for index in indices { + list.add_child(Node::new("index", Primitive::U64(*index as u64))); + } + node.add_child(list); +} + /// Parsing of v6 (Ironwood) shielding PSBTs at unsigned/half-signed/fully-signed transparent /// signing states. Native-only (orchard + zebra), matching /// `bitgo_psbt::zcash::tests::ironwood`. @@ -384,8 +459,13 @@ mod ironwood_v6_tests { fn assert_shielded_output(node: &Node, expected_value: u64, expected_recipient: &[u8; 43]) { let ironwood = find(node, "ironwood").expect("ironwood node present"); - let outputs = find(ironwood, "shielded_outputs").expect("shielded_outputs node present"); - let output = find(outputs, "shielded_output").expect("shielded_output node present"); + let verification = find(ironwood, "verification").expect("verification node present"); + assert!(matches!( + &verification.value, + Primitive::String(s) if s == "ok" + )); + let outputs = find(verification, "outputs").expect("outputs node present"); + let output = find(outputs, "output").expect("output node present"); let value = find(output, "value").expect("value present"); match &value.value { Primitive::U64(v) => assert_eq!(*v, expected_value), @@ -434,4 +514,220 @@ mod ironwood_v6_tests { assert_shielded_output(&node, 100_000_000, &test_recipient()); assert_eq!(signing_state(&node), "fully_signed"); } + /// A keyless shielded→shielded spend PSBT: two wallet notes into two wallet outputs, with + /// its action map and the wallet's spending key. + fn build_spend_psbt( + seed: &str, + ) -> ( + ZcashBitGoPsbt, + crate::zcash::ironwood_build::IronwoodActionMap, + SpendingKey, + ) { + use crate::fixed_script_wallet::bitgo_psbt::zcash::IronwoodOutputRequest; + use crate::zcash::ironwood_construct::test_util::keyless_fixture; + + let (specs, anchor, fvk, _ak) = keyless_fixture(); + let wallet_keys = RootWalletKeys::new(get_test_wallet_keys(seed)); + let psbt = BitGoPsbt::new_zcash_v6_at_height( + NetEnum::ZcashTestnet, + &wallet_keys, + NetworkUpgrade::Nu6_3.testnet_activation_height(), + None, + None, + ) + .unwrap(); + let BitGoPsbt::Zcash(mut z, _) = psbt else { + panic!("expected Zcash PSBT"); + }; + let outputs: Vec = [100u32, 101] + .iter() + .map(|&diversifier| IronwoodOutputRequest { + recipient: fvk + .address_at(diversifier, Scope::External) + .to_raw_address_bytes(), + amount: 90_000_000, + ovk: None, + memo: [0u8; 512], + unified_address: None, + }) + .collect(); + let map = z + .add_shielded_spends(&specs, &outputs, &anchor, OsRng) + .unwrap(); + let sk = Option::::from(SpendingKey::from_bytes([7u8; 32])).unwrap(); + (z, map, sk) + } + + fn string_value<'a>(node: &'a Node, label: &str) -> &'a str { + match &find(node, label) + .unwrap_or_else(|| panic!("{label} present")) + .value + { + Primitive::String(s) => s, + other => panic!("unexpected primitive for {label}: {other:?}"), + } + } + + /// The action indices listed under a pending node, or empty for its "none" marker. + fn pending(ironwood: &Node, label: &str) -> Vec { + let node = find(ironwood, label).unwrap_or_else(|| panic!("{label} present")); + if matches!(&node.value, Primitive::String(s) if s == "none") { + return Vec::new(); + } + node.children + .iter() + .map(|child| match child.value { + Primitive::U64(index) => index as usize, + ref other => panic!("unexpected primitive: {other:?}"), + }) + .collect() + } + + fn ready_for_proof(ironwood: &Node) -> bool { + match find(ironwood, "ready_for_proof").unwrap().value { + Primitive::Boolean(b) => b, + ref other => panic!("unexpected primitive: {other:?}"), + } + } + + /// Every verified spend's `(randomizer, signed)` state. + fn spend_states(ironwood: &Node) -> Vec<(String, bool)> { + let verification = find(ironwood, "verification").expect("verification node present"); + assert_eq!(string_value(ironwood, "verification"), "ok"); + find(verification, "spends") + .expect("spends node present") + .children + .iter() + .map(|spend| { + let signed = match find(spend, "signed").unwrap().value { + Primitive::Boolean(b) => b, + ref other => panic!("unexpected primitive: {other:?}"), + }; + (string_value(spend, "randomizer").to_string(), signed) + }) + .collect() + } + + fn sorted(indices: &[usize]) -> Vec { + let mut indices = indices.to_vec(); + indices.sort_unstable(); + indices + } + + /// The `ironwood` node tracks a spend PSBT through ciphertexts → randomizers → signatures. + #[test] + fn parses_shielded_spend_psbt_at_every_stage() { + use crate::zcash::ironwood_build::compute_out_ciphertext; + use ff::{Field, PrimeField}; + use orchard::keys::SpendAuthorizingKey; + use orchard::primitives::redpallas::{SpendAuth, VerificationKey}; + + let (mut z, map, sk) = build_spend_psbt("inspect_v6_spend"); + let spends = sorted(map.spend_action_indices()); + let outputs = sorted(map.output_action_indices()); + + let node = parse(&z); + let ironwood = find(&node, "ironwood").expect("ironwood node present"); + assert_eq!(string_value(ironwood, "input_kind"), "shielded"); + assert_eq!(pending(ironwood, "pending_out_ciphertexts"), outputs); + assert_eq!(pending(ironwood, "pending_randomizers"), spends); + assert_eq!(pending(ironwood, "pending_spend_signatures"), spends); + assert!(!ready_for_proof(ironwood)); + assert!(spend_states(ironwood) + .iter() + .all(|(randomizer, signed)| randomizer == "placeholder" && !signed)); + + let ovk = *FullViewingKey::from(&sk).to_ovk(Scope::External).as_ref(); + let pczt = z.ironwood_pczt().unwrap(); + let ciphertexts: Vec<_> = outputs + .iter() + .map(|&i| { + ( + i, + compute_out_ciphertext(&pczt, i, ovk, &mut OsRng).unwrap(), + ) + }) + .collect(); + z.add_out_ciphertexts(&ciphertexts).unwrap(); + let node = parse(&z); + let ironwood = find(&node, "ironwood").unwrap(); + assert!(pending(ironwood, "pending_out_ciphertexts").is_empty()); + assert_eq!(pending(ironwood, "pending_randomizers"), spends); + + let ask = SpendAuthorizingKey::from(&sk); + let alphas: Vec<(usize, pasta_curves::pallas::Scalar)> = spends + .iter() + .map(|&i| (i, pasta_curves::pallas::Scalar::random(&mut OsRng))) + .collect(); + let triples: Vec<(usize, [u8; 32], [u8; 32])> = alphas + .iter() + .map(|(i, alpha)| { + let rk = VerificationKey::::from(&ask.randomize(alpha)); + (*i, alpha.to_repr(), <[u8; 32]>::from(rk)) + }) + .collect(); + z.add_spend_randomizers(&triples, None).unwrap(); + let node = parse(&z); + let ironwood = find(&node, "ironwood").unwrap(); + assert!(pending(ironwood, "pending_randomizers").is_empty()); + assert_eq!(pending(ironwood, "pending_spend_signatures"), spends); + assert!(!ready_for_proof(ironwood)); + assert!(spend_states(ironwood) + .iter() + .all(|(randomizer, signed)| randomizer == "installed" && !signed)); + + let sighash = z.spend_sighash().unwrap(); + let signatures: Vec<(usize, [u8; 64])> = alphas + .iter() + .map(|(i, alpha)| { + ( + *i, + <[u8; 64]>::from(&ask.randomize(alpha).sign(OsRng, &sighash)), + ) + }) + .collect(); + z.add_spend_signatures(&signatures).unwrap(); + let node = parse(&z); + let ironwood = find(&node, "ironwood").unwrap(); + assert!(pending(ironwood, "pending_spend_signatures").is_empty()); + assert!(ready_for_proof(ironwood)); + let states = spend_states(ironwood); + assert_eq!(states.len(), spends.len()); + assert!(states + .iter() + .all(|(randomizer, signed)| randomizer == "installed" && *signed)); + } + + /// A transparent-only v6 PSBT has no bundle to verify. + #[test] + fn parses_transparent_only_v6_psbt_as_not_applicable() { + let wallet_keys = RootWalletKeys::new(get_test_wallet_keys("inspect_v6_transparent")); + let mut psbt = BitGoPsbt::new_zcash_v6_at_height( + NetEnum::ZcashTestnet, + &wallet_keys, + NetworkUpgrade::Nu6_3.testnet_activation_height(), + 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, 199_900_000, &wallet_keys) + .unwrap(); + let BitGoPsbt::Zcash(z, _) = psbt else { + panic!("expected Zcash PSBT"); + }; + let node = parse(&z); + let ironwood = find(&node, "ironwood").expect("ironwood node present"); + assert_eq!(string_value(ironwood, "input_kind"), "transparent"); + assert_eq!(string_value(ironwood, "verification"), "not_applicable"); + assert!(!ready_for_proof(ironwood)); + } }