From 051b0e8855b498b16fc4fd65405427befdad571a Mon Sep 17 00:00:00 2001 From: Veetrag Jain Date: Mon, 27 Jul 2026 18:35:29 +0530 Subject: [PATCH 1/5] feat(wasm-utxo): add ZIP-244 v6 signature digests (shielded + transparent) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extend the v6 (Ironwood/NU6.3) codec with the two ZIP-244 signature-hash computations needed to sign a v6 transaction. Previously the codec could only compute the ZIP-244 txid. - `compute_v6_sig_digest` — shielded SIGHASH_ALL digest (the message the Ironwood binding signature signs). - `compute_v6_transparent_sighash` — per-input transparent SIGHASH_ALL digest, folding in the ironwood_digest. Both share a single ZIP-244 §S.2 core that differs only in the per-input (txin) sub-digest: empty for a shielded signature, populated for the transparent input being signed. Adds the `ZTxTrAmountsHash`, `ZTxTrScriptsHash`, and `Zcash___TxInHash` personalizations, matching the `zcash_primitives` reference (amounts/scripts use the count-less `Array` encoding; txid sub-digests stay plain concatenation). Tests: - A golden oracle that verifies the REAL ECDSA signature of the signed `v6_shield1zec` transparent input against `compute_v6_transparent_sighash` (independent: the signature was produced by an external signer). Proves the transparent digest is byte-correct against a real signed v6 tx. - Regression coverage for determinism, that the digest commits to input amounts/scriptPubKeys, transparent-vs-shielded difference, per-input variation, and the no-transparent-inputs fallback (== txid). No new dependencies. Co-Authored-By: Claude Opus 4.8 --- packages/wasm-utxo/src/zcash/v6.rs | 319 ++++++++++++++++++++++++++++- 1 file changed, 318 insertions(+), 1 deletion(-) diff --git a/packages/wasm-utxo/src/zcash/v6.rs b/packages/wasm-utxo/src/zcash/v6.rs index fda3830ae00..416fff46b9c 100644 --- a/packages/wasm-utxo/src/zcash/v6.rs +++ b/packages/wasm-utxo/src/zcash/v6.rs @@ -124,8 +124,19 @@ pub const ZTXID_OUTPUTS_PERSONAL: &[u8; 16] = b"ZTxIdOutputsHash"; /// ZIP-244 personalization for the sapling digest. pub const ZTXID_SAPLING_PERSONAL: &[u8; 16] = b"ZTxIdSaplingHash"; /// ZIP-244 outer txid personalization prefix; the 4-byte consensus branch id -/// (little-endian) is appended to form the full 16-byte personalization. +/// (little-endian) is appended to form the full 16-byte personalization. Shared by the +/// txid digest and the signature (SIGHASH) digest. pub const ZCASH_TXID_PERSONAL_PREFIX: &[u8; 12] = b"ZcashTxHash_"; +/// ZIP-244 §S.2b personalization for the transparent input-amounts sig sub-digest. +pub const ZTXID_AMOUNTS_SIG_PERSONAL: &[u8; 16] = b"ZTxTrAmountsHash"; +/// ZIP-244 §S.2c personalization for the transparent scriptPubKeys sig sub-digest. +pub const ZTXID_SCRIPTS_SIG_PERSONAL: &[u8; 16] = b"ZTxTrScriptsHash"; +/// ZIP-244 §S.2g personalization for the per-input (txin) sig sub-digest. Hashed over the +/// empty string for a shielded signature; over the signed input's fields for a transparent one. +pub const ZTXID_TXIN_SIG_PERSONAL: &[u8; 16] = b"Zcash___TxInHash"; +/// The `SIGHASH_ALL` hash type byte. Shielded signatures always use `SIGHASH_ALL`, and the +/// BitGo transparent flows sign with `SIGHASH_ALL` as well. +const SIGHASH_ALL: u8 = 0x01; /// Boundary between the compact note plaintext and the memo inside `encCiphertext`. const ENC_COMPACT_END: usize = 52; @@ -388,6 +399,154 @@ pub fn compute_v6_txid_from_bytes(bytes: &[u8]) -> Result<[u8; 32], ZcashV6Error Ok(compute_v6_txid(&tx)) } +/// ZIP-244 §S.2 `transparent_sig_digest`, parameterized by the per-input (`txin`) sub-digest. +/// +/// The shielded and per-input transparent signature digests differ *only* in the `txin` +/// component: empty for a shielded signature, populated for the transparent input being signed. +/// Every other sub-digest (prevouts / amounts / scriptPubKeys / sequences / outputs) is identical +/// under `SIGHASH_ALL`, so both callers share this builder. `input_amounts` and +/// `input_script_pubkeys` are the spent outputs' values and scriptPubKeys, one per transparent +/// input in input order. +fn transparent_sig_digest_with_txin( + tx: &ZcashV6Transaction, + input_amounts: &[i64], + input_script_pubkeys: &[miniscript::bitcoin::ScriptBuf], + txin_sig_hash: [u8; 32], +) -> [u8; 32] { + let inputs = &tx.transparent.input; + // Per ZIP-244, with no transparent inputs the sig transparent digest equals the txid one. + if inputs.is_empty() { + return transparent_txid_digest(tx); + } + + // S.2a prevouts / S.2d sequences / S.2e outputs: identical to the txid sub-digests under + // SIGHASH_ALL (plain concatenation, no CompactSize count). + let mut prevouts = Vec::new(); + let mut sequences = Vec::new(); + for txin in inputs { + txin.previous_output + .consensus_encode(&mut prevouts) + .expect("vec write is infallible"); + txin.sequence + .consensus_encode(&mut sequences) + .expect("vec write is infallible"); + } + let mut outputs_data = Vec::new(); + for txout in &tx.transparent.output { + txout + .consensus_encode(&mut outputs_data) + .expect("vec write is infallible"); + } + let prevouts_hash = blake2b_256_personal(&prevouts, ZTXID_PREVOUTS_PERSONAL); + let sequence_hash = blake2b_256_personal(&sequences, ZTXID_SEQUENCE_PERSONAL); + let outputs_hash = blake2b_256_personal(&outputs_data, ZTXID_OUTPUTS_PERSONAL); + + // S.2b amounts / S.2c scriptPubKeys: `zcash_encoding::Array` encoding — each element written + // back-to-back with NO outer count. Amounts are 8-byte signed LE; scriptPubKeys use their + // standard (individually length-prefixed) consensus encoding. + let mut amounts_data = Vec::with_capacity(input_amounts.len() * 8); + for amount in input_amounts { + amounts_data.extend_from_slice(&amount.to_le_bytes()); + } + let mut scripts_data = Vec::new(); + for script in input_script_pubkeys { + script + .consensus_encode(&mut scripts_data) + .expect("vec write is infallible"); + } + let amounts_hash = blake2b_256_personal(&amounts_data, ZTXID_AMOUNTS_SIG_PERSONAL); + let scripts_hash = blake2b_256_personal(&scripts_data, ZTXID_SCRIPTS_SIG_PERSONAL); + + let mut data = Vec::with_capacity(1 + 32 * 6); + data.push(SIGHASH_ALL); + data.extend_from_slice(&prevouts_hash); + data.extend_from_slice(&amounts_hash); + data.extend_from_slice(&scripts_hash); + data.extend_from_slice(&sequence_hash); + data.extend_from_slice(&outputs_hash); + data.extend_from_slice(&txin_sig_hash); + blake2b_256_personal(&data, ZTXID_TRANSPARENT_PERSONAL) +} + +/// Combine the five ZIP-244 component digests into the outer signature (SIGHASH) digest, using +/// the same `ZcashTxHash_`+branch-id personalization as the txid. +fn v6_sig_digest_from_transparent(tx: &ZcashV6Transaction, transparent: [u8; 32]) -> [u8; 32] { + let header = header_digest(tx); + let sapling = blake2b_256_personal(&[], ZTXID_SAPLING_PERSONAL); + let orchard = orchard_v6_empty_digest(); + let ironwood = ironwood_digest(tx.ironwood_bundle.as_ref()); + + let mut data = Vec::with_capacity(32 * 5); + data.extend_from_slice(&header); + data.extend_from_slice(&transparent); + data.extend_from_slice(&sapling); + data.extend_from_slice(&orchard); + data.extend_from_slice(&ironwood); + + let mut personal = [0u8; 16]; + personal[..12].copy_from_slice(ZCASH_TXID_PERSONAL_PREFIX); + personal[12..].copy_from_slice(&tx.consensus_branch_id.to_le_bytes()); + blake2b_256_personal(&data, &personal) +} + +/// ZIP-244 v6 **shielded** signature hash (SIGHASH_ALL) — the message the Ironwood binding +/// signature (and any shielded spend-auth signature) signs. +/// +/// `input_amounts` / `input_script_pubkeys` describe the spent outputs, one per transparent input +/// in input order. Result is a 32-byte digest in internal order (not a txid; not reversed). +pub fn compute_v6_sig_digest( + tx: &ZcashV6Transaction, + input_amounts: &[i64], + input_script_pubkeys: &[miniscript::bitcoin::ScriptBuf], +) -> [u8; 32] { + // Shielded signature: the per-input (txin) sub-digest is the empty personalized hash. + let txin_sig_hash = blake2b_256_personal(&[], ZTXID_TXIN_SIG_PERSONAL); + let transparent = + transparent_sig_digest_with_txin(tx, input_amounts, input_script_pubkeys, txin_sig_hash); + v6_sig_digest_from_transparent(tx, transparent) +} + +/// ZIP-244 v6 **transparent** per-input signature hash (SIGHASH_ALL) — the message the key +/// controlling transparent input `input_index` signs. +/// +/// `script_code` is the script being signed for that input (its prevout scriptPubKey for P2PKH, +/// or the redeem/witness script for P2SH/P2WSH). `input_amounts` / `input_script_pubkeys` are the +/// spent outputs' values and scriptPubKeys for every input, in input order. +pub fn compute_v6_transparent_sighash( + tx: &ZcashV6Transaction, + input_index: usize, + script_code: &miniscript::bitcoin::Script, + input_amounts: &[i64], + input_script_pubkeys: &[miniscript::bitcoin::ScriptBuf], +) -> Result<[u8; 32], ZcashV6Error> { + let txin = tx + .transparent + .input + .get(input_index) + .ok_or(ZcashV6Error::UnexpectedEof)?; + let amount = *input_amounts + .get(input_index) + .ok_or(ZcashV6Error::UnexpectedEof)?; + + // S.2g: prevout ‖ value(8, signed LE) ‖ scriptCode(length-prefixed) ‖ nSequence(4, LE). + let mut txin_data = Vec::new(); + txin.previous_output + .consensus_encode(&mut txin_data) + .expect("vec write is infallible"); + txin_data.extend_from_slice(&amount.to_le_bytes()); + script_code + .consensus_encode(&mut txin_data) + .expect("vec write is infallible"); + txin.sequence + .consensus_encode(&mut txin_data) + .expect("vec write is infallible"); + let txin_sig_hash = blake2b_256_personal(&txin_data, ZTXID_TXIN_SIG_PERSONAL); + + let transparent = + transparent_sig_digest_with_txin(tx, input_amounts, input_script_pubkeys, txin_sig_hash); + Ok(v6_sig_digest_from_transparent(tx, transparent)) +} + /// A fully parsed Zcash v6 (Ironwood) transaction. #[derive(Debug, Clone, PartialEq, Eq)] pub struct ZcashV6Transaction { @@ -809,6 +968,164 @@ mod tests { } } + fn sample_tx_with_inputs() -> ZcashV6Transaction { + ZcashV6Transaction { + version_group_id: ZCASH_IRONWOOD_VERSION_GROUP_ID, + consensus_branch_id: 0x37a5165b, + transparent: sample_transparent(true), + expiry_height: 0, + sapling_value_balance: 0, + ironwood_bundle: Some(sample_bundle()), + } + } + + // ---- v6 signature-digest regression tests ---- + // (The full independent oracle — zebra recomputing these digests and verifying the binding + // signature over them — runs in the ironwood_build build→prove→combine test, where a real + // bundle with known input amounts/scripts exists.) + + #[test] + fn sig_digest_is_deterministic() { + let tx = sample_tx_with_inputs(); + let amounts = [12_345i64]; + let scripts = [ScriptBuf::from(vec![0x76u8, 0xa9, 0x14])]; + assert_eq!( + compute_v6_sig_digest(&tx, &amounts, &scripts), + compute_v6_sig_digest(&tx, &amounts, &scripts) + ); + } + + #[test] + fn shielded_sig_digest_commits_to_amounts_and_scripts() { + let tx = sample_tx_with_inputs(); + let base = compute_v6_sig_digest(&tx, &[12_345], &[ScriptBuf::from(vec![0x76u8, 0xa9])]); + // Differs from the txid (which does not commit to input amounts/scripts). + assert_ne!(base, compute_v6_txid(&tx)); + assert_ne!( + base, + compute_v6_sig_digest(&tx, &[99_999], &[ScriptBuf::from(vec![0x76u8, 0xa9])]) + ); + assert_ne!( + base, + compute_v6_sig_digest(&tx, &[12_345], &[ScriptBuf::from(vec![0x51u8])]) + ); + } + + #[test] + fn shielded_sig_digest_without_inputs_equals_txid() { + let tx = ZcashV6Transaction { + version_group_id: ZCASH_IRONWOOD_VERSION_GROUP_ID, + consensus_branch_id: 0x37a5165b, + transparent: sample_transparent(false), + expiry_height: 0, + sapling_value_balance: 0, + ironwood_bundle: Some(sample_bundle()), + }; + assert_eq!(compute_v6_sig_digest(&tx, &[], &[]), compute_v6_txid(&tx)); + } + + #[test] + fn transparent_sighash_differs_from_shielded_and_varies_by_input() { + let tx = sample_tx_with_inputs(); + let amounts = [12_345i64]; + let scripts = [ScriptBuf::from(vec![0x76u8, 0xa9, 0x14])]; + let script_code = ScriptBuf::from(vec![0x76u8, 0xa9, 0x14, 0x88, 0xac]); + + let shielded = compute_v6_sig_digest(&tx, &amounts, &scripts); + let transparent = + compute_v6_transparent_sighash(&tx, 0, script_code.as_script(), &amounts, &scripts) + .unwrap(); + // The per-input transparent sighash populates the txin component, so it must differ from + // the shielded (empty-txin) digest over the same tx. + assert_ne!(shielded, transparent); + // Deterministic. + assert_eq!( + transparent, + compute_v6_transparent_sighash(&tx, 0, script_code.as_script(), &amounts, &scripts) + .unwrap() + ); + // A different script_code changes the digest. + let other_code = ScriptBuf::from(vec![0x51u8]); + assert_ne!( + transparent, + compute_v6_transparent_sighash(&tx, 0, other_code.as_script(), &amounts, &scripts) + .unwrap() + ); + // Out-of-range input index is an error, not a panic. + assert!(compute_v6_transparent_sighash( + &tx, + 9, + script_code.as_script(), + &amounts, + &scripts + ) + .is_err()); + } + + /// Golden oracle for [`compute_v6_transparent_sighash`]: verify the **real** ECDSA signature + /// carried in a signed transparent→Ironwood testnet tx against the sighash we compute. + /// + /// The tx is the `v6_shield1zec` fixture (one P2PKH transparent input → 1 ZEC Ironwood note). + /// The spent output's value and scriptPubKey are not on the wire but are committed by ZIP-244; + /// they come from the sandbox reference that produced this tx (prevout + /// `058886a9…:0`, 313_990_000 zat, standard P2PKH). If the transaction's own signature verifies + /// against our digest, the digest is byte-correct — an independent check, since the signature + /// was produced by an external signer, not by this code. + #[test] + fn golden_transparent_sighash_verifies_real_signature() { + use miniscript::bitcoin::script::Instruction; + use miniscript::bitcoin::secp256k1::{ecdsa::Signature, Message, PublicKey, Secp256k1}; + + let raw = hex::decode(load_zcash_fixture("v6_shield1zec_rawtx.hex").trim()).unwrap(); + let tx = decode_v6_transaction(&raw).unwrap(); + assert_eq!(tx.transparent.input.len(), 1); + assert_eq!(tx.transparent.output.len(), 1); + + // Spent output (from the reference that built this tx); ZIP-244 commits to both. + let prevout_value: i64 = 313_990_000; + let prevout_script = ScriptBuf::from( + hex::decode("76a9147c6b843a25873c036aff575516e3802bcc47f63488ac").unwrap(), + ); + + // P2PKH scriptSig = . The scriptCode signed for a P2PKH + // input is its scriptPubKey. + let pushes: Vec> = tx.transparent.input[0] + .script_sig + .instructions() + .map(|i| i.expect("valid scriptSig")) + .filter_map(|i| match i { + Instruction::PushBytes(pb) => Some(pb.as_bytes().to_vec()), + Instruction::Op(_) => None, + }) + .collect(); + assert_eq!(pushes.len(), 2, "P2PKH scriptSig has sig + pubkey"); + let sig_bytes = &pushes[0]; + let pubkey_bytes = &pushes[1]; + assert_eq!( + *sig_bytes.last().unwrap(), + SIGHASH_ALL, + "signature uses SIGHASH_ALL" + ); + let der = &sig_bytes[..sig_bytes.len() - 1]; + + let sighash = compute_v6_transparent_sighash( + &tx, + 0, + prevout_script.as_script(), + &[prevout_value], + std::slice::from_ref(&prevout_script), + ) + .unwrap(); + + let secp = Secp256k1::verification_only(); + let msg = Message::from_digest(sighash); + let mut sig = Signature::from_der(der).expect("DER signature"); + sig.normalize_s(); + let pk = PublicKey::from_slice(pubkey_bytes).expect("valid pubkey"); + secp.verify_ecdsa(&msg, &sig, &pk) + .expect("the tx's real signature verifies against compute_v6_transparent_sighash"); + } + #[test] fn digest_is_deterministic() { let b = sample_bundle(); From 3548583b7702798104e000e946e1ebef1192c9f2 Mon Sep 17 00:00:00 2001 From: Veetrag Jain Date: Tue, 28 Jul 2026 11:47:17 +0530 Subject: [PATCH 2/5] feat(wasm-utxo): add orchard (no-circuit) dep + Ironwood PCZT serialization MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduce the `orchard` crate WITHOUT the `circuit` feature (Sinsemilla / pasta / note-encryption only — no halo2 prover) plus `rand`, `postcard`, and `ff`, and add the `zcash::ironwood_pczt` module that (de)serializes an `orchard::pczt::Bundle`. `orchard::pczt::Bundle` is not serde-serializable, so this bridges it to a compact wire form: a 1-byte format version + a `postcard`-encoded mirror struct whose fields are exactly what orchard's `Bundle/Action/Spend/ Output::parse(...)` consume. Serialize reads the bundle's public getters; deserialize reconstructs via `parse(...)`. This is the witness payload exchanged with the external Ironwood proof service and carried through the PSBT (proving is delegated; no halo2 in wasm-utxo). Notes: - `>32`-byte fields are stored as `Vec` (serde derives arrays only up to length 32) and length-checked on the way back in. - `note_version` is derived from the bundle version rather than carried on the wire; `zip32_derivation` (wallet metadata the prover doesn't need) is dropped. - `serde` is now a non-optional dependency (the module derives Serialize/Deserialize); the `inspect` feature drops its `dep:serde`. Verified: compiles for wasm32-unknown-unknown and native; a build -> serialize -> deserialize -> re-serialize round-trip is byte-stable. Co-Authored-By: Claude Opus 4.8 --- packages/wasm-utxo/Cargo.lock | 112 ++++- packages/wasm-utxo/Cargo.toml | 18 +- packages/wasm-utxo/src/zcash/ironwood_pczt.rs | 431 ++++++++++++++++++ packages/wasm-utxo/src/zcash/mod.rs | 2 + 4 files changed, 555 insertions(+), 8 deletions(-) create mode 100644 packages/wasm-utxo/src/zcash/ironwood_pczt.rs diff --git a/packages/wasm-utxo/Cargo.lock b/packages/wasm-utxo/Cargo.lock index 09213c85afe..a797dffeb30 100644 --- a/packages/wasm-utxo/Cargo.lock +++ b/packages/wasm-utxo/Cargo.lock @@ -144,6 +144,15 @@ dependencies = [ "syn", ] +[[package]] +name = "atomic-polyfill" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8cf2bce30dfe09ef0bfaef228b9d414faaf7e563035494d7fe092dba54b300f4" +dependencies = [ + "critical-section", +] + [[package]] name = "autocfg" version = "1.5.0" @@ -524,6 +533,15 @@ version = "0.7.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a1d728cc89cf3aee9ff92b05e62b19ee65a02b5702cff7d5a377e32c6ae29d8d" +[[package]] +name = "cobs" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fa961b519f0b462e3a3b4a34b64d119eeaca1d59af726fe450bbba07a9fc0a1" +dependencies = [ + "thiserror 2.0.17", +] + [[package]] name = "colorchoice" version = "1.0.4" @@ -641,6 +659,12 @@ dependencies = [ "libc", ] +[[package]] +name = "critical-section" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "790eea4361631c5e7d22598ecd5723ff611904e3344ce8720784c93e3d83d40b" + [[package]] name = "crossbeam-channel" version = "0.5.15" @@ -868,7 +892,7 @@ dependencies = [ "libc", "option-ext", "redox_users 0.5.2", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -960,6 +984,18 @@ dependencies = [ "zeroize", ] +[[package]] +name = "embedded-io" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef1a6892d9eef45c8fa6b9e0086428a2cca8491aca8f787c534a3d6d0bcb3ced" + +[[package]] +name = "embedded-io" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edd0f118536f44f5ccd48bcb8b111bdc3de888b58c74639dfb034a357d0f206d" + [[package]] name = "encoding_rs" version = "0.8.35" @@ -993,7 +1029,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -1299,6 +1335,15 @@ dependencies = [ "tracing", ] +[[package]] +name = "hash32" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b0c35f58762feb77d74ebe43bdbc3210f09be9fe6742234d573bacc26ed92b67" +dependencies = [ + "byteorder", +] + [[package]] name = "hashbrown" version = "0.12.3" @@ -1335,6 +1380,20 @@ dependencies = [ "hashbrown 0.14.5", ] +[[package]] +name = "heapless" +version = "0.7.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdc6457c0eb62c71aac4bc17216026d8410337c4126773b9c5daba343f17964f" +dependencies = [ + "atomic-polyfill", + "hash32", + "rustc_version", + "serde", + "spin", + "stable_deref_trait", +] + [[package]] name = "heck" version = "0.5.0" @@ -1618,6 +1677,15 @@ version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "11d3d7f243d5c5a8b9bb5d6dd2b1602c0cb0b9db1621bafc7ed66e35ff9fe092" +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + [[package]] name = "log" version = "0.4.29" @@ -1709,7 +1777,7 @@ version = "0.50.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" dependencies = [ - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -2001,6 +2069,19 @@ dependencies = [ "universal-hash", ] +[[package]] +name = "postcard" +version = "1.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6764c3b5dd454e283a30e6dfe78e9b31096d9e32036b5d1eaac7a6119ccb9a24" +dependencies = [ + "cobs", + "embedded-io 0.4.0", + "embedded-io 0.6.1", + "heapless", + "serde", +] + [[package]] name = "powerfmt" version = "0.2.0" @@ -2333,7 +2414,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -2427,6 +2508,12 @@ dependencies = [ "syn", ] +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + [[package]] name = "sec1" version = "0.7.3" @@ -2662,6 +2749,9 @@ name = "spin" version = "0.9.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6980e8d7511241f8acf4aebddbb1ff938df5eebe98691418c4468d0b72a96a67" +dependencies = [ + "lock_api", +] [[package]] name = "spki" @@ -2673,6 +2763,12 @@ dependencies = [ "der", ] +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + [[package]] name = "static_assertions" version = "1.1.0" @@ -2739,7 +2835,7 @@ dependencies = [ "getrandom 0.3.4", "once_cell", "rustix", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -3166,13 +3262,17 @@ dependencies = [ "bech32", "blake2", "crossbeam-epoch", + "ff", "getrandom 0.2.16", "hex", "js-sys", "miniscript", "musig2", "num-bigint", + "orchard", "pastey", + "postcard", + "rand", "rstest", "serde", "serde_json", @@ -3214,7 +3314,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.48.0", + "windows-sys 0.61.2", ] [[package]] diff --git a/packages/wasm-utxo/Cargo.toml b/packages/wasm-utxo/Cargo.toml index efe766e9b0c..b5e5a1e8756 100644 --- a/packages/wasm-utxo/Cargo.toml +++ b/packages/wasm-utxo/Cargo.toml @@ -22,7 +22,7 @@ unexpected_cfgs = { level = "warn", check-cfg = [ [features] default = [] -inspect = ["dep:num-bigint", "dep:serde", "dep:serde_json", "dep:hex"] +inspect = ["dep:num-bigint", "dep:serde_json", "dep:hex"] [dependencies] wasm-bindgen = "0.2" @@ -37,10 +37,24 @@ musig2 = { version = "0.3.1", default-features = false, features = ["k256"] } getrandom = { version = "0.2", features = ["js"] } pastey = "0.1" num-bigint = { version = "0.4", optional = true } -serde = { version = "1.0", features = ["derive"], optional = true } +# serde is non-optional: the Ironwood PCZT serialization (always compiled) derives Serialize/ +# Deserialize for its postcard mirror struct. +serde = { version = "1.0", features = ["derive"] } serde_json = { version = "1.0", optional = true } hex = { version = "0.4", optional = true } +# Orchard/Ironwood shielded-bundle construction (PCZT Constructor/Signer/Extractor). +# `default-features = false` DROPS the `circuit` feature, so halo2_proofs/halo2_gadgets +# (the heavy prover) are NOT pulled in — only Sinsemilla/pasta/note-encryption. Proving is +# delegated to an external service. +orchard = { version = "0.15", default-features = false, features = ["std"] } +rand = "0.8" +# Compact deterministic encoding of the Ironwood PCZT witness (proof-service payload / PSBT carry). +postcard = { version = "1.0", features = ["use-std"] } +# `ff::PrimeField::to_repr` for the one PCZT witness field (`alpha`, a Pallas scalar) that orchard +# exposes only as a curve scalar. Already in-tree via orchard/pasta_curves. +ff = "0.13" + # Pinned to avoid RUSTSEC-2026-0204 (invalid pointer dereference in fmt::Pointer) crossbeam-epoch = ">=0.9.20" diff --git a/packages/wasm-utxo/src/zcash/ironwood_pczt.rs b/packages/wasm-utxo/src/zcash/ironwood_pczt.rs new file mode 100644 index 00000000000..235bd1e6e30 --- /dev/null +++ b/packages/wasm-utxo/src/zcash/ironwood_pczt.rs @@ -0,0 +1,431 @@ +//! Serialization of an `orchard` **PCZT** (Partially Constructed Zcash Transaction) Ironwood +//! bundle — the witness payload exchanged with the external Ironwood proof service and carried +//! through the PSBT. +//! +//! `orchard::pczt::Bundle` is not itself serde-serializable, so this module bridges it to a +//! compact wire form: a 1-byte [`FORMAT_VERSION`] followed by a [`postcard`]-encoded *mirror* +//! struct whose fields are exactly what `orchard`'s `Bundle/Action/Spend/Output::parse(...)` +//! consume. Serialize reads the bundle's public getters; deserialize reconstructs it via +//! `parse(...)`. Both `wasm-utxo` (build/combine) and the proof service (prove) use this format; +//! see `docs/ironwood-proof-service-contract.md`. +//! +//! Fields orchard exposes only as `>32`-byte arrays are stored as `Vec` (serde derives arrays +//! only up to length 32) and length-checked on the way back in. `zip32_derivation` is not a +//! `parse()` input and is intentionally dropped — the prover does not need it. + +use std::collections::BTreeMap; + +use ff::PrimeField; +use serde::{Deserialize, Serialize}; + +use orchard::bundle::BundleVersion; +use orchard::note::{NoteVersion, Nullifier}; +use orchard::pczt::{ + Action as PcztAction, Bundle as PcztBundle, Output as PcztOutput, Spend as PcztSpend, +}; +use orchard::value::Sign; +use orchard::{ProtocolVersion, ValuePool}; + +/// Wire format version; bump on any layout change (deserialize rejects unknown versions). +pub const FORMAT_VERSION: u8 = 0x01; + +/// Errors from Ironwood PCZT (de)serialization. +#[derive(Debug, strum::IntoStaticStr)] +pub enum IronwoodPcztError { + /// The `postcard` payload could not be encoded/decoded. + Codec(String), + /// Unknown/unsupported [`FORMAT_VERSION`] byte. + UnsupportedVersion(u8), + /// Empty input (no version byte). + Empty, + /// A byte field had the wrong length: (field, expected, actual). + BadLength(&'static str, usize, usize), + /// An unrepresentable (value_pool, protocol_version) combination. + BadBundleVersion(u8, u8), + /// A field that must decode to a valid curve/commitment value did not. + BadFieldEncoding(&'static str), + /// orchard rejected the reconstructed bundle. + Parse(String), +} + +impl core::fmt::Display for IronwoodPcztError { + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { + match self { + Self::Codec(e) => write!(f, "ironwood-pczt: postcard codec error: {e}"), + Self::UnsupportedVersion(v) => { + write!(f, "ironwood-pczt: unsupported format version {v}") + } + Self::Empty => write!(f, "ironwood-pczt: empty input"), + Self::BadLength(field, exp, act) => { + write!( + f, + "ironwood-pczt: field {field} must be {exp} bytes, got {act}" + ) + } + Self::BadBundleVersion(vp, pv) => { + write!( + f, + "ironwood-pczt: unrepresentable bundle version (pool {vp}, protocol {pv})" + ) + } + Self::BadFieldEncoding(field) => { + write!(f, "ironwood-pczt: invalid encoding for {field}") + } + Self::Parse(e) => write!(f, "ironwood-pczt: orchard rejected the bundle: {e}"), + } + } +} + +crate::impl_wasm_error_code!(IronwoodPcztError); + +// ---- Mirror structs (serde ⇄ postcard) ---- + +#[derive(Serialize, Deserialize, PartialEq, Debug, Clone)] +struct BundleWire { + flags: u8, + value_pool: u8, // 0 = Orchard, 1 = Ironwood + protocol_version: u8, // 0 = InsecureV1, 1 = V2, 2 = V3 + value_sum_magnitude: u64, + value_sum_negative: bool, + anchor: [u8; 32], + zkproof: Option>, + bsk: Option<[u8; 32]>, + actions: Vec, +} + +#[derive(Serialize, Deserialize, PartialEq, Debug, Clone)] +struct ActionWire { + cv_net: [u8; 32], + rcv: Option<[u8; 32]>, + spend: SpendWire, + output: OutputWire, +} + +#[derive(Serialize, Deserialize, PartialEq, Debug, Clone)] +struct SpendWire { + nullifier: [u8; 32], + rk: [u8; 32], + spend_auth_sig: Option>, // 64 + recipient: Option>, // 43 + value: Option, + rho: Option<[u8; 32]>, + rseed: Option<[u8; 32]>, + fvk: Option>, // 96 + witness: Option, + alpha: Option<[u8; 32]>, + dummy_sk: Option<[u8; 32]>, + proprietary: BTreeMap>, +} + +#[derive(Serialize, Deserialize, PartialEq, Debug, Clone)] +struct WitnessWire { + position: u32, + auth_path: Vec<[u8; 32]>, +} + +#[derive(Serialize, Deserialize, PartialEq, Debug, Clone)] +struct OutputWire { + cmx: [u8; 32], + ephemeral_key: [u8; 32], + enc_ciphertext: Vec, // 580 + out_ciphertext: Vec, // 80 + recipient: Option>, + value: Option, + rseed: Option<[u8; 32]>, + ock: Option<[u8; 32]>, + user_address: Option, + proprietary: BTreeMap>, +} + +// ---- Public API ---- + +/// Serialize an `orchard` PCZT Ironwood bundle to its wire form. +pub fn serialize_pczt(bundle: &PcztBundle) -> Result, IronwoodPcztError> { + let wire = bundle_to_wire(bundle); + let mut out = Vec::with_capacity(1 + 4096); + out.push(FORMAT_VERSION); + let body = postcard::to_stdvec(&wire).map_err(|e| IronwoodPcztError::Codec(e.to_string()))?; + out.extend_from_slice(&body); + Ok(out) +} + +/// Reconstruct an `orchard` PCZT Ironwood bundle from its wire form. +pub fn deserialize_pczt(bytes: &[u8]) -> Result { + let (&version, body) = bytes.split_first().ok_or(IronwoodPcztError::Empty)?; + if version != FORMAT_VERSION { + return Err(IronwoodPcztError::UnsupportedVersion(version)); + } + let wire: BundleWire = + postcard::from_bytes(body).map_err(|e| IronwoodPcztError::Codec(e.to_string()))?; + wire_to_bundle(wire) +} + +// ---- orchard bundle -> wire ---- + +fn bundle_to_wire(bundle: &PcztBundle) -> BundleWire { + let (magnitude, sign) = bundle.value_sum().magnitude_sign(); + BundleWire { + flags: bundle.flag_byte(), + value_pool: match bundle.bundle_version().value_pool() { + ValuePool::Orchard => 0, + ValuePool::Ironwood => 1, + }, + protocol_version: match bundle.bundle_version().protocol_version() { + ProtocolVersion::InsecureV1 => 0, + ProtocolVersion::V2 => 1, + ProtocolVersion::V3 => 2, + }, + value_sum_magnitude: magnitude, + value_sum_negative: matches!(sign, Sign::Negative), + anchor: bundle.anchor().to_bytes(), + zkproof: bundle.zkproof().as_ref().map(|p| p.as_ref().to_vec()), + bsk: bundle.bsk().as_ref().map(<[u8; 32]>::from), + actions: bundle.actions().iter().map(action_to_wire).collect(), + } +} + +fn action_to_wire(a: &PcztAction) -> ActionWire { + ActionWire { + cv_net: a.cv_net().to_bytes(), + rcv: a.rcv().as_ref().map(|r| r.to_bytes()), + spend: spend_to_wire(a.spend()), + output: output_to_wire(a.output()), + } +} + +fn spend_to_wire(s: &PcztSpend) -> SpendWire { + SpendWire { + nullifier: s.nullifier().to_bytes(), + rk: <[u8; 32]>::from(s.rk()), + spend_auth_sig: s + .spend_auth_sig() + .as_ref() + .map(|sig| <[u8; 64]>::from(sig).to_vec()), + recipient: s + .recipient() + .as_ref() + .map(|a| a.to_raw_address_bytes().to_vec()), + value: s.value().as_ref().map(|v| v.inner()), + rho: s.rho().as_ref().map(|r| r.to_bytes()), + rseed: s.rseed().as_ref().map(|r| *r.as_bytes()), + fvk: s.fvk().as_ref().map(|f| f.to_bytes().to_vec()), + witness: s.witness().as_ref().map(|w| WitnessWire { + position: w.position(), + auth_path: w.auth_path().iter().map(|h| h.to_bytes()).collect(), + }), + alpha: s.alpha().as_ref().map(|a| a.to_repr()), + dummy_sk: s.dummy_sk().as_ref().map(|k| *k.to_bytes()), + proprietary: s.proprietary().clone(), + } +} + +fn output_to_wire(o: &PcztOutput) -> OutputWire { + let enc = o.encrypted_note(); + OutputWire { + cmx: o.cmx().to_bytes(), + ephemeral_key: enc.epk_bytes, + enc_ciphertext: enc.enc_ciphertext.to_vec(), + out_ciphertext: enc.out_ciphertext.to_vec(), + recipient: o + .recipient() + .as_ref() + .map(|a| a.to_raw_address_bytes().to_vec()), + value: o.value().as_ref().map(|v| v.inner()), + rseed: o.rseed().as_ref().map(|r| *r.as_bytes()), + ock: o.ock().as_ref().map(|k| k.0), + user_address: o.user_address().clone(), + proprietary: o.proprietary().clone(), + } +} + +// ---- wire -> orchard bundle ---- + +fn arr(v: &[u8], field: &'static str) -> Result<[u8; N], IronwoodPcztError> { + v.try_into() + .map_err(|_| IronwoodPcztError::BadLength(field, N, v.len())) +} + +fn wire_to_bundle(w: BundleWire) -> Result { + let bundle_version = match (w.value_pool, w.protocol_version) { + (0, 0) => BundleVersion::orchard_insecure_v1(), + (0, 1) => BundleVersion::orchard_v2(), + (0, 2) => BundleVersion::orchard_v3(), + (1, 2) => BundleVersion::ironwood_v3(), + (vp, pv) => return Err(IronwoodPcztError::BadBundleVersion(vp, pv)), + }; + // The per-note version is fixed by the bundle version (Orchard→V2, Ironwood→V3); it is not + // carried on the wire. + let note_version = bundle_version.note_version(); + + let actions = w + .actions + .into_iter() + .map(|a| wire_to_action(a, note_version)) + .collect::, _>>()?; + + PcztBundle::parse( + actions, + w.flags, + bundle_version, + (w.value_sum_magnitude, w.value_sum_negative), + w.anchor, + w.zkproof, + w.bsk, + ) + .map_err(|e| IronwoodPcztError::Parse(format!("{e:?}"))) +} + +fn wire_to_action( + a: ActionWire, + note_version: NoteVersion, +) -> Result { + // The output note's rho is the paired spend's nullifier. + let spend_nullifier = Option::::from(Nullifier::from_bytes(&a.spend.nullifier)) + .ok_or(IronwoodPcztError::BadFieldEncoding("nullifier"))?; + let spend = wire_to_spend(a.spend, note_version)?; + let output = wire_to_output(a.output, spend_nullifier, note_version)?; + PcztAction::parse(a.cv_net, spend, output, a.rcv) + .map_err(|e| IronwoodPcztError::Parse(format!("{e:?}"))) +} + +fn wire_to_spend(s: SpendWire, note_version: NoteVersion) -> Result { + let spend_auth_sig = s + .spend_auth_sig + .as_deref() + .map(|v| arr::<64>(v, "spend_auth_sig")) + .transpose()?; + let recipient = s + .recipient + .as_deref() + .map(|v| arr::<43>(v, "spend.recipient")) + .transpose()?; + let fvk = s.fvk.as_deref().map(|v| arr::<96>(v, "fvk")).transpose()?; + let witness = s + .witness + .map(|w| -> Result<_, IronwoodPcztError> { + let path: [[u8; 32]; 32] = w + .auth_path + .try_into() + .map_err(|_| IronwoodPcztError::BadFieldEncoding("witness.auth_path"))?; + Ok((w.position, path)) + }) + .transpose()?; + + PcztSpend::parse( + s.nullifier, + s.rk, + spend_auth_sig, + recipient, + s.value, + s.rho, + s.rseed, + fvk, + witness, + s.alpha, + None, // zip32_derivation: wallet metadata, not needed by the prover + s.dummy_sk, + note_version, + s.proprietary, + ) + .map_err(|e| IronwoodPcztError::Parse(format!("{e:?}"))) +} + +fn wire_to_output( + o: OutputWire, + spend_nullifier: Nullifier, + note_version: NoteVersion, +) -> Result { + let recipient = o + .recipient + .as_deref() + .map(|v| arr::<43>(v, "output.recipient")) + .transpose()?; + + PcztOutput::parse( + spend_nullifier, + o.cmx, + o.ephemeral_key, + o.enc_ciphertext, + o.out_ciphertext, + recipient, + o.value, + o.rseed, + o.ock, + None, // zip32_derivation + o.user_address, + note_version, + o.proprietary, + ) + .map_err(|e| IronwoodPcztError::Parse(format!("{e:?}"))) +} + +#[cfg(test)] +mod tests { + use super::*; + use orchard::builder::{Builder, BundleType}; + use orchard::keys::{FullViewingKey, Scope, SpendingKey}; + use orchard::tree::Anchor; + use orchard::value::NoteValue; + use rand::rngs::OsRng; + + /// Construct a shielding PCZT (one Ironwood output, dummy spend) via the orchard Constructor. + fn sample_pczt() -> PcztBundle { + let sk = Option::::from(SpendingKey::from_bytes([7u8; 32])).unwrap(); + let fvk = FullViewingKey::from(&sk); + let recipient = fvk.address_at(0u32, Scope::External); + let bundle_version = BundleVersion::ironwood_v3(); + let flags = bundle_version.default_flags(); + let mut builder = Builder::new( + BundleType::UNPADDED, + bundle_version, + flags, + Anchor::empty_tree(), + ) + .unwrap(); + builder + .add_output( + None, + recipient, + NoteValue::from_raw(100_000_000), + [0u8; 512], + ) + .unwrap(); + builder.build_for_pczt(OsRng).unwrap().0 + } + + #[test] + fn bundle_roundtrip_is_byte_stable() { + let bundle = sample_pczt(); + let bytes = serialize_pczt(&bundle).unwrap(); + assert_eq!(bytes[0], FORMAT_VERSION); + + // Reconstruct, re-serialize: the second encoding must be byte-identical, proving the + // orchard <-> wire mapping round-trips losslessly. + let bundle2 = deserialize_pczt(&bytes).unwrap(); + let bytes2 = serialize_pczt(&bundle2).unwrap(); + assert_eq!(bytes, bytes2, "serialize∘deserialize is byte-stable"); + + // And a third pass, for good measure. + let bundle3 = deserialize_pczt(&bytes2).unwrap(); + assert_eq!(serialize_pczt(&bundle3).unwrap(), bytes); + } + + #[test] + fn rejects_unknown_version() { + let bundle = sample_pczt(); + let mut bytes = serialize_pczt(&bundle).unwrap(); + bytes[0] = 0xff; + assert!(matches!( + deserialize_pczt(&bytes), + Err(IronwoodPcztError::UnsupportedVersion(0xff)) + )); + } + + #[test] + fn rejects_empty() { + assert!(matches!( + deserialize_pczt(&[]), + Err(IronwoodPcztError::Empty) + )); + } +} diff --git a/packages/wasm-utxo/src/zcash/mod.rs b/packages/wasm-utxo/src/zcash/mod.rs index 3bf672cbb0c..f61bb67c739 100644 --- a/packages/wasm-utxo/src/zcash/mod.rs +++ b/packages/wasm-utxo/src/zcash/mod.rs @@ -10,6 +10,8 @@ //! Tests verify parity with `zebra-chain` crate. pub mod blake2b; +/// Serialization of the orchard PCZT Ironwood bundle (proof-service payload / PSBT carry). +pub mod ironwood_pczt; pub mod transaction; pub mod unified_address; pub mod v6; From db73d9a35f7482a1f6453c080d83667efee9674b Mon Sep 17 00:00:00 2001 From: Veetrag Jain Date: Tue, 28 Jul 2026 12:37:33 +0530 Subject: [PATCH 3/5] feat(wasm-utxo): add Ironwood PCZT constructor/combine bridge MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add `zcash::ironwood_build`, the bridge between the `orchard` PCZT roles and the v6 `IronwoodBundle` wire type, for building transparent -> Ironwood shielding transactions without linking the halo2 prover: - `construct_shield_pczt` (Constructor): one output note + dummy spend as an `orchard::pczt::Bundle`, action data fixed, no proof/sigs. - `finalize_shield_io` (IO Finalizer/Signer): derive `bsk`, sign dummy spends over the shielded sighash. - `pczt_action_data`: effects-only view (commitments/ciphertexts/flags/value/ anchor) for computing the ZIP-244 sighash and txid. - `combine` (Transaction Extractor): given the signed+proven PCZT, verify spend-auth sigs, apply the binding signature, map to an encodable `IronwoodBundle`. The Prover role is delegated to an external service; its proof bytes are spliced into the serialized PCZT via `ironwood_pczt::with_zkproof`. Tests (all no-circuit): build->finalize->combine produces an encodable v6 tx with a stable txid; the combined tx cross-checks against zebra-chain; and a golden oracle reconstructs the orchard bundle from the real on-chain `shield1zec` action data and verifies both the real dummy spend-auth signature and the real binding signature against `compute_v6_sig_digest` — the shielded twin of PR1's transparent sighash oracle. Co-Authored-By: Claude Opus 4.8 --- packages/wasm-utxo/Cargo.lock | 1 + packages/wasm-utxo/Cargo.toml | 3 + .../wasm-utxo/src/zcash/ironwood_build.rs | 493 ++++++++++++++++++ packages/wasm-utxo/src/zcash/ironwood_pczt.rs | 59 +++ packages/wasm-utxo/src/zcash/mod.rs | 2 + 5 files changed, 558 insertions(+) create mode 100644 packages/wasm-utxo/src/zcash/ironwood_build.rs diff --git a/packages/wasm-utxo/Cargo.lock b/packages/wasm-utxo/Cargo.lock index a797dffeb30..63fda6699fd 100644 --- a/packages/wasm-utxo/Cargo.lock +++ b/packages/wasm-utxo/Cargo.lock @@ -3268,6 +3268,7 @@ dependencies = [ "js-sys", "miniscript", "musig2", + "nonempty", "num-bigint", "orchard", "pastey", diff --git a/packages/wasm-utxo/Cargo.toml b/packages/wasm-utxo/Cargo.toml index b5e5a1e8756..6c06170fae4 100644 --- a/packages/wasm-utxo/Cargo.toml +++ b/packages/wasm-utxo/Cargo.toml @@ -72,6 +72,9 @@ pastey = "0.1" # variant used as the txid oracle in the v6 codec tests. Native-only dev-dependency, so its # pre-release zcash_*/orchard cohort stays out of the shipped wasm artifact. zebra-chain = { version = "11.2", default-features = false } +# `NonEmpty` is required by `orchard::Bundle::from_parts`, used only in the ironwood_build golden +# test to reconstruct an orchard bundle from on-wire action data. Matches orchard's own version. +nonempty = "0.11" [build-dependencies] serde_json = "1.0" diff --git a/packages/wasm-utxo/src/zcash/ironwood_build.rs b/packages/wasm-utxo/src/zcash/ironwood_build.rs new file mode 100644 index 00000000000..cef5c84eef2 --- /dev/null +++ b/packages/wasm-utxo/src/zcash/ironwood_build.rs @@ -0,0 +1,493 @@ +//! Bridge between the `orchard` PCZT roles and the v6 [`IronwoodBundle`] wire type. +//! +//! For a transparent → Ironwood shielding transaction, `wasm-utxo` plays three of the four +//! `orchard` PCZT roles; the fourth (Prover) runs in an external service: +//! +//! - **Constructor** ([`construct_shield_pczt`]): builds the shielded bundle — one output note to +//! the recipient, padded with a dummy spend — as an `orchard::pczt::Bundle` with no proof or +//! signatures yet. All action data (value commitments, note commitments, ciphertexts) is fixed +//! here, and it is exactly what the ZIP-244 shielded sighash commits to. +//! - **IO Finalizer / Signer** ([`finalize_shield_io`]): derives the binding signing key (`bsk`) +//! and signs the dummy spends over the now-fixed shielded sighash. +//! - **Transaction Extractor** ([`combine`]): given the signed PCZT with the prover's `zkproof` +//! spliced in ([`super::ironwood_pczt::with_zkproof`]), verifies the spend-auth signatures, +//! applies the binding signature, and maps the authorized orchard bundle to an [`IronwoodBundle`] +//! ready for [`encode_v6_transaction`](super::v6::encode_v6_transaction). +//! +//! The **Prover** role (halo2) is intentionally absent — proving is delegated to an external +//! service so the shipped WASM never links the circuit. See +//! `docs/ironwood-proof-service-contract.md`. +//! +//! ## Why the proof can be filled in after signing +//! +//! A v6 transparent signature commits (via the ZIP-244 sighash) to the shielded *action data* +//! (`ironwood_digest`), but not to the `proof`, `anchor`, or `binding_sig` (those live in the +//! authorizing digest, which the sighash excludes). So the order is: fix action data at build, +//! compute the sighash, sign (transparent inputs + dummy spends), then prove, then combine. The +//! binding signature — applied during [`combine`] — signs the same build-time sighash and has no +//! dependency on the transparent signatures. + +use rand::{CryptoRng, RngCore}; + +use orchard::builder::{Builder, BundleType}; +use orchard::bundle::BundleVersion; +use orchard::keys::OutgoingViewingKey; +use orchard::pczt::Bundle as PcztBundle; +use orchard::tree::Anchor; +use orchard::value::NoteValue; +use orchard::{Action as OrchardAction, Address}; + +use super::v6::{IronwoodAction, IronwoodBundle}; + +/// Length of a raw Orchard/Ironwood receiver (`Address`) in bytes. +pub const ORCHARD_ADDRESS_SIZE: usize = 43; + +/// Errors produced while constructing or combining an Ironwood shielded bundle. +/// +/// The variant name is surfaced to JS as `err.code` (e.g. `"IronwoodBuildError.BadRecipient"`) +/// via [`crate::error::WasmUtxoError`]. +#[derive(Debug, strum::IntoStaticStr)] +pub enum IronwoodBuildError { + /// The recipient bytes are not a valid Orchard/Ironwood raw address. + BadRecipient, + /// The anchor bytes are not a valid Ironwood note-commitment-tree root. + BadAnchor, + /// The orchard builder rejected the bundle parameters. + Builder(String), + /// The orchard builder rejected the output. + Output(String), + /// IO finalization (bsk derivation / dummy-spend signing) failed. + Finalize(String), + /// The Transaction Extractor rejected the PCZT (missing proof/sig/bsk, or a non-canonical proof). + Extract(String), + /// The bundle had no actions (nothing to shield). + EmptyBundle, + /// The spend-auth signatures did not verify against the sighash, so the bundle could not be bound. + BindingSignatureFailed, +} + +impl core::fmt::Display for IronwoodBuildError { + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { + match self { + Self::BadRecipient => write!(f, "ironwood-build: invalid recipient address bytes"), + Self::BadAnchor => write!(f, "ironwood-build: invalid anchor bytes"), + Self::Builder(e) => write!(f, "ironwood-build: builder error: {e}"), + Self::Output(e) => write!(f, "ironwood-build: output error: {e}"), + Self::Finalize(e) => write!(f, "ironwood-build: IO finalization failed: {e}"), + Self::Extract(e) => write!(f, "ironwood-build: extractor error: {e}"), + Self::EmptyBundle => write!(f, "ironwood-build: bundle has no actions"), + Self::BindingSignatureFailed => { + write!( + f, + "ironwood-build: spend-auth signatures did not verify against the sighash" + ) + } + } + } +} + +crate::impl_wasm_error_code!(IronwoodBuildError); + +/// Constructor: build a transparent → Ironwood shielding bundle as an orchard PCZT. +/// +/// Produces a single output note of `amount` zatoshi to `recipient` (a 43-byte raw Orchard/Ironwood +/// address), paired with a dummy spend (`BundleType::UNPADDED` ⇒ exactly one action). `anchor` is +/// the current Ironwood note-commitment-tree root. `ovk`, if given, is the raw outgoing viewing key +/// used to make the output recoverable by the sender; pass `None` for a keyless build. `rng` must be +/// a CSPRNG — it seeds the note randomness (`rseed`, `rcv`) that fixes the action data. +/// +/// The returned PCZT carries no signatures or proof yet; run [`finalize_shield_io`] once the sighash +/// is known, then hand it to the prover and [`combine`]. +pub fn construct_shield_pczt( + recipient: &[u8; ORCHARD_ADDRESS_SIZE], + amount: u64, + ovk: Option<[u8; 32]>, + anchor: &[u8; 32], + memo: &[u8; 512], + rng: R, +) -> Result { + let recipient = Option::from(Address::from_raw_address_bytes(recipient)) + .ok_or(IronwoodBuildError::BadRecipient)?; + let anchor = Option::from(Anchor::from_bytes(*anchor)).ok_or(IronwoodBuildError::BadAnchor)?; + let ovk = ovk.map(OutgoingViewingKey::from); + + let bundle_version = BundleVersion::ironwood_v3(); + let flags = bundle_version.default_flags(); + let mut builder = Builder::new(BundleType::UNPADDED, bundle_version, flags, anchor) + .map_err(|e| IronwoodBuildError::Builder(e.to_string()))?; + builder + .add_output(ovk, recipient, NoteValue::from_raw(amount), *memo) + .map_err(|e| IronwoodBuildError::Output(e.to_string()))?; + let (bundle, _meta) = builder + .build_for_pczt(rng) + .map_err(|e| IronwoodBuildError::Builder(e.to_string()))?; + Ok(bundle) +} + +/// IO Finalizer / Signer: derive the binding signing key and sign the dummy spends. +/// +/// `sighash` is the ZIP-244 shielded sig digest computed over the *complete* v6 transaction +/// (transparent inputs/outputs plus this bundle's action data); it must not change afterwards, since +/// the binding signature applied in [`combine`] signs the same value. +pub fn finalize_shield_io( + bundle: &mut PcztBundle, + sighash: [u8; 32], + rng: R, +) -> Result<(), IronwoodBuildError> { + bundle + .finalize_io(sighash, rng) + .map_err(|e| IronwoodBuildError::Finalize(e.to_string())) +} + +/// Map an orchard action (in any authorization state) to the v6 wire action. +/// +/// The action-data fields are authorization-independent, so this serves both the effects-only +/// (action-data) view and the fully-authorized combine output. +fn action_to_ironwood(a: &OrchardAction) -> IronwoodAction { + let enc = a.encrypted_note(); + IronwoodAction { + cv: a.cv_net().to_bytes(), + // The action's on-wire `nullifier` field doubles as the paired output note's `rho`. + nullifier: a.nullifier().to_bytes(), + rk: <[u8; 32]>::from(a.rk()), + cmx: a.cmx().to_bytes(), + ephemeral_key: enc.epk_bytes, + enc_ciphertext: enc.enc_ciphertext, + out_ciphertext: enc.out_ciphertext, + } +} + +/// Action-data view of the PCZT: the [`IronwoodBundle`] fields the ZIP-244 sighash and txid commit +/// to (commitments, ciphertexts, flags, value balance, anchor). +/// +/// `proof`, `spend_auth_sigs`, and `binding_sig` are left empty — the result is meant for computing +/// digests, NOT for [`encode_v6_transaction`](super::v6::encode_v6_transaction) (which requires one +/// spend-auth signature per action). Use [`combine`] to produce an encodable bundle. +pub fn pczt_action_data(bundle: &PcztBundle) -> Result { + let effects = bundle + .extract_effects::() + .map_err(|e| IronwoodBuildError::Extract(e.to_string()))? + .ok_or(IronwoodBuildError::EmptyBundle)?; + Ok(IronwoodBundle { + actions: effects.actions().iter().map(action_to_ironwood).collect(), + flags: effects.flag_byte(), + value_balance: *effects.value_balance(), + anchor: effects.anchor().to_bytes(), + proof: Vec::new(), + spend_auth_sigs: Vec::new(), + binding_sig: [0u8; 64], + }) +} + +/// Transaction Extractor: turn a signed + proven PCZT into a fully-authorized [`IronwoodBundle`]. +/// +/// `bundle` must already carry every `spend_auth_sig` (from [`finalize_shield_io`]), the binding +/// signing key `bsk` (also set by [`finalize_shield_io`]), and a canonical `zkproof` (from the proof +/// service, spliced in via [`super::ironwood_pczt::with_zkproof`]). `sighash` must equal the shielded +/// sig digest used when finalizing IO: it is what the binding signature signs and what every +/// spend-auth signature is verified against. `rng` seeds the (randomized) binding signature. +pub fn combine( + bundle: &PcztBundle, + sighash: [u8; 32], + rng: R, +) -> Result { + let unbound = bundle + .extract::() + .map_err(|e| IronwoodBuildError::Extract(e.to_string()))? + .ok_or(IronwoodBuildError::EmptyBundle)?; + let authorized = unbound + .apply_binding_signature(sighash, rng) + .ok_or(IronwoodBuildError::BindingSignatureFailed)?; + + let spend_auth_sigs = authorized + .actions() + .iter() + .map(|a| <[u8; 64]>::from(a.authorization())) + .collect(); + Ok(IronwoodBundle { + actions: authorized + .actions() + .iter() + .map(action_to_ironwood) + .collect(), + flags: authorized.flag_byte(), + value_balance: *authorized.value_balance(), + anchor: authorized.anchor().to_bytes(), + proof: authorized.authorization().proof().as_ref().to_vec(), + spend_auth_sigs, + binding_sig: <[u8; 64]>::from(authorized.authorization().binding_signature()), + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::zcash::ironwood_pczt::{deserialize_pczt, serialize_pczt, with_zkproof}; + use crate::zcash::transaction::ZCASH_IRONWOOD_VERSION_GROUP_ID; + use crate::zcash::v6::{ + compute_v6_sig_digest, compute_v6_txid, decode_v6_transaction, encode_v6_transaction, + ZcashV6Transaction, + }; + use miniscript::bitcoin::hashes::Hash; + use miniscript::bitcoin::{ + Amount, OutPoint, ScriptBuf, Sequence, Transaction, TxIn, TxOut, Txid, Witness, + }; + use orchard::keys::{FullViewingKey, Scope, SpendingKey}; + use orchard::Proof; + use rand::rngs::OsRng; + + const NU6_3_BRANCH_ID: u32 = 0x37a5165b; + + /// A deterministic Ironwood receiver derived from a fixed spending key, for building test PCZTs. + fn test_recipient() -> [u8; ORCHARD_ADDRESS_SIZE] { + let sk = Option::::from(SpendingKey::from_bytes([7u8; 32])).unwrap(); + let fvk = FullViewingKey::from(&sk); + fvk.address_at(0u32, Scope::External).to_raw_address_bytes() + } + + /// A single-P2PKH-input, no-output transparent skeleton to hang the Ironwood bundle on. + fn transparent_skeleton() -> Transaction { + Transaction { + version: miniscript::bitcoin::transaction::Version::non_standard(6), + input: vec![TxIn { + // A non-zero prevout: an all-zeros hash would be read as a coinbase input. + previous_output: OutPoint::new(Txid::from_byte_array([0x11u8; 32]), 0), + script_sig: ScriptBuf::new(), + sequence: Sequence::MAX, + witness: Witness::new(), + }], + output: vec![TxOut { + value: Amount::from_sat(1_000), + script_pubkey: ScriptBuf::from(vec![0x76u8, 0xa9, 0x14]), + }], + lock_time: miniscript::bitcoin::locktime::absolute::LockTime::from_consensus(0), + } + } + + fn v6_tx(transparent: Transaction, bundle: Option) -> ZcashV6Transaction { + ZcashV6Transaction { + version_group_id: ZCASH_IRONWOOD_VERSION_GROUP_ID, + consensus_branch_id: NU6_3_BRANCH_ID, + transparent, + expiry_height: 0, + sapling_value_balance: 0, + ironwood_bundle: bundle, + } + } + + #[test] + fn construct_yields_single_action_shielding_amount() { + let amount = 100_000_000u64; + let pczt = construct_shield_pczt( + &test_recipient(), + amount, + None, + &Anchor::empty_tree().to_bytes(), + &[0u8; 512], + OsRng, + ) + .unwrap(); + let data = pczt_action_data(&pczt).unwrap(); + assert_eq!(data.actions.len(), 1, "UNPADDED single output ⇒ one action"); + // ironwood_v3 default flags = spends | outputs | cross-address. + assert_eq!(data.flags, 0x07); + // A pure output bundle (dummy spend has value 0): value_balance = 0 - amount. + assert_eq!(data.value_balance, -(amount as i64)); + } + + /// End-to-end (build → sighash → sign dummy spend → inject proof → combine), no circuit: + /// the resulting v6 tx round-trips through the codec and its txid is stable. A canonical-length + /// placeholder proof stands in for the external prover (the codec/txid never inspect proof + /// bytes, and the Extractor only length-checks them). + #[test] + fn build_finalize_combine_produces_encodable_tx() { + let amount = 100_000_000u64; + let mut pczt = construct_shield_pczt( + &test_recipient(), + amount, + None, + &Anchor::empty_tree().to_bytes(), + &[0u8; 512], + OsRng, + ) + .unwrap(); + + // Fix the transaction structure, then compute the shielded sighash it commits to. + let transparent = transparent_skeleton(); + let input_amounts = [200_000_000i64]; + let input_scripts = [ScriptBuf::from(vec![0x76u8, 0xa9, 0x14, 0x88, 0xac])]; + let tx_for_sighash = v6_tx(transparent.clone(), Some(pczt_action_data(&pczt).unwrap())); + let sighash = compute_v6_sig_digest(&tx_for_sighash, &input_amounts, &input_scripts); + + // Signer + IO finalizer (dummy spend-auth signature + bsk). + finalize_shield_io(&mut pczt, sighash, OsRng).unwrap(); + + // Round-trip through the proof-service wire path: serialize, splice a canonical-length + // placeholder proof, deserialize. + let signed_bytes = serialize_pczt(&pczt).unwrap(); + let proof = vec![0u8; Proof::expected_proof_size(1)]; + let proven = deserialize_pczt(&with_zkproof(&signed_bytes, proof).unwrap()).unwrap(); + + // Combine → fully-authorized bundle. + let bundle = combine(&proven, sighash, OsRng).unwrap(); + assert_eq!(bundle.actions.len(), 1); + assert_eq!(bundle.spend_auth_sigs.len(), 1); + assert_eq!(bundle.proof.len(), Proof::expected_proof_size(1)); + assert_eq!(bundle.flags, 0x07); + + // The action data survived unchanged from build → combine (digests are stable). + assert_eq!( + bundle.actions, + tx_for_sighash.ironwood_bundle.unwrap().actions + ); + + // The assembled tx encodes and round-trips, with a stable txid. + let tx = v6_tx(transparent, Some(bundle)); + let bytes = encode_v6_transaction(&tx).unwrap(); + let decoded = decode_v6_transaction(&bytes).unwrap(); + assert_eq!(decoded, tx); + assert_eq!(compute_v6_txid(&decoded), compute_v6_txid(&tx)); + } + + /// Cross-check the combined transaction against `zebra-chain` (an independent NU6.3 + /// implementation): zebra must decode our bytes, see a v6 tx, and agree on the ZIP-244 txid + /// and structure. This proves our Constructor → [`IronwoodBundle`] → wire mapping is canonical. + /// (zebra parses but does not verify the proof on deserialize, so the placeholder proof is fine.) + #[cfg(not(target_arch = "wasm32"))] + #[test] + fn combined_tx_matches_zebra() { + use zebra_chain::serialization::ZcashDeserialize; + use zebra_chain::transaction::Transaction as ZebraTx; + + let amount = 100_000_000u64; + let mut pczt = construct_shield_pczt( + &test_recipient(), + amount, + None, + &Anchor::empty_tree().to_bytes(), + &[0u8; 512], + OsRng, + ) + .unwrap(); + let transparent = transparent_skeleton(); + let input_amounts = [200_000_000i64]; + let input_scripts = [ScriptBuf::from(vec![0x76u8, 0xa9, 0x14, 0x88, 0xac])]; + let sighash = compute_v6_sig_digest( + &v6_tx(transparent.clone(), Some(pczt_action_data(&pczt).unwrap())), + &input_amounts, + &input_scripts, + ); + finalize_shield_io(&mut pczt, sighash, OsRng).unwrap(); + let signed_bytes = serialize_pczt(&pczt).unwrap(); + let proven = deserialize_pczt( + &with_zkproof(&signed_bytes, vec![0u8; Proof::expected_proof_size(1)]).unwrap(), + ) + .unwrap(); + let bundle = combine(&proven, sighash, OsRng).unwrap(); + + let tx = v6_tx(transparent, Some(bundle)); + let bytes = encode_v6_transaction(&tx).unwrap(); + + let zebra = ZebraTx::zcash_deserialize(&bytes[..]).expect("zebra decodes our v6 tx"); + assert_eq!(zebra.version(), 6); + assert_eq!(zebra.ironwood_actions().count(), 1); + assert_eq!(zebra.inputs().len(), tx.transparent.input.len()); + assert_eq!(zebra.outputs().len(), tx.transparent.output.len()); + + let mut our_txid = compute_v6_txid(&tx); + our_txid.reverse(); + assert_eq!( + zebra.hash().to_string(), + hex::encode(our_txid), + "zebra txid == ours" + ); + } + + /// Load a Zcash test fixture's contents from `test/fixtures/zcash/`. + #[cfg(not(target_arch = "wasm32"))] + fn load_zcash_fixture(name: &str) -> String { + crate::fixed_script_wallet::test_utils::fixtures::load_fixture(&format!("zcash/{}", name)) + .unwrap_or_else(|e| panic!("failed to load fixture {}: {}", name, e)) + } + + /// Golden shielded-sighash oracle, using the real on-chain `shield1zec` transaction. + /// + /// The transaction's Ironwood bundle carries a real RedPallas dummy spend-auth signature and a + /// real binding signature, both produced by an external Ironwood signer over the ZIP-244 + /// *shielded* sig digest. We reconstruct the orchard bundle from the on-wire action data and + /// verify **both** real signatures against the digest [`compute_v6_sig_digest`] computes. If + /// they verify, our shielded sig digest is byte-correct — the shielded twin of the transparent + /// golden test in `v6.rs`, and an independent check since neither signature was produced by this + /// code. + #[cfg(not(target_arch = "wasm32"))] + #[test] + fn golden_shield1zec_shielded_signatures_verify() { + use nonempty::NonEmpty; + use orchard::bundle::{Bundle as OrchardBundle, EffectsOnly}; + use orchard::note::{ExtractedNoteCommitment, Nullifier, TransmittedNoteCiphertext}; + use orchard::primitives::redpallas::{Binding, Signature, SpendAuth, VerificationKey}; + use orchard::value::ValueCommitment; + + let raw = hex::decode(load_zcash_fixture("v6_shield1zec_rawtx.hex").trim()).unwrap(); + let tx = decode_v6_transaction(&raw).unwrap(); + let bundle = tx.ironwood_bundle.clone().expect("ironwood bundle present"); + + // Spent output committed by ZIP-244 (from the reference that built this tx; see the + // transparent golden test in v6.rs). + let prevout_value: i64 = 313_990_000; + let prevout_script = ScriptBuf::from( + hex::decode("76a9147c6b843a25873c036aff575516e3802bcc47f63488ac").unwrap(), + ); + assert_eq!(tx.transparent.input.len(), 1); + let sighash = + compute_v6_sig_digest(&tx, &[prevout_value], std::slice::from_ref(&prevout_script)); + + // Reconstruct the orchard EffectsOnly bundle from the on-wire action data, so we can derive + // the binding validating key and verify the real signatures. + let actions = bundle + .actions + .iter() + .map(|a| { + let cv_net = + Option::from(ValueCommitment::from_bytes(&a.cv)).expect("valid cv_net"); + let rk = VerificationKey::::try_from(a.rk).expect("valid rk"); + let cmx = + Option::from(ExtractedNoteCommitment::from_bytes(&a.cmx)).expect("valid cmx"); + let nf = Option::from(Nullifier::from_bytes(&a.nullifier)).expect("valid nf"); + let encrypted_note = TransmittedNoteCiphertext { + epk_bytes: a.ephemeral_key, + enc_ciphertext: a.enc_ciphertext, + out_ciphertext: a.out_ciphertext, + }; + OrchardAction::from_parts(nf, rk, cmx, encrypted_note, cv_net, ()) + .expect("valid action") + }) + .collect::>(); + let actions = NonEmpty::from_vec(actions).expect("at least one action"); + + let orchard_bundle = OrchardBundle::::from_parts( + actions, + BundleVersion::ironwood_v3().default_flags(), + bundle.value_balance, + Option::from(Anchor::from_bytes(bundle.anchor)).expect("valid anchor"), + EffectsOnly, + BundleVersion::ironwood_v3(), + ) + .expect("valid bundle"); + assert_eq!(orchard_bundle.flag_byte(), bundle.flags); + + // The real dummy spend-auth signature verifies against our shielded sig digest. + let spend_auth_sig = Signature::::from(bundle.spend_auth_sigs[0]); + orchard_bundle.actions()[0] + .rk() + .verify(&sighash, &spend_auth_sig) + .expect("real spend-auth signature verifies against compute_v6_sig_digest"); + + // The real binding signature verifies against our shielded sig digest. + let binding_sig = Signature::::from(bundle.binding_sig); + orchard_bundle + .binding_validating_key() + .verify(&sighash, &binding_sig) + .expect("real binding signature verifies against compute_v6_sig_digest"); + } +} diff --git a/packages/wasm-utxo/src/zcash/ironwood_pczt.rs b/packages/wasm-utxo/src/zcash/ironwood_pczt.rs index 235bd1e6e30..43c01c72c85 100644 --- a/packages/wasm-utxo/src/zcash/ironwood_pczt.rs +++ b/packages/wasm-utxo/src/zcash/ironwood_pczt.rs @@ -149,6 +149,29 @@ pub fn serialize_pczt(bundle: &PcztBundle) -> Result, IronwoodPcztError> Ok(out) } +/// Splice the prover's `zkproof` bytes into an already-serialized PCZT. +/// +/// This is the proof-service response path: `wasm-utxo` sends the signed PCZT (no proof), the +/// service returns the opaque halo2 proof bytes, and this re-emits the wire form with `zkproof` +/// set — without needing to reconstruct the orchard bundle (whose `zkproof` field is not otherwise +/// publicly settable). The proof length is NOT validated here; the Transaction Extractor +/// ([`orchard::pczt::Bundle::extract`]) rejects non-canonical proof sizes when combining. +pub fn with_zkproof(bytes: &[u8], proof: Vec) -> Result, IronwoodPcztError> { + let (&version, body) = bytes.split_first().ok_or(IronwoodPcztError::Empty)?; + if version != FORMAT_VERSION { + return Err(IronwoodPcztError::UnsupportedVersion(version)); + } + let mut wire: BundleWire = + postcard::from_bytes(body).map_err(|e| IronwoodPcztError::Codec(e.to_string()))?; + wire.zkproof = Some(proof); + let mut out = Vec::with_capacity(1 + body.len()); + out.push(FORMAT_VERSION); + let reencoded = + postcard::to_stdvec(&wire).map_err(|e| IronwoodPcztError::Codec(e.to_string()))?; + out.extend_from_slice(&reencoded); + Ok(out) +} + /// Reconstruct an `orchard` PCZT Ironwood bundle from its wire form. pub fn deserialize_pczt(bytes: &[u8]) -> Result { let (&version, body) = bytes.split_first().ok_or(IronwoodPcztError::Empty)?; @@ -421,6 +444,42 @@ mod tests { )); } + #[test] + fn with_zkproof_sets_the_proof() { + // A freshly constructed PCZT has no proof. + let bundle = sample_pczt(); + assert!(bundle.zkproof().is_none()); + + let bytes = serialize_pczt(&bundle).unwrap(); + let proof = vec![0xabu8; 4992]; + let proven = deserialize_pczt(&with_zkproof(&bytes, proof.clone()).unwrap()).unwrap(); + assert_eq!( + proven.zkproof().as_ref().map(|p| p.as_ref().to_vec()), + Some(proof) + ); + + // Injection is idempotent under re-serialization for the rest of the bundle: only zkproof + // changed, everything else round-trips byte-stable. + assert_eq!( + with_zkproof(&serialize_pczt(&proven).unwrap(), vec![0xabu8; 4992]).unwrap(), + with_zkproof(&bytes, vec![0xabu8; 4992]).unwrap() + ); + } + + #[test] + fn with_zkproof_rejects_unknown_version() { + let mut bytes = serialize_pczt(&sample_pczt()).unwrap(); + bytes[0] = 0xff; + assert!(matches!( + with_zkproof(&bytes, vec![0u8; 4992]), + Err(IronwoodPcztError::UnsupportedVersion(0xff)) + )); + assert!(matches!( + with_zkproof(&[], vec![0u8; 4992]), + Err(IronwoodPcztError::Empty) + )); + } + #[test] fn rejects_empty() { assert!(matches!( diff --git a/packages/wasm-utxo/src/zcash/mod.rs b/packages/wasm-utxo/src/zcash/mod.rs index f61bb67c739..1c5572c9077 100644 --- a/packages/wasm-utxo/src/zcash/mod.rs +++ b/packages/wasm-utxo/src/zcash/mod.rs @@ -10,6 +10,8 @@ //! Tests verify parity with `zebra-chain` crate. pub mod blake2b; +/// orchard PCZT ↔ v6 IronwoodBundle bridge (Constructor / Signer / Extractor roles). +pub mod ironwood_build; /// Serialization of the orchard PCZT Ironwood bundle (proof-service payload / PSBT carry). pub mod ironwood_pczt; pub mod transaction; From c9a0d62df6c69f592ee90cae6a94ffbb957e89db Mon Sep 17 00:00:00 2001 From: Veetrag Jain Date: Tue, 28 Jul 2026 13:28:38 +0530 Subject: [PATCH 4/5] feat(wasm-utxo): wire v6 (Ironwood) build/sign/combine into ZcashBitGoPsbt MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a dedicated Zcash v6 (Ironwood / NU6.3) shielding flow on ZcashBitGoPsbt, mirroring the microservice build → sign → combine PSBT lifecycle. The ~20 generic BitGoPsbt::Zcash dispatch arms are left untouched; v6 uses dedicated methods. propkv: add ProprietaryKeySubtype::ZecIronwoodPczt (0x07, the serialized orchard PCZT) and ZecV6Params (0x08, versionGroupId + expiryHeight); the latter marks a PSBT as v6 so it round-trips through a plain PSBT serialization. ZcashBitGoPsbt v6 methods: - new_v6 / new_v6_at_height: version-6, Ironwood VGID, NU6.3 branch. - add_ironwood_output (Constructor): build the shielded note as an orchard PCZT and store it in the PSBT. - ironwood_action_data / v6_txid / v6_transparent_sighash: derive the ZIP-244 txid and per-input transparent sighash from the transparent skeleton + stored PCZT action data. - add_v6_transparent_signature: verify a client/HSM signature against the v6 sighash and insert it into partial_sigs. - combine_ironwood_proof (Extractor): finalize the transparent inputs, splice in the external prover's zkproof, apply the binding signature, and encode the broadcast-ready v6 transaction. - serialize_v6 / deserialize_v6: round-trip the v6 PSBT (transparent skeleton + PCZT + params). BitGoPsbt::new_zcash_v6_at_height: a builder so transparent inputs/outputs use the existing add_wallet_input / add_wallet_output machinery. Tests (native): - build a 2-of-3 P2SH → Ironwood shield PSBT, round-trip it, sign the transparent input over the ZIP-244 sighash, combine with a placeholder proof, and assert the result decodes, keeps a stable txid across signing, and is accepted by zebra-chain. - golden oracle: reproduce the on-chain shield1zec transaction inside a PSBT and assert its v6_transparent_sighash both equals the codec-golden sighash and verifies the transaction's real ECDSA signature — proving the PSBT threads the true spent-output value + scriptCode into the sighash (a path the synthetic and zebra checks cannot cover). - propkv round-trip unit test. Co-Authored-By: Claude Opus 4.8 --- .../src/fixed_script_wallet/bitgo_psbt/mod.rs | 25 + .../fixed_script_wallet/bitgo_psbt/propkv.rs | 78 +++ .../bitgo_psbt/zcash_psbt.rs | 643 ++++++++++++++++++ 3 files changed, 746 insertions(+) diff --git a/packages/wasm-utxo/src/fixed_script_wallet/bitgo_psbt/mod.rs b/packages/wasm-utxo/src/fixed_script_wallet/bitgo_psbt/mod.rs index 73fbd6322bd..2a08cd7f6b4 100644 --- a/packages/wasm-utxo/src/fixed_script_wallet/bitgo_psbt/mod.rs +++ b/packages/wasm-utxo/src/fixed_script_wallet/bitgo_psbt/mod.rs @@ -496,6 +496,31 @@ impl BitGoPsbt { )) } + /// Create an empty Zcash **v6 (Ironwood)** shielding PSBT with the consensus branch id resolved + /// from block height. Delegates to [`ZcashBitGoPsbt::new_v6_at_height`]. + /// + /// The transparent inputs/outputs are added with the usual [`Self::add_wallet_input`] / + /// [`Self::add_wallet_output`] machinery; the shielded output and the v6 sign/combine steps use + /// the dedicated methods on [`ZcashBitGoPsbt`]. + pub fn new_zcash_v6_at_height( + network: Network, + wallet_keys: &crate::fixed_script_wallet::RootWalletKeys, + block_height: u32, + lock_time: Option, + expiry_height: Option, + ) -> Result { + Ok(BitGoPsbt::Zcash( + ZcashBitGoPsbt::new_v6_at_height( + network, + wallet_keys, + block_height, + lock_time, + expiry_height, + )?, + network, + )) + } + /// Create a new empty PSBT with the same network parameters as an existing PSBT /// /// This is useful for reconstructing PSBTs - it copies: diff --git a/packages/wasm-utxo/src/fixed_script_wallet/bitgo_psbt/propkv.rs b/packages/wasm-utxo/src/fixed_script_wallet/bitgo_psbt/propkv.rs index 4c48408d957..7588bc69854 100644 --- a/packages/wasm-utxo/src/fixed_script_wallet/bitgo_psbt/propkv.rs +++ b/packages/wasm-utxo/src/fixed_script_wallet/bitgo_psbt/propkv.rs @@ -43,6 +43,12 @@ pub enum ProprietaryKeySubtype { PayGoAddressAttestationProof = 0x04, Bip322Message = 0x05, WasmUtxoSignedWith = 0x06, + /// Serialized Zcash Ironwood (v6) PCZT bundle carried through the PSBT: action data + + /// witness at build time, later carrying the externally-generated `zkproof`. + ZecIronwoodPczt = 0x07, + /// Zcash v6 (Ironwood) header params that are not derivable from the transparent skeleton: + /// 8 bytes = versionGroupId (u32 LE) ‖ expiryHeight (u32 LE). Its presence marks a PSBT as v6. + ZecV6Params = 0x08, } impl ProprietaryKeySubtype { @@ -55,6 +61,8 @@ impl ProprietaryKeySubtype { 0x04 => Some(ProprietaryKeySubtype::PayGoAddressAttestationProof), 0x05 => Some(ProprietaryKeySubtype::Bip322Message), 0x06 => Some(ProprietaryKeySubtype::WasmUtxoSignedWith), + 0x07 => Some(ProprietaryKeySubtype::ZecIronwoodPczt), + 0x08 => Some(ProprietaryKeySubtype::ZecV6Params), _ => None, } } @@ -246,6 +254,47 @@ pub fn set_zec_consensus_branch_id(psbt: &mut miniscript::bitcoin::psbt::Psbt, b psbt.proprietary.insert(key, value); } +/// Store a serialized Ironwood (v6) PCZT bundle in the PSBT global proprietary map, under the +/// BitGo proprietary key with subtype `ZecIronwoodPczt` (0x07). Overwrites any existing value. +pub fn set_ironwood_pczt(psbt: &mut miniscript::bitcoin::psbt::Psbt, bytes: Vec) { + let kv = BitGoKeyValue::new(ProprietaryKeySubtype::ZecIronwoodPczt, vec![], bytes); + let (key, value) = kv.to_key_value(); + psbt.proprietary.insert(key, value); +} + +/// Fetch the serialized Ironwood (v6) PCZT bundle from the PSBT global proprietary map, if present. +pub fn get_ironwood_pczt(psbt: &miniscript::bitcoin::psbt::Psbt) -> Option> { + let kv = find_kv(ProprietaryKeySubtype::ZecIronwoodPczt, &psbt.proprietary).next()?; + Some(kv.value.clone()) +} + +/// Store the Zcash v6 (Ironwood) header params — `version_group_id` and `expiry_height` — under the +/// BitGo proprietary key with subtype `ZecV6Params` (0x08), as 8 bytes (both u32 little-endian). +/// Its presence marks the PSBT as v6. Overwrites any existing value. +pub fn set_zec_v6_params( + psbt: &mut miniscript::bitcoin::psbt::Psbt, + version_group_id: u32, + expiry_height: u32, +) { + let mut value = Vec::with_capacity(8); + value.extend_from_slice(&version_group_id.to_le_bytes()); + value.extend_from_slice(&expiry_height.to_le_bytes()); + let kv = BitGoKeyValue::new(ProprietaryKeySubtype::ZecV6Params, vec![], value); + let (key, value) = kv.to_key_value(); + psbt.proprietary.insert(key, value); +} + +/// Fetch the Zcash v6 (Ironwood) header params `(version_group_id, expiry_height)`, if present. +pub fn get_zec_v6_params(psbt: &miniscript::bitcoin::psbt::Psbt) -> Option<(u32, u32)> { + let kv = find_kv(ProprietaryKeySubtype::ZecV6Params, &psbt.proprietary).next()?; + if kv.value.len() != 8 { + return None; + } + let vgid = u32::from_le_bytes(kv.value[0..4].try_into().ok()?); + let expiry = u32::from_le_bytes(kv.value[4..8].try_into().ok()?); + Some((vgid, expiry)) +} + #[cfg(test)] mod tests { use super::*; @@ -310,6 +359,35 @@ mod tests { assert_eq!(NetworkUpgrade::Nu6.branch_id(), 0xc8e71055); } + #[test] + fn test_ironwood_pczt_and_v6_params_roundtrip() { + use miniscript::bitcoin::psbt::Psbt; + use miniscript::bitcoin::Transaction; + + let tx = Transaction { + version: miniscript::bitcoin::transaction::Version::non_standard(6), + lock_time: miniscript::bitcoin::locktime::absolute::LockTime::ZERO, + input: vec![], + output: vec![], + }; + let mut psbt = Psbt::from_unsigned_tx(tx).unwrap(); + + assert_eq!(get_ironwood_pczt(&psbt), None); + assert_eq!(get_zec_v6_params(&psbt), None); + + set_ironwood_pczt(&mut psbt, vec![1, 2, 3, 4]); + set_zec_v6_params(&mut psbt, 0xD884B698, 42); + + assert_eq!(get_ironwood_pczt(&psbt), Some(vec![1, 2, 3, 4])); + assert_eq!(get_zec_v6_params(&psbt), Some((0xD884B698, 42))); + + // Overwrite semantics. + set_ironwood_pczt(&mut psbt, vec![9]); + set_zec_v6_params(&mut psbt, 0xD884B698, 0); + assert_eq!(get_ironwood_pczt(&psbt), Some(vec![9])); + assert_eq!(get_zec_v6_params(&psbt), Some((0xD884B698, 0))); + } + #[test] fn test_version_info_serialization() { let version_info = diff --git a/packages/wasm-utxo/src/fixed_script_wallet/bitgo_psbt/zcash_psbt.rs b/packages/wasm-utxo/src/fixed_script_wallet/bitgo_psbt/zcash_psbt.rs index 020984a00a0..a54fb2cb944 100644 --- a/packages/wasm-utxo/src/fixed_script_wallet/bitgo_psbt/zcash_psbt.rs +++ b/packages/wasm-utxo/src/fixed_script_wallet/bitgo_psbt/zcash_psbt.rs @@ -543,6 +543,327 @@ impl ZcashBitGoPsbt { } } +// ---- Zcash v6 (Ironwood / NU6.3) shielding ---- +// +// A v6 shielding PSBT keeps its transparent inputs/outputs in `psbt.unsigned_tx` (a version-6 +// rust-bitcoin `Transaction`) exactly like the v4 path, and carries the shielded side as an +// `orchard` PCZT in the proprietary map (subtype `ZecIronwoodPczt`). The v6 header params +// (`version_group_id`, `expiry_height`) live under subtype `ZecV6Params`, whose presence marks the +// PSBT as v6 so it round-trips through a plain PSBT serialization without the v4 tx-replacement +// dance. +// +// The lifecycle mirrors the microservice PSBT flow: the server builds (Constructor, no keys), the +// client + HSM sign the transparent inputs over the ZIP-244 sighash this module exposes, the signed +// PSBT goes to the external proof service for the Halo2 `zkproof`, and `combine_ironwood_proof` +// finalizes the transparent inputs and splices in the proof + shielded bundle to produce the +// broadcast-ready v6 transaction. See `crate::zcash::ironwood_build` for the PCZT role bridge. +impl ZcashBitGoPsbt { + /// Create an empty Zcash **v6 (Ironwood)** shielding PSBT, with the consensus branch id resolved + /// from `block_height` (must be at or after NU6.3 activation). + pub fn new_v6_at_height( + network: crate::Network, + wallet_keys: &crate::fixed_script_wallet::RootWalletKeys, + block_height: u32, + lock_time: Option, + expiry_height: Option, + ) -> Result { + let is_mainnet = matches!(network, crate::Network::Zcash); + let consensus_branch_id = crate::zcash::branch_id_for_height(block_height, is_mainnet) + .ok_or_else(|| { + format!( + "Block height {} is before Overwinter activation on {}", + block_height, + if is_mainnet { "mainnet" } else { "testnet" } + ) + })?; + Ok(Self::new_v6( + network, + wallet_keys, + consensus_branch_id, + lock_time, + expiry_height, + )) + } + + /// Create an empty Zcash **v6 (Ironwood)** shielding PSBT with an explicit consensus branch id. + pub fn new_v6( + network: crate::Network, + wallet_keys: &crate::fixed_script_wallet::RootWalletKeys, + consensus_branch_id: u32, + lock_time: Option, + expiry_height: Option, + ) -> Self { + let version_group_id = crate::zcash::transaction::ZCASH_IRONWOOD_VERSION_GROUP_ID; + let expiry_height = expiry_height.unwrap_or(0); + let mut z = Self::new( + network, + wallet_keys, + consensus_branch_id, + Some(6), + lock_time, + Some(version_group_id), + Some(expiry_height), + ); + // v6 has no Sapling fields; the v6 codec writes the empty Sapling/Orchard slots itself. + z.sapling_fields = Vec::new(); + super::propkv::set_zec_v6_params(&mut z.psbt, version_group_id, expiry_height); + z + } + + /// Whether this PSBT is a v6 (Ironwood) PSBT. + pub fn is_ironwood_v6(&self) -> bool { + self.version_group_id == Some(crate::zcash::transaction::ZCASH_IRONWOOD_VERSION_GROUP_ID) + } + + /// Constructor role: build the shielded output (one Ironwood note to `recipient`) as an orchard + /// PCZT and store it in the PSBT. `recipient` is a 43-byte raw Orchard/Ironwood address, + /// `anchor` the current Ironwood note-commitment-tree root, `ovk` an optional raw outgoing + /// viewing key, `memo` the 512-byte memo field. Must be called after the transparent + /// inputs/outputs are in place is NOT required — the shielded action data does not depend on + /// them — but the sighash later does, so add all transparent I/O before signing. + pub fn add_ironwood_output( + &mut self, + recipient: &[u8; crate::zcash::ironwood_build::ORCHARD_ADDRESS_SIZE], + amount: u64, + ovk: Option<[u8; 32]>, + anchor: &[u8; 32], + memo: &[u8; 512], + rng: R, + ) -> Result<(), String> { + let pczt = crate::zcash::ironwood_build::construct_shield_pczt( + recipient, amount, ovk, anchor, memo, rng, + ) + .map_err(|e| e.to_string())?; + let bytes = + crate::zcash::ironwood_pczt::serialize_pczt(&pczt).map_err(|e| e.to_string())?; + super::propkv::set_ironwood_pczt(&mut self.psbt, bytes); + Ok(()) + } + + /// Deserialize the stored orchard PCZT. + fn ironwood_pczt(&self) -> Result { + let bytes = super::propkv::get_ironwood_pczt(&self.psbt) + .ok_or_else(|| "no Ironwood PCZT stored in PSBT".to_string())?; + crate::zcash::ironwood_pczt::deserialize_pczt(&bytes).map_err(|e| e.to_string()) + } + + /// The shielded action-data view of the stored PCZT (commitments/ciphertexts/flags/value/anchor; + /// no proof or signatures). This is what the ZIP-244 txid and sighash commit to. + pub fn ironwood_action_data(&self) -> Result { + crate::zcash::ironwood_build::pczt_action_data(&self.ironwood_pczt()?) + .map_err(|e| e.to_string()) + } + + /// The spent-output value (zatoshi, as i64) and scriptPubKey of every transparent input, in + /// input order — the amounts/scripts ZIP-244 commits to. + fn transparent_input_amounts_and_scripts( + &self, + ) -> Result<(Vec, Vec), String> { + let mut amounts = Vec::with_capacity(self.psbt.inputs.len()); + let mut scripts = Vec::with_capacity(self.psbt.inputs.len()); + for (i, input) in self.psbt.inputs.iter().enumerate() { + let prevout = self.psbt.unsigned_tx.input[i].previous_output; + let (script, value) = + super::psbt_wallet_input::get_output_script_and_value(input, prevout) + .map_err(|e| format!("input {i}: missing UTXO value/script: {e}"))?; + amounts.push(value.to_sat() as i64); + scripts.push(script.clone()); + } + Ok((amounts, scripts)) + } + + /// Assemble a [`ZcashV6Transaction`] from the transparent skeleton plus the given Ironwood + /// bundle (action-data-only for digests, or fully authorized for extraction). + fn to_v6_transaction( + &self, + transparent: Transaction, + ironwood_bundle: Option, + ) -> Result { + let consensus_branch_id = super::propkv::get_zec_consensus_branch_id(&self.psbt) + .ok_or_else(|| "missing consensus_branch_id".to_string())?; + Ok(crate::zcash::v6::ZcashV6Transaction { + version_group_id: crate::zcash::transaction::ZCASH_IRONWOOD_VERSION_GROUP_ID, + consensus_branch_id, + transparent, + expiry_height: self.expiry_height.unwrap_or(0), + sapling_value_balance: 0, + ironwood_bundle, + }) + } + + /// The ZIP-244 v6 txid (internal byte order — reverse for display) of the transaction as it + /// currently stands (transparent skeleton + shielded action data). + pub fn v6_txid(&self) -> Result<[u8; 32], String> { + let bundle = self.ironwood_action_data()?; + let tx = self.to_v6_transaction(self.psbt.unsigned_tx.clone(), Some(bundle))?; + Ok(crate::zcash::v6::compute_v6_txid(&tx)) + } + + /// ZIP-244 per-input transparent sighash for transparent input `index` (SIGHASH_ALL) — the + /// message the key controlling that input must sign. + pub fn v6_transparent_sighash(&self, index: usize) -> Result<[u8; 32], String> { + let (amounts, scripts) = self.transparent_input_amounts_and_scripts()?; + let bundle = self.ironwood_action_data()?; + let tx = self.to_v6_transaction(self.psbt.unsigned_tx.clone(), Some(bundle))?; + let input = self + .psbt + .inputs + .get(index) + .ok_or_else(|| format!("input {index} out of range"))?; + let script_code = input + .witness_script + .as_ref() + .or(input.redeem_script.as_ref()) + .ok_or_else(|| format!("input {index}: no redeem/witness script"))?; + crate::zcash::v6::compute_v6_transparent_sighash( + &tx, + index, + script_code.as_script(), + &amounts, + &scripts, + ) + .map_err(|e| e.to_string()) + } + + /// Ingest a transparent-input signature returned by the client/HSM into `partial_sigs`, after + /// verifying it against [`Self::v6_transparent_sighash`] for that input. `sig` is a DER ECDSA + /// signature with the trailing SIGHASH_ALL byte (as it appears in a scriptSig). + pub fn add_v6_transparent_signature( + &mut self, + index: usize, + pubkey: miniscript::bitcoin::PublicKey, + sig: &[u8], + ) -> Result<(), String> { + use miniscript::bitcoin::ecdsa::Signature as EcdsaSig; + use miniscript::bitcoin::secp256k1::{Message, Secp256k1}; + + let sighash = self.v6_transparent_sighash(index)?; + let ecdsa_sig = + EcdsaSig::from_slice(sig).map_err(|e| format!("input {index}: bad signature: {e}"))?; + let secp = Secp256k1::verification_only(); + let msg = Message::from_digest(sighash); + secp.verify_ecdsa(&msg, &ecdsa_sig.signature, &pubkey.inner) + .map_err(|e| { + format!("input {index}: signature does not verify against v6 sighash: {e}") + })?; + self.psbt.inputs[index] + .partial_sigs + .insert(pubkey, ecdsa_sig); + Ok(()) + } + + /// Build the finalized transparent transaction: clone the skeleton and fill each input's + /// scriptSig from the collected `partial_sigs`, in the redeem script's pubkey order + /// (`OP_0 ` for a 2-of-3 P2SH multisig). Zcash transparent inputs are + /// non-segwit, so this produces a complete scriptSig with no witness. + fn finalized_transparent_tx(&self) -> Result { + use crate::fixed_script_wallet::wallet_scripts::parse_multisig_script_2_of_3; + use miniscript::bitcoin::blockdata::opcodes::all::OP_PUSHBYTES_0; + use miniscript::bitcoin::script::{Builder, PushBytesBuf}; + + let mut tx = self.psbt.unsigned_tx.clone(); + for (i, txin) in tx.input.iter_mut().enumerate() { + let input = &self.psbt.inputs[i]; + let redeem_script = input + .redeem_script + .as_ref() + .ok_or_else(|| format!("input {i}: no redeem script (expected P2SH multisig)"))?; + let pubkeys = parse_multisig_script_2_of_3(redeem_script) + .map_err(|e| format!("input {i}: {e}"))?; + + // OP_0 (the multisig off-by-one dummy), then each present signature in pubkey order. + let mut builder = Builder::new().push_opcode(OP_PUSHBYTES_0); + for pk in &pubkeys { + if let Some(sig) = input + .partial_sigs + .get(&miniscript::bitcoin::PublicKey::from(*pk)) + { + let mut buf = PushBytesBuf::new(); + buf.extend_from_slice(&sig.to_vec()) + .map_err(|e| format!("input {i}: sig too large: {e}"))?; + builder = builder.push_slice(&buf); + } + } + let mut rs = PushBytesBuf::new(); + rs.extend_from_slice(redeem_script.as_bytes()) + .map_err(|e| format!("input {i}: redeem script too large: {e}"))?; + builder = builder.push_slice(&rs); + txin.script_sig = builder.into_script(); + } + Ok(tx) + } + + /// Transaction Extractor role: given the external prover's `proof` bytes, finalize the + /// transparent inputs, apply the shielded binding signature, and produce the broadcast-ready v6 + /// transaction bytes. + /// + /// The PSBT must already carry every transparent input's signatures (via + /// [`Self::add_v6_transparent_signature`]) and the PCZT (from [`Self::add_ironwood_output`]). + /// `rng` seeds the randomized binding + dummy spend-auth signatures. Consumes `self`. + pub fn combine_ironwood_proof( + self, + proof: Vec, + rng: R, + ) -> Result, String> { + use crate::zcash::{ironwood_build, ironwood_pczt}; + + // The shielded binding signature signs the ZIP-244 sig digest over the complete tx + // (transparent I/O + shielded action data). Signatures/proof are excluded from that digest, + // so it is stable regardless of transparent-input signing. + let (amounts, scripts) = self.transparent_input_amounts_and_scripts()?; + let action_bundle = self.ironwood_action_data()?; + let sig_tx = self.to_v6_transaction(self.psbt.unsigned_tx.clone(), Some(action_bundle))?; + let sighash = crate::zcash::v6::compute_v6_sig_digest(&sig_tx, &amounts, &scripts); + + // Signer + IO finalizer: sign the dummy spend(s) and derive the binding signing key. + let mut pczt = self.ironwood_pczt()?; + ironwood_build::finalize_shield_io(&mut pczt, sighash, rng).map_err(|e| e.to_string())?; + + // Splice in the external proof, then run the Transaction Extractor. + let signed_bytes = ironwood_pczt::serialize_pczt(&pczt).map_err(|e| e.to_string())?; + let proven = ironwood_pczt::deserialize_pczt( + &ironwood_pczt::with_zkproof(&signed_bytes, proof).map_err(|e| e.to_string())?, + ) + .map_err(|e| e.to_string())?; + // Fresh RNG value for the binding signature (the previous `rng` was moved into finalize_io). + let full_bundle = ironwood_build::combine(&proven, sighash, rand::rngs::OsRng) + .map_err(|e| e.to_string())?; + + // Finalize transparent inputs and assemble the network transaction. + let transparent = self.finalized_transparent_tx()?; + let tx = self.to_v6_transaction(transparent, Some(full_bundle))?; + crate::zcash::v6::encode_v6_transaction(&tx).map_err(|e| e.to_string()) + } + + /// Serialize a v6 PSBT to bytes: a plain PSBT carrying the transparent skeleton in + /// `unsigned_tx` and the shielded state (PCZT, branch id, v6 params) in the proprietary map. + pub fn serialize_v6(&self) -> Vec { + self.psbt.serialize() + } + + /// Deserialize a v6 PSBT produced by [`Self::serialize_v6`], restoring the v6 header params from + /// the proprietary map. + pub fn deserialize_v6( + bytes: &[u8], + network: crate::Network, + ) -> Result { + let psbt = Psbt::deserialize(bytes)?; + let (version_group_id, expiry_height) = super::propkv::get_zec_v6_params(&psbt) + .ok_or_else(|| { + super::DeserializeError::Network( + "PSBT is missing the ZecV6Params proprietary key (not a v6 PSBT)".to_string(), + ) + })?; + Ok(ZcashBitGoPsbt { + psbt, + network, + version_group_id: Some(version_group_id), + expiry_height: Some(expiry_height), + sapling_fields: Vec::new(), + }) + } +} + #[cfg(test)] mod tests { use super::*; @@ -653,3 +974,325 @@ mod tests { ); } } + +/// End-to-end v6 (Ironwood) shielding through the PSBT layer. Native-only (orchard + zebra). +#[cfg(all(test, not(target_arch = "wasm32")))] +mod ironwood_v6_tests { + use super::*; + use crate::bitcoin::bip32::Xpriv; + use crate::bitcoin::hashes::{sha256, Hash}; + use crate::bitcoin::secp256k1::{Message, Secp256k1, SecretKey}; + use crate::bitcoin::{CompressedPublicKey, Network as BtcNetwork, PublicKey, Txid}; + use crate::fixed_script_wallet::bitgo_psbt::psbt_wallet_input::WalletInputOptions; + use crate::fixed_script_wallet::bitgo_psbt::BitGoPsbt; + use crate::fixed_script_wallet::script_id::ScriptId; + use crate::fixed_script_wallet::test_utils::get_test_wallet_keys; + use crate::fixed_script_wallet::wallet_scripts::chain_index_path; + use crate::fixed_script_wallet::RootWalletKeys; + use crate::networks::Network; + use crate::zcash::NetworkUpgrade; + use orchard::keys::{FullViewingKey, Scope, SpendingKey}; + use orchard::tree::Anchor; + use orchard::Proof; + use rand::rngs::OsRng; + + /// A deterministic Ironwood receiver derived from a fixed spending key. + fn test_recipient() -> [u8; 43] { + let sk = Option::::from(SpendingKey::from_bytes([7u8; 32])).unwrap(); + FullViewingKey::from(&sk) + .address_at(0u32, Scope::External) + .to_raw_address_bytes() + } + + /// The three wallet signing keys derived at `chain/index`, matching `get_test_wallet_keys` + /// (same `seed.N` scheme), so their pubkeys equal the multisig redeem script's. + fn signing_secret_keys(seed: &str, chain: u32, index: u32) -> [SecretKey; 3] { + let secp = Secp256k1::new(); + let path = chain_index_path(chain, index); + let mut keys = Vec::with_capacity(3); + for i in 0..3u8 { + let hash = sha256::Hash::hash(format!("{seed}.{i}").as_bytes()).to_byte_array(); + let master = Xpriv::new_master(BtcNetwork::Testnet, &hash).unwrap(); + keys.push(master.derive_priv(&secp, &path).unwrap().private_key); + } + keys.try_into().unwrap() + } + + #[test] + fn build_sign_combine_produces_valid_v6_tx() { + let seed = "ironwood_v6_psbt"; + let wallet_keys = RootWalletKeys::new(get_test_wallet_keys(seed)); + let nu6_3 = NetworkUpgrade::Nu6_3.testnet_activation_height(); + + // Build: one 2-of-3 P2SH transparent input (2 ZEC), a transparent change output, and a + // shielded Ironwood output (1 ZEC). + let mut psbt = BitGoPsbt::new_zcash_v6_at_height( + Network::ZcashTestnet, + &wallet_keys, + nu6_3, + None, + None, + ) + .unwrap(); + psbt.add_wallet_input( + Txid::from_byte_array([0x22u8; 32]), + 0, + 200_000_000, + &wallet_keys, + ScriptId { chain: 0, index: 0 }, + WalletInputOptions::default(), + ) + .unwrap(); + psbt.add_wallet_output(0, 1, 99_900_000, &wallet_keys) + .unwrap(); + + let BitGoPsbt::Zcash(mut z, _) = psbt else { + panic!("expected Zcash PSBT"); + }; + assert!(z.is_ironwood_v6()); + z.add_ironwood_output( + &test_recipient(), + 100_000_000, + None, + &Anchor::empty_tree().to_bytes(), + &[0u8; 512], + OsRng, + ) + .unwrap(); + + // The v6 txid is defined at build time (before signing/proving). + let txid = z.v6_txid().unwrap(); + + // Serialize → deserialize preserves the transparent skeleton, PCZT, branch id, and v6 params. + let bytes = z.serialize_v6(); + let z2 = ZcashBitGoPsbt::deserialize_v6(&bytes, Network::ZcashTestnet).unwrap(); + assert!(z2.is_ironwood_v6()); + assert_eq!(z2.v6_txid().unwrap(), txid, "txid survives PSBT round-trip"); + + // Sign the transparent input with user + bitgo (keys 0 and 2) over the ZIP-244 sighash. + let secp = Secp256k1::new(); + let sighash = z.v6_transparent_sighash(0).unwrap(); + let msg = Message::from_digest(sighash); + for i in [0usize, 2] { + let sk = signing_secret_keys(seed, 0, 0)[i]; + let secp_pk = crate::bitcoin::secp256k1::PublicKey::from_secret_key(&secp, &sk); + let pubkey = PublicKey::from(CompressedPublicKey(secp_pk)); + let mut der = secp.sign_ecdsa(&msg, &sk).serialize_der().to_vec(); + der.push(0x01); // SIGHASH_ALL + z.add_v6_transparent_signature(0, pubkey, &der).unwrap(); + } + + // Combine with a canonical-length placeholder proof (stands in for the external prover). + let proof = vec![0u8; Proof::expected_proof_size(1)]; + let raw = z.combine_ironwood_proof(proof, OsRng).unwrap(); + + // The result is a well-formed v6 transaction with a finalized transparent input and the + // shielded bundle. + let tx = crate::zcash::v6::decode_v6_transaction(&raw).unwrap(); + assert_eq!(tx.transparent.input.len(), 1); + assert_eq!(tx.transparent.output.len(), 1); + assert!( + !tx.transparent.input[0].script_sig.is_empty(), + "input finalized" + ); + let bundle = tx.ironwood_bundle.as_ref().unwrap(); + assert_eq!(bundle.actions.len(), 1); + assert_eq!(bundle.spend_auth_sigs.len(), 1); + assert_eq!(bundle.proof.len(), Proof::expected_proof_size(1)); + + // txid is unchanged by signing/proving (ZIP-244 excludes scriptSigs, proof, and sigs). + let mut internal = crate::zcash::v6::compute_v6_txid(&tx); + assert_eq!(internal, txid); + + // zebra-chain independently decodes the combined tx and agrees on the txid. + use zebra_chain::serialization::ZcashDeserialize; + use zebra_chain::transaction::Transaction as ZebraTx; + let zebra = ZebraTx::zcash_deserialize(&raw[..]).expect("zebra decodes v6 tx"); + assert_eq!(zebra.version(), 6); + assert_eq!(zebra.ironwood_actions().count(), 1); + internal.reverse(); + assert_eq!( + zebra.hash().to_string(), + hex::encode(internal), + "zebra txid == ours" + ); + } + + /// Build a PCZT whose action data equals the given bundle's, with all witness fields absent — + /// enough for the effects-only (action-data) view the sighash/txid need. Used to reconstruct + /// the on-chain fixture's shielded state inside a PSBT. + fn pczt_bytes_from_action_data(bundle: &crate::zcash::v6::IronwoodBundle) -> Vec { + use orchard::bundle::BundleVersion; + use orchard::note::Nullifier; + use orchard::pczt::{ + Action as PcztAction, Bundle as PcztBundle, Output as PcztOutput, Spend as PcztSpend, + }; + use std::collections::BTreeMap; + + let bundle_version = BundleVersion::ironwood_v3(); + let note_version = bundle_version.note_version(); + + let actions = bundle + .actions + .iter() + .map(|a| { + let spend = PcztSpend::parse( + a.nullifier, + a.rk, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + note_version, + BTreeMap::new(), + ) + .expect("valid spend"); + let spend_nullifier = + Option::::from(Nullifier::from_bytes(&a.nullifier)).unwrap(); + let output = PcztOutput::parse( + spend_nullifier, + a.cmx, + a.ephemeral_key, + a.enc_ciphertext.to_vec(), + a.out_ciphertext.to_vec(), + None, + None, + None, + None, + None, + None, + note_version, + BTreeMap::new(), + ) + .expect("valid output"); + PcztAction::parse(a.cv, spend, output, None).expect("valid action") + }) + .collect::>(); + + let magnitude = bundle.value_balance.unsigned_abs(); + let negative = bundle.value_balance.is_negative(); + let pczt = PcztBundle::parse( + actions, + bundle.flags, + bundle_version, + (magnitude, negative), + bundle.anchor, + None, + None, + ) + .expect("valid pczt bundle"); + crate::zcash::ironwood_pczt::serialize_pczt(&pczt).unwrap() + } + + /// Golden PSBT-level oracle: the `v6_transparent_sighash` the PSBT produces from the on-chain + /// `shield1zec` transaction verifies the transaction's **real** ECDSA signature. + /// + /// This closes a gap the synthetic end-to-end test cannot: it computes the sighash for both + /// signing and verifying with the same code, so a consistently-wrong spent-output value/script + /// would still verify there, and the ZIP-244 txid (which the zebra check pins) does not commit + /// to input amounts/scripts. Here the signature was produced by an **external** signer using the + /// true amount + scriptPubKey, so it only verifies if the PSBT threaded the real spent-output + /// value (313_990_000 zat) and scriptCode into the sighash correctly. + #[test] + fn golden_shield1zec_psbt_sighash_verifies_real_signature() { + use crate::bitcoin::script::Instruction; + use crate::bitcoin::secp256k1::{ + ecdsa::Signature, Message, PublicKey as SecpPk, Secp256k1, + }; + use crate::bitcoin::{Amount, ScriptBuf, TxOut}; + use crate::zcash::v6::{compute_v6_transparent_sighash, decode_v6_transaction}; + + let raw = hex::decode( + crate::fixed_script_wallet::test_utils::fixtures::load_fixture( + "zcash/v6_shield1zec_rawtx.hex", + ) + .unwrap() + .trim(), + ) + .unwrap(); + let tx = decode_v6_transaction(&raw).unwrap(); + let bundle = tx.ironwood_bundle.clone().expect("ironwood bundle"); + assert_eq!(tx.transparent.input.len(), 1); + + // Spent output committed by ZIP-244 (from the reference that built this tx). + let prevout_value: i64 = 313_990_000; + let prevout_script = ScriptBuf::from( + hex::decode("76a9147c6b843a25873c036aff575516e3802bcc47f63488ac").unwrap(), + ); + + // The real P2PKH signature + pubkey from the on-chain scriptSig. + let pushes: Vec> = tx.transparent.input[0] + .script_sig + .instructions() + .map(|i| i.expect("valid scriptSig")) + .filter_map(|i| match i { + Instruction::PushBytes(pb) => Some(pb.as_bytes().to_vec()), + Instruction::Op(_) => None, + }) + .collect(); + assert_eq!(pushes.len(), 2); + let der = &pushes[0][..pushes[0].len() - 1]; // strip trailing SIGHASH_ALL byte + let pubkey_bytes = &pushes[1]; + + // Reconstruct the fixture's state inside a v6 PSBT: transparent skeleton (scriptSigs + // stripped), the spent output hydrated as witness_utxo (this is the extraction under test), + // and the shielded action data as a stored PCZT. + let wallet_keys = RootWalletKeys::new(get_test_wallet_keys("shield1zec_psbt")); + let mut z = ZcashBitGoPsbt::new_v6( + Network::ZcashTestnet, + &wallet_keys, + tx.consensus_branch_id, + Some(tx.transparent.lock_time.to_consensus_u32()), + Some(tx.expiry_height), + ); + let mut skeleton = tx.transparent.clone(); + for i in skeleton.input.iter_mut() { + i.script_sig = ScriptBuf::new(); + } + z.psbt.unsigned_tx = skeleton; + let mut input = crate::bitcoin::psbt::Input { + witness_utxo: Some(TxOut { + value: Amount::from_sat(prevout_value as u64), + script_pubkey: prevout_script.clone(), + }), + ..Default::default() + }; + // For a P2PKH input the scriptCode is the scriptPubKey; the wrapper reads it from + // redeem/witness_script. + input.redeem_script = Some(prevout_script.clone()); + z.psbt.inputs = vec![input]; + z.psbt.outputs = vec![crate::bitcoin::psbt::Output::default(); tx.transparent.output.len()]; + crate::fixed_script_wallet::bitgo_psbt::propkv::set_ironwood_pczt( + &mut z.psbt, + pczt_bytes_from_action_data(&bundle), + ); + + // The PSBT-derived sighash must equal the codec's (PR1-golden) sighash — i.e. the PSBT + // threaded the spent-output value + script through correctly. + let psbt_sighash = z.v6_transparent_sighash(0).unwrap(); + let codec_sighash = compute_v6_transparent_sighash( + &tx, + 0, + prevout_script.as_script(), + &[prevout_value], + std::slice::from_ref(&prevout_script), + ) + .unwrap(); + assert_eq!(psbt_sighash, codec_sighash, "PSBT sighash == codec golden"); + + // And the transaction's real signature verifies against the PSBT-derived sighash. + let secp = Secp256k1::verification_only(); + let msg = Message::from_digest(psbt_sighash); + let mut sig = Signature::from_der(der).expect("DER signature"); + sig.normalize_s(); + let pk = SecpPk::from_slice(pubkey_bytes).expect("valid pubkey"); + secp.verify_ecdsa(&msg, &sig, &pk) + .expect("real signature verifies against the PSBT-derived v6 sighash"); + } +} From 4cb59b1f05613c7effa9e9eb084785e7c7ed9ed0 Mon Sep 17 00:00:00 2001 From: Veetrag Jain Date: Tue, 28 Jul 2026 13:57:01 +0530 Subject: [PATCH 5/5] feat(wasm-utxo): expose v6 (Ironwood) PSBT flow via wasm + TS MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Surface the dedicated ZcashBitGoPsbt v6 methods through the wasm bindings and the TypeScript ZcashBitGoPsbt wrapper. wasm (src/wasm/fixed_script_wallet): add create_empty_zcash_v6_at_height, add_ironwood_output, ironwood_v6_txid, ironwood_v6_transparent_sighash, add_ironwood_v6_signature, and combine_ironwood_proof, plus private zcash()/zcash_mut() helpers to reach the Zcash arm. Byte-length validation for recipient (43) / anchor (32) / memo (512) / ovk (32) happens at the boundary; randomness uses OsRng (getrandom js). zcash_psbt: make the internal serialize()/deserialize() v6-aware so the standard from_bytes/serialize path round-trips a v6 PSBT (plain PSBT carrying the transparent skeleton + PCZT + ZecV6Params) without the v4 tx-replacement dance. TS (ZcashBitGoPsbt.ts): add createIronwood, addIronwoodOutput, ironwoodTxid, ironwoodTransparentSighash, addIronwoodSignature, and combineIronwoodProof. Test (test/fixedScript/zcashIronwoodPsbt.ts): drive the bindings from TypeScript — build a shield PSBT, assert the Ironwood version group id, compute a 32-byte v6 txid + per-input sighash that survive a serialize round-trip, and check the signature/recipient validation error paths. (Full sign→combine runtime behavior is covered by the native Rust test in PR4.) A small getrandom shim lets the bundler-target wasm source randomness under the Node/mocha harness; production bundler/browser and nodejs-target builds don't need it. Co-Authored-By: Claude Opus 4.8 --- .../js/fixedScriptWallet/ZcashBitGoPsbt.ts | 86 +++++++++++ .../bitgo_psbt/zcash_psbt.rs | 22 +++ .../src/wasm/fixed_script_wallet/mod.rs | 140 ++++++++++++++++++ .../test/fixedScript/zcashIronwoodPsbt.ts | 78 ++++++++++ 4 files changed, 326 insertions(+) create mode 100644 packages/wasm-utxo/test/fixedScript/zcashIronwoodPsbt.ts diff --git a/packages/wasm-utxo/js/fixedScriptWallet/ZcashBitGoPsbt.ts b/packages/wasm-utxo/js/fixedScriptWallet/ZcashBitGoPsbt.ts index a43efe751e7..198442323bc 100644 --- a/packages/wasm-utxo/js/fixedScriptWallet/ZcashBitGoPsbt.ts +++ b/packages/wasm-utxo/js/fixedScriptWallet/ZcashBitGoPsbt.ts @@ -292,4 +292,90 @@ export class ZcashBitGoPsbt extends BitGoPsbt { override extractTransaction(maxFeeRate?: number): ZcashTransaction { return ZcashTransaction.fromWasm(this.wasm.extract_zcash_transaction(maxFeeRate)); } + + // --- Zcash v6 (Ironwood / NU6.3) shielding --- + + /** + * Create an empty Zcash **v6 (Ironwood)** shielding PSBT, with the consensus branch ID + * determined from block height. + * + * Add transparent inputs/outputs with the usual `addWalletInput` / `addWalletOutput`, the + * shielded output with {@link addIronwoodOutput}, then sign the transparent inputs over + * {@link ironwoodTransparentSighash} and finish with {@link combineIronwoodProof}. + * + * @param network - Zcash network name ("zcash", "zcashTest", "zec", "tzec") + * @param walletKeys - The wallet's root keys (sets global xpubs in the PSBT) + * @param options - Options including blockHeight (at/after NU6.3 activation) + */ + static createIronwood( + network: ZcashNetworkName, + walletKeys: WalletKeysArg, + options: { blockHeight: number; lockTime?: number; expiryHeight?: number }, + ): ZcashBitGoPsbt { + const keys = RootWalletKeys.from(walletKeys); + const wasm = WasmBitGoPsbt.create_empty_zcash_v6_at_height( + network, + keys.wasm, + options.blockHeight, + options.lockTime, + options.expiryHeight, + ); + return new ZcashBitGoPsbt(wasm); + } + + /** + * Add the shielded Ironwood output (Constructor role). Stores the orchard PCZT in the PSBT. + * + * @param recipient - 43-byte raw Orchard/Ironwood address + * @param amount - note value in zatoshi + * @param options.anchor - 32-byte Ironwood note-commitment-tree root + * @param options.memo - optional 512-byte memo (defaults to all zeros) + * @param options.ovk - optional 32-byte outgoing viewing key (omit for a keyless build) + */ + addIronwoodOutput( + recipient: Uint8Array, + amount: bigint, + options: { anchor: Uint8Array; memo?: Uint8Array; ovk?: Uint8Array }, + ): void { + const memo = options.memo ?? new Uint8Array(512); + this.wasm.add_ironwood_output(recipient, amount, options.ovk, options.anchor, memo); + } + + /** + * The ZIP-244 v6 txid in display (reverse) byte order. Defined once the transparent + * inputs/outputs and the Ironwood output are in place; unchanged by signing or proving. + */ + ironwoodTxid(): Uint8Array { + return this.wasm.ironwood_v6_txid(); + } + + /** + * The ZIP-244 per-input transparent sighash (32 bytes) the key controlling transparent input + * `index` must sign. + */ + ironwoodTransparentSighash(index: number): Uint8Array { + return this.wasm.ironwood_v6_transparent_sighash(index); + } + + /** + * Ingest a transparent-input signature returned by the client/HSM, after verifying it against + * {@link ironwoodTransparentSighash} for that input. + * + * @param index - transparent input index + * @param pubkey - the signing public key + * @param sig - DER ECDSA signature with the trailing SIGHASH_ALL byte (as in a scriptSig) + */ + addIronwoodSignature(index: number, pubkey: Uint8Array, sig: Uint8Array): void { + this.wasm.add_ironwood_v6_signature(index, pubkey, sig); + } + + /** + * Transaction Extractor role: given the external prover's `proof` bytes, finalize the + * transparent inputs, apply the shielded binding signature, and return the broadcast-ready v6 + * transaction bytes. Requires every transparent input to be signed via + * {@link addIronwoodSignature}. + */ + combineIronwoodProof(proof: Uint8Array): Uint8Array { + return this.wasm.combine_ironwood_proof(proof); + } } diff --git a/packages/wasm-utxo/src/fixed_script_wallet/bitgo_psbt/zcash_psbt.rs b/packages/wasm-utxo/src/fixed_script_wallet/bitgo_psbt/zcash_psbt.rs index a54fb2cb944..36e259b471e 100644 --- a/packages/wasm-utxo/src/fixed_script_wallet/bitgo_psbt/zcash_psbt.rs +++ b/packages/wasm-utxo/src/fixed_script_wallet/bitgo_psbt/zcash_psbt.rs @@ -240,6 +240,22 @@ impl ZcashBitGoPsbt { network: crate::Network, require_branch_id: bool, ) -> Result { + // A v6 (Ironwood) PSBT is a plain PSBT carrying the transparent skeleton, so it parses + // directly; the presence of the ZecV6Params proprietary key distinguishes it from the v4 + // path (whose embedded overwintered tx a plain PSBT decoder cannot parse). + if let Ok(psbt) = Psbt::deserialize(bytes) { + if let Some((version_group_id, expiry_height)) = super::propkv::get_zec_v6_params(&psbt) + { + return Ok(ZcashBitGoPsbt { + psbt, + network, + version_group_id: Some(version_group_id), + expiry_height: Some(expiry_height), + sapling_fields: Vec::new(), + }); + } + } + let mut r = bytes; // Read magic bytes @@ -407,6 +423,12 @@ impl ZcashBitGoPsbt { /// Serialize the Zcash PSBT back to bytes, including Zcash-specific fields pub fn serialize(&self) -> Result, super::DeserializeError> { + // A v6 (Ironwood) PSBT keeps its transparent skeleton in the PSBT's own tx and its shielded + // state in the proprietary map, so it serializes as a plain PSBT. + if self.is_ironwood_v6() { + return Ok(self.serialize_v6()); + } + // First serialize as standard Bitcoin PSBT let bitcoin_psbt_bytes = self.psbt.serialize(); diff --git a/packages/wasm-utxo/src/wasm/fixed_script_wallet/mod.rs b/packages/wasm-utxo/src/wasm/fixed_script_wallet/mod.rs index fe27ce704a8..5eabbf792b8 100644 --- a/packages/wasm-utxo/src/wasm/fixed_script_wallet/mod.rs +++ b/packages/wasm-utxo/src/wasm/fixed_script_wallet/mod.rs @@ -307,6 +307,30 @@ pub struct BitGoPsbt { pub(crate) first_rounds: HashMap<(usize, String), musig2::FirstRound>, } +impl BitGoPsbt { + /// Borrow the inner `ZcashBitGoPsbt`, erroring if this is not a Zcash PSBT. + fn zcash( + &self, + ) -> Result<&crate::fixed_script_wallet::bitgo_psbt::ZcashBitGoPsbt, WasmUtxoError> { + use crate::fixed_script_wallet::bitgo_psbt::BitGoPsbt as InnerBitGoPsbt; + match &self.psbt { + InnerBitGoPsbt::Zcash(z, _) => Ok(z), + _ => Err(WasmUtxoError::new("not a Zcash PSBT")), + } + } + + /// Mutably borrow the inner `ZcashBitGoPsbt`, erroring if this is not a Zcash PSBT. + fn zcash_mut( + &mut self, + ) -> Result<&mut crate::fixed_script_wallet::bitgo_psbt::ZcashBitGoPsbt, WasmUtxoError> { + use crate::fixed_script_wallet::bitgo_psbt::BitGoPsbt as InnerBitGoPsbt; + match &mut self.psbt { + InnerBitGoPsbt::Zcash(z, _) => Ok(z), + _ => Err(WasmUtxoError::new("not a Zcash PSBT")), + } + } +} + #[wasm_bindgen] impl BitGoPsbt { /// Deserialize a PSBT from bytes with network-specific logic @@ -439,6 +463,122 @@ impl BitGoPsbt { }) } + // ---- Zcash v6 (Ironwood / NU6.3) shielding ---- + + /// Create an empty Zcash **v6 (Ironwood)** shielding PSBT, with the consensus branch id + /// resolved from `block_height`. Transparent inputs/outputs are added with the usual + /// `add_wallet_input` / `add_wallet_output`; the shielded output and the v6 sign/combine + /// steps use the dedicated `add_ironwood_output` / `ironwood_v6_*` / `combine_ironwood_proof` + /// methods. + pub fn create_empty_zcash_v6_at_height( + network: &str, + wallet_keys: &WasmRootWalletKeys, + block_height: u32, + lock_time: Option, + expiry_height: Option, + ) -> Result { + let network = parse_network(network)?; + let wallet_keys = wallet_keys.inner(); + let psbt = crate::fixed_script_wallet::bitgo_psbt::BitGoPsbt::new_zcash_v6_at_height( + network, + wallet_keys, + block_height, + lock_time, + expiry_height, + ) + .map_err(|e| WasmUtxoError::new(&e))?; + Ok(BitGoPsbt { + psbt, + first_rounds: HashMap::new(), + }) + } + + /// Constructor: add the shielded Ironwood output as an orchard PCZT stored in the PSBT. + /// + /// * `recipient` - 43-byte raw Orchard/Ironwood address + /// * `amount` - note value in zatoshi + /// * `ovk` - optional 32-byte outgoing viewing key (`None` for a keyless build) + /// * `anchor` - 32-byte Ironwood note-commitment-tree root + /// * `memo` - 512-byte memo field + pub fn add_ironwood_output( + &mut self, + recipient: &[u8], + amount: u64, + ovk: Option>, + anchor: &[u8], + memo: &[u8], + ) -> Result<(), WasmUtxoError> { + let recipient: [u8; 43] = recipient + .try_into() + .map_err(|_| WasmUtxoError::new("recipient must be 43 bytes"))?; + let anchor: [u8; 32] = anchor + .try_into() + .map_err(|_| WasmUtxoError::new("anchor must be 32 bytes"))?; + let memo: [u8; 512] = memo + .try_into() + .map_err(|_| WasmUtxoError::new("memo must be 512 bytes"))?; + let ovk: Option<[u8; 32]> = match ovk { + Some(v) => Some( + v.as_slice() + .try_into() + .map_err(|_| WasmUtxoError::new("ovk must be 32 bytes"))?, + ), + None => None, + }; + self.zcash_mut()? + .add_ironwood_output(&recipient, amount, ovk, &anchor, &memo, rand::rngs::OsRng) + .map_err(|e| WasmUtxoError::new(&e)) + } + + /// The ZIP-244 v6 txid in display (reverse) byte order. + pub fn ironwood_v6_txid(&self) -> Result, WasmUtxoError> { + let mut txid = self + .zcash()? + .v6_txid() + .map_err(|e| WasmUtxoError::new(&e))?; + txid.reverse(); + Ok(txid.to_vec()) + } + + /// The ZIP-244 per-input transparent sighash (32 bytes) that the key controlling transparent + /// input `index` must sign. + pub fn ironwood_v6_transparent_sighash(&self, index: usize) -> Result, WasmUtxoError> { + self.zcash()? + .v6_transparent_sighash(index) + .map(|h| h.to_vec()) + .map_err(|e| WasmUtxoError::new(&e)) + } + + /// Ingest a transparent-input signature (`sig`: DER ECDSA with the trailing SIGHASH_ALL byte) + /// after verifying it against `ironwood_v6_transparent_sighash(index)`. + pub fn add_ironwood_v6_signature( + &mut self, + index: usize, + pubkey: &[u8], + sig: &[u8], + ) -> Result<(), WasmUtxoError> { + let pk = miniscript::bitcoin::PublicKey::from_slice(pubkey) + .map_err(|e| WasmUtxoError::new(&format!("invalid pubkey: {e}")))?; + self.zcash_mut()? + .add_v6_transparent_signature(index, pk, sig) + .map_err(|e| WasmUtxoError::new(&e)) + } + + /// Transaction Extractor: given the external prover's `proof` bytes, finalize the transparent + /// inputs, apply the shielded binding signature, and return the broadcast-ready v6 transaction + /// bytes. Requires the transparent inputs to be signed (via `add_ironwood_v6_signature`). + pub fn combine_ironwood_proof(&self, proof: &[u8]) -> Result, WasmUtxoError> { + use crate::fixed_script_wallet::bitgo_psbt::BitGoPsbt as InnerBitGoPsbt; + match self.psbt.clone() { + InnerBitGoPsbt::Zcash(z, _) => z + .combine_ironwood_proof(proof.to_vec(), rand::rngs::OsRng) + .map_err(|e| WasmUtxoError::new(&e)), + _ => Err(WasmUtxoError::new( + "combine_ironwood_proof: not a Zcash PSBT", + )), + } + } + /// Convert a half-signed legacy transaction to a psbt-lite. /// /// # Arguments diff --git a/packages/wasm-utxo/test/fixedScript/zcashIronwoodPsbt.ts b/packages/wasm-utxo/test/fixedScript/zcashIronwoodPsbt.ts new file mode 100644 index 00000000000..0440d9b6e36 --- /dev/null +++ b/packages/wasm-utxo/test/fixedScript/zcashIronwoodPsbt.ts @@ -0,0 +1,78 @@ +import assert from "node:assert"; +import { createRequire } from "node:module"; +import { describe, it } from "mocha"; + +// The bundler-target wasm sources CSPRNG bytes through getrandom, which — on detecting Node via +// `process` — calls `module.require("crypto")`. `module` is undefined under Node ESM (the mocha +// harness), so provide a working `require`. Production consumers (real bundlers, or the +// nodejs-target build the microservice uses) don't need this. Runs before the first wasm +// randomness call. +(globalThis as unknown as { module?: { require: NodeRequire } }).module ??= { + require: createRequire(import.meta.url), +}; + +import { ZcashBitGoPsbt } from "../../js/fixedScriptWallet/ZcashBitGoPsbt.js"; +import { getWalletKeysForSeed } from "../../js/testutils/index.js"; + +// NU6.3 (Ironwood) testnet activation height and version group id. +const NU6_3_TESTNET_HEIGHT = 4134000; +const IRONWOOD_VERSION_GROUP_ID = 0xd884b698; + +// A valid raw Orchard/Ironwood receiver (43 bytes), derived in Rust from +// SpendingKey([7u8; 32]) → FullViewingKey → address_at(0, External). +const RECIPIENT = Buffer.from( + "4559029c0b5dbf941c5ad181a5fe8f45b34630f29d0c8dd8dc1cc3573386f416cb324133156d723df5e62d", + "hex", +); + +describe("ZcashBitGoPsbt v6 (Ironwood)", function () { + function buildShieldPsbt(): ZcashBitGoPsbt { + const walletKeys = getWalletKeysForSeed("ironwood-ts"); + const psbt = ZcashBitGoPsbt.createIronwood("zcashTest", walletKeys, { + blockHeight: NU6_3_TESTNET_HEIGHT, + }); + // One 2-of-3 P2SH transparent input (2 ZEC) and a transparent change output. + psbt.addWalletInput({ txid: "11".repeat(32), vout: 0, value: 200_000_000n }, walletKeys, { + scriptId: { chain: 0, index: 0 }, + signPath: { signer: "user", cosigner: "bitgo" }, + }); + psbt.addWalletOutput(walletKeys, { chain: 1, index: 0, value: 99_900_000n }); + // Shielded Ironwood output (1 ZEC). Any valid Pallas base element is a usable build-time + // anchor (validity against a real tree is a prover concern), so all-zeros works. + psbt.addIronwoodOutput(RECIPIENT, 100_000_000n, { anchor: new Uint8Array(32) }); + return psbt; + } + + it("creates an Ironwood PSBT with the Ironwood version group id", function () { + const psbt = buildShieldPsbt(); + assert.strictEqual(psbt.versionGroupId, IRONWOOD_VERSION_GROUP_ID); + }); + + it("computes a 32-byte v6 txid and per-input sighash that survive a serialize round-trip", function () { + const psbt = buildShieldPsbt(); + const txid = psbt.ironwoodTxid(); + assert.strictEqual(txid.length, 32); + const sighash = psbt.ironwoodTransparentSighash(0); + assert.strictEqual(sighash.length, 32); + + // serialize → fromBytes preserves the v6 state (transparent skeleton + PCZT + params). + const bytes = psbt.serialize(); + const round = ZcashBitGoPsbt.fromBytes(bytes, "zcashTest"); + assert.strictEqual(round.versionGroupId, IRONWOOD_VERSION_GROUP_ID); + assert.deepStrictEqual(round.ironwoodTxid(), txid); + assert.deepStrictEqual(round.ironwoodTransparentSighash(0), sighash); + }); + + it("rejects a signature that does not verify against the v6 sighash", function () { + const psbt = buildShieldPsbt(); + assert.throws(() => psbt.addIronwoodSignature(0, new Uint8Array(33), new Uint8Array(72))); + }); + + it("rejects Ironwood operations on a non-Ironwood output shape", function () { + const psbt = buildShieldPsbt(); + // A bad recipient length is rejected before touching the orchard builder. + assert.throws(() => + psbt.addIronwoodOutput(new Uint8Array(10), 1n, { anchor: new Uint8Array(32) }), + ); + }); +});