From 178d76af7383c9d7a54ff594b245d51448c6a5fd Mon Sep 17 00:00:00 2001 From: Veetrag Jain Date: Tue, 28 Jul 2026 13:57:01 +0530 Subject: [PATCH] feat(wasm-utxo): expose v6 (Ironwood) PSBT flow via wasm + TS --- packages/wasm-utxo/.mocharc.json | 3 +- .../js/fixedScriptWallet/ZcashBitGoPsbt.ts | 24 ++- .../ZcashIronwoodBitGoPsbt.ts | 163 ++++++++++++++ .../wasm-utxo/js/fixedScriptWallet/index.ts | 4 + .../fixed_script_wallet/bitgo_psbt/propkv.rs | 15 ++ .../bitgo_psbt/zcash_psbt.rs | 97 ++++++++- .../src/wasm/fixed_script_wallet/mod.rs | 147 +++++++++++++ packages/wasm-utxo/src/wasm/zcash.rs | 10 + .../test/fixedScript/zcashIronwoodPsbt.ts | 198 ++++++++++++++++++ .../wasm-utxo/test/setupWasmRandomness.ts | 38 ++++ 10 files changed, 695 insertions(+), 4 deletions(-) create mode 100644 packages/wasm-utxo/js/fixedScriptWallet/ZcashIronwoodBitGoPsbt.ts create mode 100644 packages/wasm-utxo/test/fixedScript/zcashIronwoodPsbt.ts create mode 100644 packages/wasm-utxo/test/setupWasmRandomness.ts diff --git a/packages/wasm-utxo/.mocharc.json b/packages/wasm-utxo/.mocharc.json index 5ef1b0df60e..97d5df61489 100644 --- a/packages/wasm-utxo/.mocharc.json +++ b/packages/wasm-utxo/.mocharc.json @@ -1,5 +1,6 @@ { "extensions": ["ts", "tsx", "js", "jsx"], - "ignore": ["test/benchmark/**", "test/integrationLocalRpc/**"], + "ignore": ["test/benchmark/**", "test/integrationLocalRpc/**", "test/setupWasmRandomness.ts"], + "require": ["test/setupWasmRandomness.ts"], "node-option": ["import=tsx/esm", "experimental-wasm-modules"] } diff --git a/packages/wasm-utxo/js/fixedScriptWallet/ZcashBitGoPsbt.ts b/packages/wasm-utxo/js/fixedScriptWallet/ZcashBitGoPsbt.ts index a43efe751e7..e17724981b3 100644 --- a/packages/wasm-utxo/js/fixedScriptWallet/ZcashBitGoPsbt.ts +++ b/packages/wasm-utxo/js/fixedScriptWallet/ZcashBitGoPsbt.ts @@ -1,4 +1,8 @@ -import { BitGoPsbt as WasmBitGoPsbt, zcash_branch_id_for_height } from "../wasm/wasm_utxo.js"; +import { + BitGoPsbt as WasmBitGoPsbt, + zcash_branch_id_for_height, + zcash_ironwood_version_group_id, +} from "../wasm/wasm_utxo.js"; import { type WalletKeysArg, RootWalletKeys } from "./RootWalletKeys.js"; import { BitGoPsbt, type CreateEmptyOptions, type HydrationUnspent } from "./BitGoPsbt.js"; import { ZcashTransaction, type ITransaction } from "../transaction.js"; @@ -6,6 +10,16 @@ import { ZcashTransaction, type ITransaction } from "../transaction.js"; /** Zcash network names */ export type ZcashNetworkName = "zcash" | "zcashTest" | "zec" | "tzec"; +/** + * Zcash v6 (Ironwood) version group id (0xd884b698). Its presence marks a PSBT as v6 — see + * `ZcashIronwoodBitGoPsbt`. + * + * Read from the wasm layer rather than hard-coded, so it cannot drift from + * `ZCASH_IRONWOOD_VERSION_GROUP_ID` in `src/zcash/transaction.rs`: a divergence would make the + * v4/v6 discrimination in {@link ZcashBitGoPsbt.fromBytes} silently classify v6 bytes as v4. + */ +export const IRONWOOD_VERSION_GROUP_ID: number = zcash_ironwood_version_group_id(); + /** Options for creating an empty Zcash PSBT (preferred method using block height) */ export type CreateEmptyZcashOptions = CreateEmptyOptions & { /** Block height to determine consensus branch ID automatically */ @@ -137,11 +151,17 @@ export class ZcashBitGoPsbt extends BitGoPsbt { * * @param bytes - The PSBT bytes * @param network - Zcash network name ("zcash", "zcashTest", "zec", "tzec") + * @throws Error if the deserialized PSBT is a v6 (Ironwood) PSBT — use + * {@link ZcashIronwoodBitGoPsbt.fromBytes} instead * @returns A ZcashBitGoPsbt instance */ static override fromBytes(bytes: Uint8Array, network: ZcashNetworkName): ZcashBitGoPsbt { const wasm = WasmBitGoPsbt.from_bytes(bytes, network); - return new ZcashBitGoPsbt(wasm); + const psbt = new ZcashBitGoPsbt(wasm); + if (psbt.versionGroupId === IRONWOOD_VERSION_GROUP_ID) { + throw new Error("this is a v6 (Ironwood) PSBT: use ZcashIronwoodBitGoPsbt.fromBytes instead"); + } + return psbt; } /** diff --git a/packages/wasm-utxo/js/fixedScriptWallet/ZcashIronwoodBitGoPsbt.ts b/packages/wasm-utxo/js/fixedScriptWallet/ZcashIronwoodBitGoPsbt.ts new file mode 100644 index 00000000000..dffd0689346 --- /dev/null +++ b/packages/wasm-utxo/js/fixedScriptWallet/ZcashIronwoodBitGoPsbt.ts @@ -0,0 +1,163 @@ +import { BitGoPsbt as WasmBitGoPsbt } from "../wasm/wasm_utxo.js"; +import { type WalletKeysArg, RootWalletKeys } from "./RootWalletKeys.js"; +import { + IRONWOOD_VERSION_GROUP_ID, + ZcashBitGoPsbt, + type ZcashNetworkName, +} from "./ZcashBitGoPsbt.js"; + +/** + * A fresh ZIP-302 "no memo" memo field: the `0xf6` marker byte followed by 511 zero bytes. The + * default for {@link ZcashIronwoodBitGoPsbt.addIronwoodOutput}'s `memo` option. + * + * Not the same as an all-zeros memo, which decodes as a *text* memo holding the empty string and is + * rendered as such by wallets. Exported so callers can pass it explicitly, and so the distinction is + * visible rather than buried in a default. + * + * A function rather than a shared constant: a module-level `Uint8Array` is mutable, so one caller + * writing into it would silently change the default memo for every later output. + */ +export function zip302NoMemo(): Uint8Array { + const memo = new Uint8Array(512); + memo[0] = 0xf6; + return memo; +} + +/** + * A Zcash **v6 (Ironwood / NU6.3)** shielding PSBT. + * + * Distinct from the generic `ZcashBitGoPsbt` (v4/Sapling-shaped transactions): a v6 PSBT carries + * its shielded side as an orchard PCZT in the proprietary map rather than Sapling fields, and its + * lifecycle mirrors the microservice build → sign → combine flow rather than the v4 ZIP-243 + * signing path. + * + * @example + * ```typescript + * const psbt = ZcashIronwoodBitGoPsbt.createIronwood("zcash", walletKeys, { blockHeight }); + * psbt.addWalletInput(...); + * psbt.addWalletOutput(...); + * psbt.addIronwoodOutput(recipient, amount, { anchor }); + * const sighash = psbt.ironwoodTransparentSighash(0); + * // ... sign sighash externally, then: + * psbt.addIronwoodSignature(0, pubkey, sig); + * const tx = psbt.combineIronwoodProof(proof); + * ``` + */ +export class ZcashIronwoodBitGoPsbt extends ZcashBitGoPsbt { + /** + * Not applicable to v6: use {@link createIronwood}. Inherited only because JS statics are + * inherited — calling it would hand back a v4-shaped `ZcashBitGoPsbt`. + */ + static override createEmpty(): never { + throw new Error("use ZcashIronwoodBitGoPsbt.createIronwood to build a v6 (Ironwood) PSBT"); + } + + /** + * 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 }, + ): ZcashIronwoodBitGoPsbt { + 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 ZcashIronwoodBitGoPsbt(wasm); + } + + /** + * Deserialize a v6 (Ironwood) Zcash PSBT from bytes. + * + * @param bytes - The PSBT bytes + * @param network - Zcash network name ("zcash", "zcashTest", "zec", "tzec") + * @throws Error if the deserialized PSBT is not a v6 (Ironwood) PSBT + * @returns A ZcashIronwoodBitGoPsbt instance + */ + static override fromBytes(bytes: Uint8Array, network: ZcashNetworkName): ZcashIronwoodBitGoPsbt { + const wasm = WasmBitGoPsbt.from_bytes(bytes, network); + const psbt = new ZcashIronwoodBitGoPsbt(wasm); + if (psbt.versionGroupId !== IRONWOOD_VERSION_GROUP_ID) { + throw new Error( + "not a v6 (Ironwood) PSBT: use ZcashBitGoPsbt.fromBytes for v4/Sapling-shaped PSBTs", + ); + } + return psbt; + } + + /** + * 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 the ZIP-302 "no memo" encoding) + * @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 ?? zip302NoMemo(); + this.wasm.add_ironwood_output(recipient, amount, options.ovk, options.anchor, memo); + } + + /** + * The canonical (display-order) ZIP-244 v6 txid as a lowercase hex string, matching the + * `getId()` convention used by the other transaction/PSBT wrappers. Defined once the + * transparent inputs/outputs and the Ironwood output are in place; unchanged by signing or + * proving. + */ + ironwoodTxid(): string { + 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}. + * + * Terminal: the wasm binding drops the stored PCZT on success, so any later Ironwood call on this + * PSBT throws — including after a `serialize`/{@link fromBytes} round-trip, since the PCZT is gone + * from the bytes too. Build a fresh PSBT instead. + */ + combineIronwoodProof(proof: Uint8Array): Uint8Array { + return this.wasm.combine_ironwood_proof(proof); + } +} diff --git a/packages/wasm-utxo/js/fixedScriptWallet/index.ts b/packages/wasm-utxo/js/fixedScriptWallet/index.ts index 53c8dba60c3..280473fbbcf 100644 --- a/packages/wasm-utxo/js/fixedScriptWallet/index.ts +++ b/packages/wasm-utxo/js/fixedScriptWallet/index.ts @@ -44,8 +44,12 @@ export { ZcashBitGoPsbt, type ZcashNetworkName, type CreateEmptyZcashOptions, + IRONWOOD_VERSION_GROUP_ID, } from "./ZcashBitGoPsbt.js"; +// Zcash v6 (Ironwood / NU6.3) shielding PSBT +export { ZcashIronwoodBitGoPsbt, zip302NoMemo } from "./ZcashIronwoodBitGoPsbt.js"; + // Zcash ZIP-316 Unified Address export { ZcashUnifiedAddress } from "./ZcashUnifiedAddress.js"; 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 4c9552e4bb7..f91a104a67a 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 @@ -322,6 +322,21 @@ pub fn get_ironwood_pczt(psbt: &miniscript::bitcoin::psbt::Psbt) -> Option bool { + let key = miniscript::bitcoin::psbt::raw::ProprietaryKey { + prefix: BITGO_ZEC_V6.to_vec(), + subtype: ZecV6KeySubtype::IronwoodPczt as u8, + key: vec![], + }; + psbt.proprietary.remove(&key).is_some() +} + /// Store the Zcash v6 (Ironwood) header params — `version_group_id` and `expiry_height` — under the /// `BITGO_ZEC_V6` namespace. `version_group_id`'s presence marks the PSBT as v6; `expiry_height` is /// always written too (even when 0, a valid "no expiry" value, so it can be told apart from absent). 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 a1836ee0408..570ee0fb5cd 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 @@ -265,6 +265,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). Both share the + // `psbt\xff` magic, so a failed parse here is not conclusive — fall through to the v4 path + // and let it report the real error. + // + // Delegate to `deserialize_v6` rather than constructing the struct inline: it validates the + // declared version group id, the consensus branch id and the PCZT. Trusting them would let a + // PSBT that carries ZecV6Params but a non-Ironwood version group id through with + // `is_ironwood_v6() == false`, which then routes `serialize()` back down the v4 path. + if let Ok(psbt) = Psbt::deserialize(bytes) { + if super::propkv::get_zec_v6_params(&psbt).is_some() { + return Self::deserialize_v6(bytes, network); + } + } + let mut r = bytes; // Read magic bytes @@ -432,6 +448,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(); @@ -945,6 +967,17 @@ impl ZcashBitGoPsbt { crate::zcash::v6::encode_v6_transaction(&tx).map_err(|e| e.to_string()) } + /// Mark this v6 PSBT as extracted by dropping its PCZT, making [`Self::combine_ironwood_proof`] + /// terminal for callers that cannot consume the PSBT by value (the wasm bindings, which must + /// clone). Any later Ironwood operation then fails with "no Ironwood PCZT stored in PSBT" + /// instead of silently re-running against already-extracted state — and because the PCZT is + /// gone from the bytes too, that holds across a `serialize`/`deserialize` round-trip. + /// + /// Returns whether a PCZT was present. + pub fn mark_ironwood_extracted(&mut self) -> bool { + super::propkv::take_ironwood_pczt(&mut self.psbt) + } + /// 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 { @@ -1341,6 +1374,39 @@ mod ironwood_v6_tests { assert!(err.contains("already present"), "unexpected error: {err}"); } + /// `mark_ironwood_extracted` makes extraction terminal for callers that must clone rather than + /// consume the PSBT (the wasm bindings): the PCZT is gone, so every later Ironwood operation + /// fails loudly instead of silently re-running against already-extracted state — and because the + /// PCZT is absent from the serialized bytes too, a round-trip cannot launder it back. + #[test] + fn mark_ironwood_extracted_is_terminal() { + let mut z = build_shield_psbt("v6_extracted"); + assert!(z.v6_txid().is_ok(), "usable before extraction"); + + assert!(z.mark_ironwood_extracted(), "a PCZT was present"); + assert!(!z.mark_ironwood_extracted(), "second call is a no-op"); + + // Every operation that reads the shielded state now fails. (`combine_ironwood_proof` is not + // listed because its transparent-signature check fires first on this unsigned PSBT; it reads + // the PCZT via the same `ironwood_pczt()` accessor as these.) + for err in [ + z.v6_txid().unwrap_err(), + z.v6_transparent_sighash(0).unwrap_err(), + z.ironwood_action_data().unwrap_err(), + ] { + assert!(err.contains("no Ironwood PCZT"), "unexpected error: {err}"); + } + + // The serialized bytes no longer carry a PCZT, and `deserialize` rejects that outright. + let err = ZcashBitGoPsbt::deserialize(&z.serialize().unwrap(), Network::ZcashTestnet) + .unwrap_err() + .to_string(); + assert!( + err.contains("missing its Ironwood PCZT"), + "unexpected error: {err}" + ); + } + /// A signature from a key outside the input's redeem script is rejected at ingest, rather than /// being silently dropped at finalization. #[test] @@ -1448,11 +1514,14 @@ mod ironwood_v6_tests { /// The v4 code paths refuse a v6 PSBT instead of emitting a v4-shaped transaction (which would /// be unbroadcastable) or a ZIP-243 signature (which could never verify). + /// + /// `serialize()` is deliberately excluded: it is v6-aware (routes to `serialize_v6()` when + /// `is_ironwood_v6()`), so a v6 PSBT round-trips through the standard `serialize`/`fromBytes` + /// path without the v4 tx-replacement dance — see `serialize()`'s doc comment. #[test] fn v4_paths_reject_a_v6_psbt() { let z = build_shield_psbt("v6_v4_guard"); for err in [ - z.serialize().unwrap_err().to_string(), z.extract_unsigned_zcash_transaction() .unwrap_err() .to_string(), @@ -1465,6 +1534,11 @@ mod ironwood_v6_tests { ); } + // `serialize()` succeeds for v6 (v6-aware) and round-trips via `deserialize()`. + let bytes = z.serialize().unwrap(); + let round = ZcashBitGoPsbt::deserialize(&bytes, Network::ZcashTestnet).unwrap(); + assert!(round.is_ironwood_v6()); + // The generic sign path refuses too, so no ZIP-243 signature can reach `partial_sigs`. let mut generic = BitGoPsbt::Zcash(z, Network::ZcashTestnet); let err = generic @@ -1499,6 +1573,27 @@ mod ironwood_v6_tests { ); } + /// The *general* `deserialize` entry point routes v6-shaped bytes through `deserialize_v6`, so + /// it inherits that validation. Without the delegation a PSBT carrying ZecV6Params but a + /// non-Ironwood version group id would deserialize with `is_ironwood_v6() == false` and then + /// serialize back down the v4 path. + #[test] + fn deserialize_rejects_a_v6_psbt_with_a_non_ironwood_version_group_id() { + let mut z = build_shield_psbt("v6_bad_vgid_general"); + crate::fixed_script_wallet::bitgo_psbt::propkv::set_zec_v6_params( + &mut z.psbt, + ZCASH_SAPLING_VERSION_GROUP_ID, + 0, + ); + let err = ZcashBitGoPsbt::deserialize(&z.serialize_v6(), Network::ZcashTestnet) + .unwrap_err() + .to_string(); + assert!( + err.contains("expected the Ironwood id"), + "unexpected error: {err}" + ); + } + /// `deserialize_v6` rejects a PSBT missing its consensus branch id, rather than deserializing /// successfully and failing later (with a less obvious error) in v6_txid/combine. #[test] 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..929e4331c58 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,129 @@ 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 canonical (display-order) ZIP-244 v6 txid as a lowercase hex string, matching the + /// `getId()` convention used elsewhere (see [`crate::wasm::zcash::ZcashV6Transaction::get_id`]). + pub fn ironwood_v6_txid(&self) -> Result { + use miniscript::bitcoin::hashes::Hash; + let txid = self + .zcash()? + .v6_txid() + .map_err(|e| WasmUtxoError::new(&e))?; + Ok(miniscript::bitcoin::Txid::from_byte_array(txid).to_string()) + } + + /// 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`). + /// + /// Terminal: on success the stored PCZT is dropped, so a second call — or any other Ironwood + /// operation, including after a `serialize`/`from_bytes` round-trip — fails rather than silently + /// re-running against already-extracted state. Build a fresh PSBT instead. On failure nothing is + /// dropped, so a call that errors (bad proof, unsigned input) leaves the PSBT retryable. + pub fn combine_ironwood_proof(&mut self, proof: &[u8]) -> Result, WasmUtxoError> { + // The inner `combine_ironwood_proof` takes `self` by value (it is a consuming Extractor + // step), which does not translate across the wasm boundary — so clone, and mark this + // instance spent only once the clone has actually produced a transaction. + let tx = self + .zcash()? + .clone() + .combine_ironwood_proof(proof.to_vec(), rand::rngs::OsRng) + .map_err(|e| WasmUtxoError::new(&e))?; + self.zcash_mut()?.mark_ironwood_extracted(); + Ok(tx) + } + /// Convert a half-signed legacy transaction to a psbt-lite. /// /// # Arguments diff --git a/packages/wasm-utxo/src/wasm/zcash.rs b/packages/wasm-utxo/src/wasm/zcash.rs index e6f8b49628e..8d8156c9bcf 100644 --- a/packages/wasm-utxo/src/wasm/zcash.rs +++ b/packages/wasm-utxo/src/wasm/zcash.rs @@ -32,6 +32,16 @@ pub fn zcash_branch_id_for_height( Ok(crate::zcash::branch_id_for_height(height, is_mainnet)) } +/// The Zcash v6 (Ironwood / NU6.3) version group id. +/// +/// Exported so the TypeScript layer can derive its `IRONWOOD_VERSION_GROUP_ID` from the Rust +/// constant instead of hard-coding a second copy: the two are used together to tell v6 PSBTs from +/// v4/Sapling ones, and a silent divergence would route v6 bytes down the v4 path. +#[wasm_bindgen] +pub fn zcash_ironwood_version_group_id() -> u32 { + crate::zcash::transaction::ZCASH_IRONWOOD_VERSION_GROUP_ID +} + /// A parsed ZIP-316 Unified Address. /// /// Decode once with [`ZcashUnifiedAddress::parse`], then read each component through diff --git a/packages/wasm-utxo/test/fixedScript/zcashIronwoodPsbt.ts b/packages/wasm-utxo/test/fixedScript/zcashIronwoodPsbt.ts new file mode 100644 index 00000000000..2c1a135e573 --- /dev/null +++ b/packages/wasm-utxo/test/fixedScript/zcashIronwoodPsbt.ts @@ -0,0 +1,198 @@ +import assert from "node:assert"; +import { describe, it } from "mocha"; + +import { + ZcashIronwoodBitGoPsbt, + zip302NoMemo, +} from "../../js/fixedScriptWallet/ZcashIronwoodBitGoPsbt.js"; +import { + IRONWOOD_VERSION_GROUP_ID, + ZcashBitGoPsbt, +} from "../../js/fixedScriptWallet/ZcashBitGoPsbt.js"; +import { getWalletKeysForSeed } from "../../js/testutils/index.js"; + +// NU6.3 (Ironwood) testnet activation height. +const NU6_3_TESTNET_HEIGHT = 4134000; + +// 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", +); + +const SCRIPT_ID = { chain: 0, index: 0 } as const; + +describe("ZcashIronwoodBitGoPsbt v6 (Ironwood)", function () { + const walletKeys = getWalletKeysForSeed("ironwood-ts"); + + function buildShieldPsbt(): ZcashIronwoodBitGoPsbt { + const psbt = ZcashIronwoodBitGoPsbt.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: SCRIPT_ID, + 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; + } + + /** The user pubkey in the input's 2-of-3 redeem script (derivation prefix `m/0/0`, then chain/index). */ + function userPubkeyForInput(): Uint8Array { + return walletKeys.userKey().derivePath(`0/0/${SCRIPT_ID.chain}/${SCRIPT_ID.index}`).publicKey; + } + + 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 canonical v6 txid and 32-byte per-input sighash that survive a serialize round-trip", function () { + const psbt = buildShieldPsbt(); + const txid = psbt.ironwoodTxid(); + assert.match(txid, /^[0-9a-f]{64}$/, "canonical (display-order) hex txid"); + 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 = ZcashIronwoodBitGoPsbt.fromBytes(bytes, "zcashTest"); + assert.strictEqual(round.versionGroupId, IRONWOOD_VERSION_GROUP_ID); + assert.strictEqual(round.ironwoodTxid(), txid); + assert.deepStrictEqual(round.ironwoodTransparentSighash(0), sighash); + }); + + it("rejects a well-formed signature that does not verify against the v6 sighash", function () { + const psbt = buildShieldPsbt(); + // A pubkey the redeem script actually contains, so the failure comes from verification against + // the v6 sighash rather than from the pubkey checks below. + // Minimal well-formed DER encoding of (r, s) = (1, 1) plus the SIGHASH_ALL byte: parses as a + // signature, cannot verify against any message. + const sig = Buffer.from("3006020101020101" + "01", "hex"); + assert.throws( + () => psbt.addIronwoodSignature(0, userPubkeyForInput(), sig), + /does not verify/i, + ); + }); + + it("rejects a signature whose sighash type is not SIGHASH_ALL", function () { + const psbt = buildShieldPsbt(); + // Same DER body, SIGHASH_NONE (0x02) type byte. + const sig = Buffer.from("3006020101020101" + "02", "hex"); + assert.throws( + () => psbt.addIronwoodSignature(0, userPubkeyForInput(), sig), + /must be SIGHASH_ALL/, + ); + }); + + it("rejects a malformed pubkey", function () { + const psbt = buildShieldPsbt(); + // All-zeros is not a valid secp256k1 point, so this fails before any sighash comparison. + assert.throws( + () => psbt.addIronwoodSignature(0, new Uint8Array(33), new Uint8Array(72)), + /invalid pubkey/, + ); + }); + + it("rejects a valid pubkey that is not in the input's redeem script", function () { + const psbt = buildShieldPsbt(); + const stranger = getWalletKeysForSeed("not-this-wallet").userKey().publicKey; + assert.throws( + () => psbt.addIronwoodSignature(0, stranger, new Uint8Array(72)), + /not one of the redeem script's keys/, + ); + }); + + it("rejects an out-of-range transparent input index", function () { + const psbt = buildShieldPsbt(); + assert.throws(() => psbt.ironwoodTransparentSighash(5), /out of range/); + }); + + describe("addIronwoodOutput byte-length validation", function () { + // Each field is validated at the wasm boundary, before the orchard builder is touched. + const anchor = new Uint8Array(32); + const cases: Array<[string, () => void, RegExp]> = [ + [ + "recipient", + () => buildShieldPsbt().addIronwoodOutput(new Uint8Array(10), 1n, { anchor }), + /recipient must be 43 bytes/, + ], + [ + "anchor", + () => buildShieldPsbt().addIronwoodOutput(RECIPIENT, 1n, { anchor: new Uint8Array(31) }), + /anchor must be 32 bytes/, + ], + [ + "memo", + () => + buildShieldPsbt().addIronwoodOutput(RECIPIENT, 1n, { anchor, memo: new Uint8Array(511) }), + /memo must be 512 bytes/, + ], + [ + "ovk", + () => + buildShieldPsbt().addIronwoodOutput(RECIPIENT, 1n, { anchor, ovk: new Uint8Array(31) }), + /ovk must be 32 bytes/, + ], + ]; + for (const [field, run, expected] of cases) { + it(`rejects a ${field} of the wrong length`, function () { + assert.throws(run, expected); + }); + } + }); + + it("rejects a second Ironwood output", function () { + const psbt = buildShieldPsbt(); + assert.throws( + () => psbt.addIronwoodOutput(RECIPIENT, 1n, { anchor: new Uint8Array(32) }), + /already present/, + ); + }); + + it("the default memo is the ZIP-302 no-memo encoding, not all zeros", function () { + // `addIronwoodOutput` defaults `memo` to this. Asserted on the encoding rather than by comparing + // txids across two builds: `construct_shield_pczt` draws a random rseed, so two separately-built + // PSBTs have different note ciphertexts (hence different txids) whatever their memos. + const memo = zip302NoMemo(); + assert.strictEqual(memo.length, 512); + assert.strictEqual(memo[0], 0xf6, "ZIP-302 no-memo marker byte"); + assert.ok( + memo.slice(1).every((b: number) => b === 0), + "remaining bytes are zero padding", + ); + }); + + it("zip302NoMemo returns a fresh array, so a caller cannot poison the default", function () { + const mine = zip302NoMemo(); + mine[0] = 0x00; + assert.strictEqual(zip302NoMemo()[0], 0xf6); + }); + + it("createEmpty is not available on the Ironwood subclass", function () { + assert.throws(() => ZcashIronwoodBitGoPsbt.createEmpty(), /createIronwood/); + }); + + it("ZcashBitGoPsbt.fromBytes rejects a v6 (Ironwood) PSBT", function () { + const bytes = buildShieldPsbt().serialize(); + assert.throws(() => ZcashBitGoPsbt.fromBytes(bytes, "zcashTest"), /Ironwood/); + }); + + it("ZcashIronwoodBitGoPsbt.fromBytes rejects a non-v6 (v4/Sapling) PSBT", function () { + const v4Psbt = ZcashBitGoPsbt.createEmpty("zcashTest", walletKeys, { + blockHeight: NU6_3_TESTNET_HEIGHT, + }); + v4Psbt.addWalletInput({ txid: "11".repeat(32), vout: 0, value: 200_000_000n }, walletKeys, { + scriptId: SCRIPT_ID, + signPath: { signer: "user", cosigner: "bitgo" }, + }); + v4Psbt.addWalletOutput(walletKeys, { chain: 1, index: 0, value: 199_900_000n }); + const bytes = v4Psbt.serialize(); + assert.throws(() => ZcashIronwoodBitGoPsbt.fromBytes(bytes, "zcashTest"), /not a v6/); + }); +}); diff --git a/packages/wasm-utxo/test/setupWasmRandomness.ts b/packages/wasm-utxo/test/setupWasmRandomness.ts new file mode 100644 index 00000000000..16f596abdbc --- /dev/null +++ b/packages/wasm-utxo/test/setupWasmRandomness.ts @@ -0,0 +1,38 @@ +import { createRequire } from "node:module"; + +/** + * Mocha setup: make CSPRNG bytes available to the bundler-target wasm under Node ESM. + * + * The bundler-target build sources randomness through getrandom, which — on detecting Node via + * `process` — calls `module.require("crypto")`. `module` is undefined under Node ESM (this test + * harness), so provide a working `require`. Production consumers (real bundlers, or the + * nodejs-target build the microservice uses) don't need this. + * + * This lives in a `--require`d setup file rather than at the top of a spec because ESM hoists + * `import` declarations above every statement in a module body: a shim written inline in a spec runs + * only *after* the wasm module it is meant to support has been imported and evaluated. It happens to + * work there because getrandom resolves its backend lazily on first use, but that is a fragile thing + * to depend on. Loading it here runs it before any spec module is evaluated. + * + * Defined as a **self-removing** accessor rather than a plain global. A global `module` is how + * libraries detect CommonJS (`typeof module !== "undefined"`), and leaving one in place for the whole + * run would tell that lie to every dependency in the suite. getrandom reads `module.require` exactly + * once and caches the resulting `crypto` object, so deleting the property on first read narrows the + * window to that single access. If something else consumes it first, getrandom then fails loudly + * rather than any test silently changing behavior. + * + * The proper fix is a custom getrandom backend in Rust that goes straight to + * `globalThis.crypto.getRandomValues`, removing the Node-detection path — then this file can go. + */ +const g = globalThis as unknown as { module?: { require: NodeRequire } }; + +if (g.module === undefined) { + Object.defineProperty(g, "module", { + configurable: true, + enumerable: false, + get(): { require: NodeRequire } { + delete g.module; + return { require: createRequire(import.meta.url) }; + }, + }); +}