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 a1836ee0408..a41495290db 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). + 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 @@ -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(); 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) }), + ); + }); +});