Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions packages/wasm-utxo/Cargo.lock

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

6 changes: 6 additions & 0 deletions packages/wasm-utxo/cli/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
78 changes: 78 additions & 0 deletions packages/wasm-utxo/cli/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <PATH> --network <NETWORK> --notes-file <JSON> [--fvk-file <FILE>] [--anchor <HEX32>]
wasm-utxo-cli psbt out-ciphertexts <PATH> --network <NETWORK> --fvk-file <FILE>
wasm-utxo-cli psbt set-spend-randomizer <PATH> --network <NETWORK> --action-index <INDEX> --fvk-file <FILE> [--alpha <HEX32>]
wasm-utxo-cli psbt add-spend-signature <PATH> --network <NETWORK> --action-index <INDEX> (--sk-file <FILE> --alpha <HEX32> | --signature <HEX64>)
wasm-utxo-cli psbt prove <PATH> --network <NETWORK> --alpha <INDEX>:<HEX32>... (--request | --fvk-file <FILE>)
wasm-utxo-cli psbt build-witness --cmx <HEX32> --position <POS> --anchor <HEX32> (--auth-path <HASHES> | --shard-file <JSON> --cap-file <JSON> --shard-height <H> --leaf-count <N>)
```

`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 <alpha0> > psbt4.hex
wasm-utxo-cli psbt add-spend-signature psbt4.hex --network tzec \
--action-index 0 --alpha <alpha0> --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:<alpha0> --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:<alpha0> --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 <hex32> --position 0 --anchor <hex32> \
--auth-path '[ "<hex32>", ... 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):
Expand Down
247 changes: 247 additions & 0 deletions packages/wasm-utxo/cli/src/psbt/add_shielded_input.rs
Original file line number Diff line number Diff line change
@@ -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": "<hex32>",
//! "spends": [{
//! "recipient": "<hex43>", "value": 100000000,
//! "rho": "<hex32>", "rseed": "<hex32>", "cmx": "<hex32>",
//! "witness": { "position": 0, "authPath": ["<hex32>", "..."] },
//! "nullifier": "<hex32>"
//! }],
//! "outputs": [{ "recipient": "<hex43>", "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<String>,
spends: Vec<NoteSpend>,
#[serde(default)]
outputs: Vec<NoteOutput>,
}

#[derive(Deserialize)]
struct NoteSpend {
recipient: String,
value: JsonU64,
rho: String,
rseed: String,
cmx: String,
witness: NoteWitness,
nullifier: Option<String>,
}

#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
struct NoteWitness {
position: u32,
auth_path: AuthPath,
}

#[derive(Deserialize)]
#[serde(untagged)]
enum AuthPath {
Hashes(Vec<String>),
Flattened(String),
}

#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
struct NoteOutput {
recipient: String,
amount: JsonU64,
memo: Option<String>,
unified_address: Option<String>,
}

/// 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<u64> {
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<PathBuf>,
anchor: Option<String>,
) -> Result<()> {
let mut psbt = read_zcash_psbt(&path, network)?;

let notes = read_input_bytes(&notes_file, "notes file")?;
let notes: NotesFile =
serde_json::from_slice(&notes).context("notes file is not a valid notes JSON")?;

let anchor: [u8; 32] = match (&anchor, &notes.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::<Result<Vec<_>>>()?;
let outputs = notes
.outputs
.iter()
.enumerate()
.map(|(i, output)| parse_output(output, &format!("output {i}")))
.collect::<Result<Vec<_>>>()?;

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<IronwoodKeylessSpendSpec> {
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<IronwoodOutputRequest> {
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::<Address>::from(Address::from_raw_address_bytes(recipient))
.context("the spend recipient is not a valid Orchard address")?;
let rho = Option::<Rho>::from(Rho::from_bytes(rho))
.context("the spend rho is not a canonical field element")?;
let rseed = Option::<RandomSeed>::from(RandomSeed::from_bytes(*rseed, &rho))
.context("the spend rseed is not canonical")?;
let note = Option::<Note>::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())
}
7 changes: 4 additions & 3 deletions packages/wasm-utxo/cli/src/psbt/add_shielded_output.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand All @@ -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")?
Expand All @@ -43,7 +44,7 @@ pub fn handle_add_shielded_output_command(
.context("invalid --memo hex")?
.try_into()
.map_err(|v: Vec<u8>| anyhow!("--memo must be 512 bytes, got {}", v.len()))?,
None => [0u8; 512],
None => zip302_no_memo(),
};

psbt.add_ironwood_output(
Expand Down
Loading
Loading