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 @@ -25,3 +25,9 @@ num-bigint = "0.4"
colored = "2.1"
ptree = "0.5"
rand = "0.8"

# Used only by the `examples/` Ironwood capture tools (note scanning and tree witnessing).
[dev-dependencies]
incrementalmerkletree = { version = "0.8", features = ["legacy-api"] }
shardtree = "=0.6.2"
zcash_note_encryption = "0.4"
8 changes: 8 additions & 0 deletions packages/wasm-utxo/cli/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -296,6 +296,14 @@ The spend-flow commands reproduce the same steps interactively: a fixture's `req
valid `--notes-file`, and steps 3–5 above produce its out-ciphertexts, randomizers, signatures,
proof request and proof.

**Capturing on-chain goldens.** The v6 spend goldens under `test/fixtures/zcash/`
(`v6_unshield_*`, `v6_shielded_transfer_*`) were produced with the same commands on chain data:
the `ironwood_scan_note` and `ironwood_tree` examples recover the spent note and its Merkle
witness from a node's RPC output, the steps above build, sign and prove the transaction, and
the mined transaction's raw hex and txid become the fixture pair. Wallet generation
(`ironwood_keygen`, `ironwood_derive`) and the full procedure are documented in
[`../docs/zcash-v6-transaction.md`](../docs/zcash-v6-transaction.md).

### Supported Networks

The CLI supports the following networks (use with `--network` flag):
Expand Down
62 changes: 62 additions & 0 deletions packages/wasm-utxo/cli/examples/ironwood_derive.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
//! Derive Orchard/Ironwood receiving addresses from a spending key file.
//!
//! Prints the raw 43-byte Ironwood receiver and the single-receiver Unified Address for
//! diversifiers 0..count (External scope), plus the 96-byte FVK — the material a spend flow's
//! `--fvk-file` / shielded-output `--recipient` consumes.
//!
//! Usage: `cargo run --example ironwood_derive -- <sk-hex|sk-file> [count]` (count defaults to
//! 2, at most 100).

use anyhow::{bail, Context};
use orchard::keys::{FullViewingKey, Scope, SpendingKey};
use wasm_utxo::zcash::unified_address::encode_orchard_receiver;

const MAX_COUNT: u32 = 100;

fn main() -> anyhow::Result<()> {
let args: Vec<String> = std::env::args().collect();
if args.len() < 2 || args.len() > 3 {
bail!("usage: ironwood_derive <sk-hex|sk-file> [count]");
}
// A readable file holds the key; anything else is the key itself.
let raw = std::fs::read_to_string(&args[1]).unwrap_or_else(|_| args[1].clone());
let sk_bytes: [u8; 32] = hex::decode(raw.trim())
.context("invalid spending key hex")?
.as_slice()
.try_into()
.map_err(|_| anyhow::anyhow!("sk must be 32 bytes"))?;
let sk = Option::<SpendingKey>::from(SpendingKey::from_bytes(sk_bytes))
.context("not a valid spending key")?;
let fvk = FullViewingKey::from(&sk);
let count: u32 = args
.get(2)
.map(|s| s.parse().context("count must be a number"))
.transpose()?
.unwrap_or(2);
if count > MAX_COUNT {
bail!("count must be at most {MAX_COUNT}");
}

let mut receivers = Vec::new();
for diversifier in 0..count {
let receiver: [u8; 43] = fvk
.address_at(diversifier, Scope::External)
.to_raw_address_bytes();
let ua = encode_orchard_receiver(&receiver, "tzec")
.map_err(|e| anyhow::anyhow!("encoding receiver as UA: {e}"))?;
receivers.push(serde_json::json!({
"diversifier": diversifier,
"receiverHex": hex::encode(receiver),
"unifiedAddress": ua,
}));
}
println!(
"{}",
serde_json::to_string_pretty(&serde_json::json!({
"fvkHex": hex::encode(fvk.to_bytes()),
"skHex": hex::encode(sk_bytes),
"receivers": receivers,
}))?
);
Ok(())
}
169 changes: 169 additions & 0 deletions packages/wasm-utxo/cli/examples/ironwood_keygen.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,169 @@
//! Generate a Zcash testnet wallet for exercising the Ironwood shield/spend flows.
//!
//! Prints the wallet and writes the key files the wasm-utxo-cli shield/spend commands consume
//! (`--sk-file`, `--fvk-file`, `--privkey`, `--descriptor`):
//!
//! - an Orchard spending key + full viewing key, the raw 43-byte Ironwood receiver at
//! diversifier 0 (External), and its single-receiver Unified Address (`utest1…`);
//! - a 2-of-3 P2SH transparent funding address with three WIF keys — the shape
//! `psbt sign-v6-input` requires for transparent inputs;
//! - a plain P2PKH t-address to receive a deshield payout.
//!
//! Usage: `cargo run --example ironwood_keygen -- [<output-dir>] [--from-sk <32-byte-hex>]`.
//! The output directory defaults to `ironwood-e2e-wallet` (relative to the cwd); `--from-sk`
//! re-derives the Orchard keys of a known spending key instead of generating one. The
//! transparent keys are always fresh. TESTNET-ONLY key material; never reuse on mainnet.

use anyhow::{bail, Context};
use orchard::keys::{FullViewingKey, Scope, SpendingKey};
use rand::rngs::OsRng;
use rand::RngCore;
use serde_json::json;
use wasm_utxo::bitcoin::opcodes::all::{OP_CHECKMULTISIG, OP_PUSHNUM_2, OP_PUSHNUM_3};
use wasm_utxo::bitcoin::script::Builder;
use wasm_utxo::bitcoin::secp256k1::{Secp256k1, SecretKey};
use wasm_utxo::bitcoin::{Network as BtcNetwork, PrivateKey, ScriptBuf};
use wasm_utxo::zcash::unified_address::{encode_orchard_receiver, UnifiedAddress};
use wasm_utxo::{from_output_script_with_network, Network};

/// Network every address/key below is bound to: Zcash testnet (`tzec` / `utest` HRP).
const NETWORK: Network = Network::ZcashTestnet;
const UA_NETWORK: &str = "tzec";
const DEFAULT_OUT_DIR: &str = "ironwood-e2e-wallet";
const USAGE: &str = "usage: ironwood_keygen [<output-dir>] [--from-sk <32-byte-hex>]";

fn main() -> anyhow::Result<()> {
let (out_dir, from_sk) = parse_args()?;
std::fs::create_dir_all(&out_dir).context("creating output dir")?;

// ---- Orchard / Ironwood keys -------------------------------------------
// SpendingKey::from_bytes rejects ~2^-252 of byte strings (non-canonical Pallas scalars);
// loop for the (never taken in practice) rejection.
let spending_key = match from_sk {
Some(hex_sk) => {
let bytes: [u8; 32] = hex::decode(hex_sk.trim())
.context("invalid --from-sk hex")?
.as_slice()
.try_into()
.map_err(|_| anyhow::anyhow!("--from-sk must be 32 bytes"))?;
Option::<SpendingKey>::from(SpendingKey::from_bytes(bytes))
.context("--from-sk is not a valid spending key")?
}
None => loop {
if let Some(sk) = Option::<SpendingKey>::from(SpendingKey::from_bytes(random_bytes())) {
break sk;
}
},
};

let fvk = FullViewingKey::from(&spending_key);
let sk_hex = hex::encode(spending_key.to_bytes());
let fvk_hex = hex::encode(fvk.to_bytes());

let receiver: [u8; 43] = fvk.address_at(0u32, Scope::External).to_raw_address_bytes();
let receiver_hex = hex::encode(receiver);
let unified_address = encode_orchard_receiver(&receiver, UA_NETWORK)
.map_err(|e| anyhow::anyhow!("encoding orchard receiver as UA: {e}"))?;

// Round-trip: the UA must parse back to the same Orchard receiver (and only it).
let parsed = UnifiedAddress::parse(&unified_address, UA_NETWORK)
.map_err(|e| anyhow::anyhow!("generated UA failed to parse: {e}"))?;
let parsed_receiver = parsed
.orchard_receiver()
.map_err(|e| anyhow::anyhow!("generated UA has no orchard receiver: {e}"))?;
if parsed_receiver.as_deref() != Some(receiver.as_slice()) {
bail!("UA round-trip lost the orchard receiver");
}

// ---- Transparent 2-of-3 funding address (sign-v6-input's required shape) ----
let secp = Secp256k1::new();
let funding_keys = (0..3)
.map(|_| random_private_key())
.collect::<anyhow::Result<Vec<_>>>()?;
let pubkeys: Vec<_> = funding_keys.iter().map(|k| k.public_key(&secp)).collect();
let redeem_script = Builder::new()
.push_opcode(OP_PUSHNUM_2)
.push_key(&pubkeys[0])
.push_key(&pubkeys[1])
.push_key(&pubkeys[2])
.push_opcode(OP_PUSHNUM_3)
.push_opcode(OP_CHECKMULTISIG)
.into_script();
let p2sh_script = ScriptBuf::new_p2sh(&redeem_script.script_hash());
let funding_address = from_output_script_with_network(&p2sh_script, NETWORK)
.context("encoding 2-of-3 P2SH t-address")?;
let descriptor = format!("sh(multi(2,{},{},{}))", pubkeys[0], pubkeys[1], pubkeys[2]);

// ---- Plain P2PKH t-address (deshield payout destination) ----
let payout_key = random_private_key()?;
let p2pkh_script = ScriptBuf::new_p2pkh(&payout_key.public_key(&secp).pubkey_hash());
let payout_address = from_output_script_with_network(&p2pkh_script, NETWORK)
.context("encoding P2PKH t-address")?;

let summary = json!({
"network": "tzec (Zcash testnet)",
"orchard": {
"spendingKeyHex": sk_hex,
"fvkHex": fvk_hex,
"ironwoodReceiverHex": receiver_hex,
"unifiedAddress": unified_address,
},
"funding": {
"address": funding_address,
"descriptor": descriptor,
"redeemScriptHex": hex::encode(redeem_script.as_bytes()),
"p2shScriptHex": hex::encode(p2sh_script.as_bytes()),
"wifs": funding_keys.iter().map(|k| k.to_wif()).collect::<Vec<_>>(),
"pubkeys": pubkeys.iter().map(|k| k.to_string()).collect::<Vec<_>>(),
},
"payout": {
"address": payout_address,
"wif": payout_key.to_wif(),
"p2pkhScriptHex": hex::encode(p2pkh_script.as_bytes()),
},
});
let summary = serde_json::to_string_pretty(&summary)?;

for (name, contents) in [
("orchard_sk.hex", sk_hex.as_str()),
("orchard_fvk.hex", fvk_hex.as_str()),
("orchard_receiver.hex", receiver_hex.as_str()),
("wallet.json", summary.as_str()),
] {
std::fs::write(format!("{out_dir}/{name}"), contents)
.with_context(|| format!("writing {name}"))?;
}

println!("{summary}");
println!("\nfiles written to {out_dir}/");
Ok(())
}

/// `[<output-dir>] [--from-sk <hex>]`, in that order.
fn parse_args() -> anyhow::Result<(String, Option<String>)> {
let mut args = std::env::args().skip(1).peekable();
let out_dir = match args.peek() {
Some(arg) if arg != "--from-sk" => args.next().unwrap(),
_ => DEFAULT_OUT_DIR.to_string(),
};
let from_sk = match args.next().as_deref() {
None => None,
Some("--from-sk") => Some(args.next().context("--from-sk requires a value")?),
Some(other) => bail!("unexpected argument {other}\n{USAGE}"),
};
if let Some(extra) = args.next() {
bail!("unexpected argument {extra}\n{USAGE}");
}
Ok((out_dir, from_sk))
}

fn random_bytes() -> [u8; 32] {
let mut bytes = [0u8; 32];
OsRng.fill_bytes(&mut bytes);
bytes
}

fn random_private_key() -> anyhow::Result<PrivateKey> {
let secret = SecretKey::from_slice(&random_bytes()).context("secp256k1 secret key")?;
Ok(PrivateKey::new(secret, BtcNetwork::Testnet))
}
Loading
Loading