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 74673e54a5d..c81c1f2fe77 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 @@ -497,6 +497,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: @@ -1966,6 +1991,7 @@ impl BitGoPsbt { input_index: usize, privkey: &secp256k1::SecretKey, ) -> Result<(), String> { + self.ensure_not_ironwood_v6()?; use miniscript::bitcoin::PublicKey; // Get network before mutable borrow @@ -2291,6 +2317,7 @@ impl BitGoPsbt { input_index: usize, privkey: &secp256k1::SecretKey, ) -> Result<(), String> { + self.ensure_not_ironwood_v6()?; let psbt = self.psbt(); if input_index >= psbt.inputs.len() { return Err(format!( @@ -2394,6 +2421,21 @@ impl BitGoPsbt { } } BitGoPsbt::Zcash(ref mut zcash_psbt, _network) => { + // v6 (Ironwood) inputs are signed over the ZIP-244 digest, not ZIP-243. Signing + // here would put a signature into `partial_sigs` that no verifier can satisfy, and + // nothing downstream re-checks it — so refuse. `SignError` carries no message, so + // callers going through the `String`-returning wrappers get the detail from + // `ensure_not_ironwood_v6`. Use `add_v6_transparent_signature` instead. + if zcash_psbt.is_ironwood_v6() { + return Err(( + Default::default(), + std::collections::BTreeMap::from_iter([( + 0, + miniscript::bitcoin::psbt::SignError::UnknownOutputType, + )]), + )); + } + // Extract consensus branch ID from PSBT proprietary map let branch_id = propkv::get_zec_consensus_branch_id(&zcash_psbt.psbt).ok_or_else(|| { @@ -2420,6 +2462,18 @@ impl BitGoPsbt { } } + /// Reject v6 (Ironwood) PSBTs on the v4/Sapling-shaped paths, with a message explaining which + /// dedicated method to use instead. [`Self::sign`] applies the same rule, but its `SignError` + /// return type carries no message, so the `String`-returning wrappers check here as well. + pub(crate) fn ensure_not_ironwood_v6(&self) -> Result<(), String> { + match self { + BitGoPsbt::Zcash(z, _) if z.is_ironwood_v6() => { + Err(zcash_psbt::V6_NOT_SUPPORTED_BY_V4_PATH.to_string()) + } + _ => Ok(()), + } + } + /// Sign all non-MuSig2 inputs with the provided xpriv in a single pass. /// /// This is more efficient than calling `sign_with_privkey` for each input individually @@ -2438,6 +2492,7 @@ impl BitGoPsbt { &mut self, xpriv: &miniscript::bitcoin::bip32::Xpriv, ) -> Result { + self.ensure_not_ironwood_v6()?; let secp = secp256k1::Secp256k1::new(); // Sign all inputs - miniscript handles this efficiently @@ -2503,6 +2558,7 @@ impl BitGoPsbt { input_index: usize, xpriv: &miniscript::bitcoin::bip32::Xpriv, ) -> Result<(), String> { + self.ensure_not_ironwood_v6()?; let psbt = self.psbt(); if input_index >= psbt.inputs.len() { return Err(format!( @@ -2840,6 +2896,13 @@ impl BitGoPsbt { sighash::SighashCacheZcashExt, }; + // v6 (Ironwood) inputs are signed over the ZIP-244 digest, not ZIP-243. Signing here would + // insert a signature into `partial_sigs` that no verifier can ever satisfy, and nothing + // downstream re-checks it — so refuse instead. + if version_group_id == crate::zcash::transaction::ZCASH_IRONWOOD_VERSION_GROUP_ID { + return Err(zcash_psbt::V6_NOT_SUPPORTED_BY_V4_PATH.to_string()); + } + // Get input value for sighash computation let input = &psbt.inputs[input_index]; let prevout = psbt.unsigned_tx.input[input_index].previous_output; 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..4c9552e4bb7 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 @@ -246,6 +246,101 @@ pub fn set_zec_consensus_branch_id(psbt: &mut miniscript::bitcoin::psbt::Psbt, b psbt.proprietary.insert(key, value); } +/// Zcash v6 (Ironwood) proprietary namespace — its own private subtype space, so v6 keys don't +/// consume slots in the shared `BITGO` space (which is a hard-limited single byte shared by every +/// other BitGo proprietary key: MuSig2, PayGo, BIP322, WasmUtxo, etc). +pub const BITGO_ZEC_V6: &[u8] = b"BITGO/ZEC/V6"; + +/// Subtypes within the [`BITGO_ZEC_V6`] namespace. +/// +/// This mirrors the v4 `ZecConsensusBranchId` (0x00 under the legacy `BITGO` prefix), but v4 is +/// untouched: the two 0x00 branch-id keys are unambiguous because their prefixes differ. +/// +/// Note that a v6 PSBT still carries the legacy `BITGO`/`ZecConsensusBranchId` key as well, because +/// [`ZcashBitGoPsbt::new`] writes it for every Zcash PSBT. The v6 code paths read only the key in +/// this namespace; the legacy one is redundant but harmless, and keeping it means the shared +/// `new` constructor needs no v6 special case. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[repr(u8)] +pub enum ZecV6KeySubtype { + ConsensusBranchId = 0x00, + IronwoodPczt = 0x01, + VersionGroupId = 0x02, + ExpiryHeight = 0x03, +} + +fn set_zec_v6( + psbt: &mut miniscript::bitcoin::psbt::Psbt, + subtype: ZecV6KeySubtype, + value: Vec, +) { + let key = ProprietaryKey { + prefix: BITGO_ZEC_V6.to_vec(), + subtype: subtype as u8, + key: vec![], + }; + psbt.proprietary.insert(key, value); +} + +fn get_zec_v6(psbt: &miniscript::bitcoin::psbt::Psbt, subtype: ZecV6KeySubtype) -> Option> { + find_kv_iter(&psbt.proprietary, BITGO_ZEC_V6, Some(subtype as u8)) + .next() + .map(|(_, v)| v.clone()) +} + +fn set_zec_v6_u32( + psbt: &mut miniscript::bitcoin::psbt::Psbt, + subtype: ZecV6KeySubtype, + value: u32, +) { + set_zec_v6(psbt, subtype, value.to_le_bytes().to_vec()); +} + +fn get_zec_v6_u32(psbt: &miniscript::bitcoin::psbt::Psbt, subtype: ZecV6KeySubtype) -> Option { + let bytes = get_zec_v6(psbt, subtype)?; + Some(u32::from_le_bytes(bytes.as_slice().try_into().ok()?)) +} + +/// Store the Zcash v6 (Ironwood) consensus branch ID under the `BITGO_ZEC_V6` namespace. +pub fn set_zec_v6_consensus_branch_id(psbt: &mut miniscript::bitcoin::psbt::Psbt, branch_id: u32) { + set_zec_v6_u32(psbt, ZecV6KeySubtype::ConsensusBranchId, branch_id); +} + +/// Fetch the Zcash v6 (Ironwood) consensus branch ID from the `BITGO_ZEC_V6` namespace, if present. +pub fn get_zec_v6_consensus_branch_id(psbt: &miniscript::bitcoin::psbt::Psbt) -> Option { + get_zec_v6_u32(psbt, ZecV6KeySubtype::ConsensusBranchId) +} + +/// Store a serialized Ironwood (v6) PCZT bundle in the PSBT global proprietary map, under the +/// `BITGO_ZEC_V6` namespace's `IronwoodPczt` subtype. Overwrites any existing value. +pub fn set_ironwood_pczt(psbt: &mut miniscript::bitcoin::psbt::Psbt, bytes: Vec) { + set_zec_v6(psbt, ZecV6KeySubtype::IronwoodPczt, bytes); +} + +/// 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> { + get_zec_v6(psbt, ZecV6KeySubtype::IronwoodPczt) +} + +/// 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). +pub fn set_zec_v6_params( + psbt: &mut miniscript::bitcoin::psbt::Psbt, + version_group_id: u32, + expiry_height: u32, +) { + set_zec_v6_u32(psbt, ZecV6KeySubtype::VersionGroupId, version_group_id); + set_zec_v6_u32(psbt, ZecV6KeySubtype::ExpiryHeight, expiry_height); +} + +/// 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 vgid = get_zec_v6_u32(psbt, ZecV6KeySubtype::VersionGroupId)?; + let expiry = get_zec_v6_u32(psbt, ZecV6KeySubtype::ExpiryHeight)?; + Some((vgid, expiry)) +} + #[cfg(test)] mod tests { use super::*; @@ -310,6 +405,65 @@ 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])); + // expiry_height == 0 is a valid value, distinct from "absent". + assert_eq!(get_zec_v6_params(&psbt), Some((0xD884B698, 0))); + + // These keys live under the private BITGO_ZEC_V6 namespace, not the shared BITGO space. + for key in psbt.proprietary.keys() { + assert_eq!(key.prefix, BITGO_ZEC_V6); + } + } + + #[test] + fn test_zec_v6_consensus_branch_id_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_zec_v6_consensus_branch_id(&psbt), None); + + set_zec_v6_consensus_branch_id(&mut psbt, 0x736b_bdac); + assert_eq!(get_zec_v6_consensus_branch_id(&psbt), Some(0x736b_bdac)); + + // Coexists with the legacy (v4) key at the same subtype 0x00, disambiguated by prefix. + set_zec_consensus_branch_id(&mut psbt, 0xc2d6_d0b4); + assert_eq!(get_zec_consensus_branch_id(&psbt), Some(0xc2d6_d0b4)); + assert_eq!(get_zec_v6_consensus_branch_id(&psbt), Some(0x736b_bdac)); + } + #[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..a1836ee0408 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 @@ -31,6 +31,15 @@ pub struct ZcashBitGoPsbt { pub sapling_fields: Vec, } +/// Error returned when a v4/Sapling-shaped code path is handed a v6 (Ironwood) PSBT. The v4 wire +/// encoding and ZIP-243 sighash are both wrong for v6, so these paths fail loudly rather than +/// silently producing an unbroadcastable transaction or an unverifiable signature. Use the +/// dedicated `*_v6` / `*_ironwood_*` methods instead. +pub(crate) const V6_NOT_SUPPORTED_BY_V4_PATH: &str = + "this is a v6 (Ironwood) PSBT; use the dedicated v6 methods \ + (serialize_v6/deserialize_v6, v6_transparent_sighash, add_v6_transparent_signature, \ + combine_ironwood_proof) — the v4 path would produce an invalid transaction"; + impl ZcashBitGoPsbt { /// Create an empty Zcash PSBT directly without going through `BitGoPsbt`. pub(crate) fn new( @@ -118,6 +127,9 @@ impl ZcashBitGoPsbt { index: usize, input: &super::FixedScriptInput, ) -> Result<(), String> { + if self.is_ironwood_v6() { + return Err(V6_NOT_SUPPORTED_BY_V4_PATH.to_string()); + } let branch_id = super::propkv::get_zec_consensus_branch_id(&self.psbt) .ok_or_else(|| "missing consensus_branch_id".to_string())?; let ctx = super::SighashContext::Zcash { @@ -135,6 +147,13 @@ impl ZcashBitGoPsbt { &self, tx: &Transaction, ) -> Result, super::DeserializeError> { + // Chokepoint for the v4 wire encoding — also covers + // `extract_unsigned_zcash_transaction`, `compute_txid` and `serialize`. + if self.is_ironwood_v6() { + return Err(super::DeserializeError::Network( + V6_NOT_SUPPORTED_BY_V4_PATH.to_string(), + )); + } let parts = crate::zcash::transaction::ZcashTransactionParts { transaction: tx.clone(), is_overwintered: true, @@ -174,6 +193,12 @@ impl ZcashBitGoPsbt { ) -> Result, super::DeserializeError> { use miniscript::bitcoin::psbt::ExtractTxError; + if self.is_ironwood_v6() { + return Err(super::DeserializeError::Network( + V6_NOT_SUPPORTED_BY_V4_PATH.to_string(), + )); + } + // Capture Zcash-specific fields before consuming psbt let version_group_id = self .version_group_id @@ -543,6 +568,434 @@ 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, under the `BITGO_ZEC_V6` namespace's `IronwoodPczt` +// subtype. The v6 header params (`version_group_id`, `expiry_height`) live under that same +// namespace's `VersionGroupId`/`ExpiryHeight` subtypes; `version_group_id`'s 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`, which must be at or after NU6.3 activation. + /// + /// The height is checked against NU6.3 rather than merely Overwinter: a v6 transaction stamped + /// with a pre-NU6.3 branch id is only rejected at broadcast, long after signing. + 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 nu6_3 = crate::zcash::NetworkUpgrade::Nu6_3.activation_height(is_mainnet); + if block_height < nu6_3 { + return Err(format!( + "Block height {} is before NU6.3 (Ironwood) activation ({}) on {}; \ + v6 transactions are not valid before then", + block_height, + nu6_3, + if is_mainnet { "mainnet" } else { "testnet" } + )); + } + 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); + super::propkv::set_zec_v6_consensus_branch_id(&mut z.psbt, consensus_branch_id); + 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. + /// + /// Ordering relative to the transparent inputs/outputs does not matter — the shielded action + /// data does not depend on them — but the sighash does, so all transparent I/O must be in place + /// before signing. + /// + /// Exactly one shielded output is supported; calling this twice is an error rather than a silent + /// overwrite of the first note (whose value the transparent side would still be funding). + 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> { + if super::propkv::get_ironwood_pczt(&self.psbt).is_some() { + return Err( + "an Ironwood shielded output is already present; only one is supported".to_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() { + // `psbt.inputs` and `unsigned_tx.input` are parallel vectors by PSBT invariant, but a + // skew here would be a WASM abort rather than a JS exception, so surface it as an error. + let prevout = self + .psbt + .unsigned_tx + .input + .get(i) + .ok_or_else(|| format!("input {i}: no matching tx input"))? + .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_v6_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}; + + // Reject a key the input's redeem script does not contain: such a signature would be + // silently dropped at finalization, so fail at ingest where the caller can see it. + let redeem_script = self + .psbt + .inputs + .get(index) + .ok_or_else(|| format!("input {index} out of range"))? + .redeem_script + .as_ref() + .ok_or_else(|| format!("input {index}: no redeem script"))?; + let script_pubkeys = + crate::fixed_script_wallet::wallet_scripts::parse_multisig_script_2_of_3(redeem_script) + .map_err(|e| format!("input {index}: {e}"))?; + if !script_pubkeys + .iter() + .any(|pk| miniscript::bitcoin::PublicKey::from(*pk) == pubkey) + { + return Err(format!( + "input {index}: pubkey is not one of the redeem script's keys" + )); + } + + let sighash = self.v6_transparent_sighash(index)?; + let ecdsa_sig = + EcdsaSig::from_slice(sig).map_err(|e| format!("input {index}: bad signature: {e}"))?; + // `v6_transparent_sighash` always digests as SIGHASH_ALL; any other type byte would be + // re-emitted verbatim by `finalized_transparent_tx`, producing a scriptSig whose signature + // doesn't match the sighash a verifier recomputes for that type — fail at ingest instead. + const SIGHASH_ALL: u32 = miniscript::bitcoin::sighash::EcdsaSighashType::All as u32; + if ecdsa_sig.sighash_type != SIGHASH_ALL { + return Err(format!( + "input {index}: signature sighash type must be SIGHASH_ALL (0x{SIGHASH_ALL:02x}), got 0x{:02x}", + ecdsa_sig.sighash_type + )); + } + 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 + .get(i) + .ok_or_else(|| format!("input {i}: no matching PSBT input"))?; + 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}"))?; + const REQUIRED_SIGS: usize = 2; + + // OP_0 (the multisig off-by-one dummy), then exactly REQUIRED_SIGS signatures in pubkey + // order. Pushing every collected signature would break a 2-of-3 input signed by all + // three keys: OP_CHECKMULTISIG pops exactly 2 and the extra push fails the script. + let mut builder = Builder::new().push_opcode(OP_PUSHBYTES_0); + let mut sig_count = 0usize; + for pk in &pubkeys { + if sig_count == REQUIRED_SIGS { + break; + } + 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); + sig_count += 1; + } + } + if sig_count < REQUIRED_SIGS { + return Err(format!( + "input {i}: only {sig_count} of {REQUIRED_SIGS} required signatures collected" + )); + } + 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 both the dummy spend-auth signature (in the IO finalizer) and the binding + /// signature (in the Transaction Extractor). Consumes `self`. + pub fn combine_ironwood_proof( + self, + proof: Vec, + mut rng: R, + ) -> Result, String> { + use crate::zcash::{ironwood_build, ironwood_pczt}; + + // Finalize the transparent inputs first: it enforces the 2-of-3 signature threshold, so an + // under-signed PSBT fails here instead of after the (expensive) shielded finalize/combine. + let transparent = self.finalized_transparent_tx()?; + + // 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, &mut 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())?; + let full_bundle = + ironwood_build::combine(&proven, sighash, &mut rng).map_err(|e| e.to_string())?; + + 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(), + ) + })?; + // Validate rather than trust: an unchecked version_group_id would leave + // `is_ironwood_v6()` false on a PSBT that just came out of `deserialize_v6`, which would + // then route `serialize` back down the v4 path. + if version_group_id != crate::zcash::transaction::ZCASH_IRONWOOD_VERSION_GROUP_ID { + return Err(super::DeserializeError::Network(format!( + "PSBT declares version_group_id {:#010x}, expected the Ironwood id {:#010x}", + version_group_id, + crate::zcash::transaction::ZCASH_IRONWOOD_VERSION_GROUP_ID + ))); + } + // Defense-in-depth: a v6 PSBT missing these would otherwise deserialize fine and only fail + // later, in v6_txid/combine_ironwood_proof, with a less obvious error. + if super::propkv::get_zec_v6_consensus_branch_id(&psbt).is_none() { + return Err(super::DeserializeError::Network( + "v6 PSBT is missing its consensus branch id".to_string(), + )); + } + if super::propkv::get_ironwood_pczt(&psbt).is_none() { + return Err(super::DeserializeError::Network( + "v6 PSBT is missing its Ironwood PCZT".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 +1106,685 @@ 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::{DerivationPath, 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; + use std::str::FromStr; + + /// 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. + /// + /// `RootWalletKeys::new` derives every (chain, index) key under a fixed `m/0/0` prefix (see + /// `RootWalletKeys::new`), so the path here must match: `m/0/0//`. + fn signing_secret_keys(seed: &str, chain: u32, index: u32) -> [SecretKey; 3] { + let secp = Secp256k1::new(); + let prefix = DerivationPath::from_str("m/0/0").unwrap(); + let path = prefix.extend(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" + ); + } + + /// The root `Xpriv` behind key `i` of `get_test_wallet_keys(seed)` (same `seed.N` scheme). + fn test_wallet_xpriv(seed: &str, i: u8) -> Xpriv { + let hash = sha256::Hash::hash(format!("{seed}.{i}").as_bytes()).to_byte_array(); + Xpriv::new_master(BtcNetwork::Testnet, &hash).unwrap() + } + + /// A minimal v6 PSBT with one 2-of-3 P2SH input, a change output, and a shielded output. + fn build_shield_psbt(seed: &str) -> ZcashBitGoPsbt { + let wallet_keys = RootWalletKeys::new(get_test_wallet_keys(seed)); + let mut psbt = BitGoPsbt::new_zcash_v6_at_height( + Network::ZcashTestnet, + &wallet_keys, + NetworkUpgrade::Nu6_3.testnet_activation_height(), + None, + None, + ) + .unwrap(); + psbt.add_wallet_input( + Txid::from_byte_array([0x33u8; 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"); + }; + z.add_ironwood_output( + &test_recipient(), + 100_000_000, + None, + &Anchor::empty_tree().to_bytes(), + &[0u8; 512], + OsRng, + ) + .unwrap(); + z + } + + /// `new_v6_at_height` rejects a height before NU6.3 rather than stamping the transaction with a + /// branch id that only fails at broadcast. + #[test] + fn new_v6_at_height_rejects_pre_nu6_3_height() { + let wallet_keys = RootWalletKeys::new(get_test_wallet_keys("v6_height")); + let nu6_3 = NetworkUpgrade::Nu6_3.testnet_activation_height(); + let err = ZcashBitGoPsbt::new_v6_at_height( + Network::ZcashTestnet, + &wallet_keys, + nu6_3 - 1, + None, + None, + ) + .unwrap_err(); + assert!(err.contains("NU6.3"), "unexpected error: {err}"); + // The activation height itself is accepted. + assert!(ZcashBitGoPsbt::new_v6_at_height( + Network::ZcashTestnet, + &wallet_keys, + nu6_3, + None, + None + ) + .is_ok()); + } + + /// A second shielded output is an error, not a silent overwrite of the first note. + #[test] + fn add_ironwood_output_twice_is_rejected() { + let mut z = build_shield_psbt("v6_double_output"); + let err = z + .add_ironwood_output( + &test_recipient(), + 1, + None, + &Anchor::empty_tree().to_bytes(), + &[0u8; 512], + OsRng, + ) + .unwrap_err(); + assert!(err.contains("already present"), "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] + fn add_v6_signature_rejects_foreign_pubkey() { + let mut z = build_shield_psbt("v6_foreign_key"); + let secp = Secp256k1::new(); + let sk = SecretKey::from_slice(&[0x11u8; 32]).unwrap(); + let secp_pk = crate::bitcoin::secp256k1::PublicKey::from_secret_key(&secp, &sk); + let pubkey = PublicKey::from(CompressedPublicKey(secp_pk)); + let msg = Message::from_digest(z.v6_transparent_sighash(0).unwrap()); + let mut der = secp.sign_ecdsa(&msg, &sk).serialize_der().to_vec(); + der.push(0x01); + let err = z.add_v6_transparent_signature(0, pubkey, &der).unwrap_err(); + assert!( + err.contains("not one of the redeem script's keys"), + "unexpected error: {err}" + ); + } + + /// A signature tagged with a sighash type other than SIGHASH_ALL is rejected at ingest: the + /// sighash it was verified against is always the SIGHASH_ALL digest, so re-emitting it with a + /// different type byte would make a verifier recompute a different (mismatching) digest. + #[test] + fn add_v6_signature_rejects_non_sighash_all() { + let mut z = build_shield_psbt("v6_wrong_sighash_type"); + let seed = "v6_wrong_sighash_type"; + let secp = Secp256k1::new(); + let sk = signing_secret_keys(seed, 0, 0)[0]; + let secp_pk = crate::bitcoin::secp256k1::PublicKey::from_secret_key(&secp, &sk); + let pubkey = PublicKey::from(CompressedPublicKey(secp_pk)); + let msg = Message::from_digest(z.v6_transparent_sighash(0).unwrap()); + let mut der = secp.sign_ecdsa(&msg, &sk).serialize_der().to_vec(); + der.push(0x02); // SIGHASH_NONE, not SIGHASH_ALL + let err = z.add_v6_transparent_signature(0, pubkey, &der).unwrap_err(); + assert!(err.contains("SIGHASH_ALL"), "unexpected error: {err}"); + } + + /// A 2-of-3 input signed by all three keys still produces a 2-signature scriptSig; + /// `OP_CHECKMULTISIG` pops exactly two, so a third push would fail the script. + #[test] + fn combine_uses_only_required_signatures() { + let seed = "v6_three_sigs"; + let mut z = build_shield_psbt(seed); + let secp = Secp256k1::new(); + let msg = Message::from_digest(z.v6_transparent_sighash(0).unwrap()); + for sk in signing_secret_keys(seed, 0, 0) { + 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); + z.add_v6_transparent_signature(0, pubkey, &der).unwrap(); + } + assert_eq!(z.psbt.inputs[0].partial_sigs.len(), 3); + let redeem_script = z.psbt.inputs[0].redeem_script.clone().unwrap(); + + let raw = z + .combine_ironwood_proof(vec![0u8; Proof::expected_proof_size(1)], OsRng) + .unwrap(); + let tx = crate::zcash::v6::decode_v6_transaction(&raw).unwrap(); + // rust-bitcoin decodes OP_0 as a zero-length push, so the expected shape is + // ` ` — four pushes, two of them signatures. A third + // signature here would make OP_CHECKMULTISIG fail on the leftover stack element. + let pushes: Vec> = tx.transparent.input[0] + .script_sig + .instructions() + .map(|i| i.expect("valid scriptSig")) + .filter_map(|i| match i { + crate::bitcoin::script::Instruction::PushBytes(pb) => Some(pb.as_bytes().to_vec()), + crate::bitcoin::script::Instruction::Op(_) => None, + }) + .collect(); + assert_eq!(pushes.len(), 4, "OP_0 "); + assert!(pushes[0].is_empty(), "OP_0 dummy"); + assert_eq!(pushes[3], redeem_script.as_bytes()); + // The two signatures are the first two redeem-script keys that signed, in script order. + for sig in &pushes[1..3] { + assert_eq!(*sig.last().unwrap(), 0x01, "SIGHASH_ALL"); + } + } + + /// An under-signed transparent input is rejected before the (expensive) shielded + /// finalize/combine work runs, not after. + #[test] + fn combine_ironwood_proof_fails_fast_on_missing_signatures() { + let mut z = build_shield_psbt("v6_undersigned"); + let seed = "v6_undersigned"; + let secp = Secp256k1::new(); + let msg = Message::from_digest(z.v6_transparent_sighash(0).unwrap()); + // Only one of the required two signatures. + let sk = signing_secret_keys(seed, 0, 0)[0]; + 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); + z.add_v6_transparent_signature(0, pubkey, &der).unwrap(); + + let err = z + .combine_ironwood_proof(vec![0u8; Proof::expected_proof_size(1)], OsRng) + .unwrap_err(); + assert!( + err.contains("required signatures collected"), + "unexpected error: {err}" + ); + } + + /// 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). + #[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(), + z.compute_txid().unwrap_err().to_string(), + z.clone().extract_tx().unwrap_err().to_string(), + ] { + assert!( + err.contains("v6 (Ironwood) PSBT"), + "unexpected error: {err}" + ); + } + + // 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 + .sign_all_with_xpriv(&test_wallet_xpriv("v6_v4_guard", 0)) + .unwrap_err(); + assert!( + err.contains("v6 (Ironwood) PSBT"), + "unexpected error: {err}" + ); + assert!( + generic.psbt().inputs[0].partial_sigs.is_empty(), + "no ZIP-243 signature reached partial_sigs" + ); + } + + /// `deserialize_v6` validates the version group id rather than trusting it — an unchecked value + /// would leave `is_ironwood_v6()` false and route `serialize` back down the v4 path. + #[test] + fn deserialize_v6_rejects_a_non_ironwood_version_group_id() { + let mut z = build_shield_psbt("v6_bad_vgid"); + crate::fixed_script_wallet::bitgo_psbt::propkv::set_zec_v6_params( + &mut z.psbt, + ZCASH_SAPLING_VERSION_GROUP_ID, + 0, + ); + let err = ZcashBitGoPsbt::deserialize_v6(&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] + fn deserialize_v6_rejects_a_missing_branch_id() { + let z = build_shield_psbt("v6_missing_branch_id"); + let mut psbt = z.psbt.clone(); + psbt.proprietary.retain(|k, _| { + !(k.prefix == crate::fixed_script_wallet::bitgo_psbt::propkv::BITGO_ZEC_V6 + && k.subtype == 0x00) + }); + let err = ZcashBitGoPsbt::deserialize_v6(&psbt.serialize(), Network::ZcashTestnet) + .unwrap_err() + .to_string(); + assert!( + err.contains("missing its consensus branch id"), + "unexpected error: {err}" + ); + } + + /// `deserialize_v6` rejects a PSBT missing its Ironwood PCZT, rather than deserializing + /// successfully and failing later (with a less obvious error) in v6_txid/combine. + #[test] + fn deserialize_v6_rejects_a_missing_pczt() { + let z = build_shield_psbt("v6_missing_pczt"); + let mut psbt = z.psbt.clone(); + psbt.proprietary.retain(|k, _| { + !(k.prefix == crate::fixed_script_wallet::bitgo_psbt::propkv::BITGO_ZEC_V6 + && k.subtype == 0x01) + }); + let err = ZcashBitGoPsbt::deserialize_v6(&psbt.serialize(), Network::ZcashTestnet) + .unwrap_err() + .to_string(); + assert!( + err.contains("missing its Ironwood PCZT"), + "unexpected error: {err}" + ); + } + + /// 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() + } + + /// The public/consensus-committed values from the reference build of the on-chain `shield1zec` + /// transaction. The spent output's value and scriptPubKey are committed by ZIP-244 but are not + /// on the wire, so they can only come from the reference — hence the fixture rather than + /// literals in the test body. + #[derive(serde::Deserialize)] + struct Shield1ZecDetails { + txid: String, + expiry_height: u32, + lock_time: u32, + transparent_utxos_spent: Vec, + value_balance_zat: i64, + flags_byte: u8, + num_actions: usize, + transparent_public_key_sec1: String, + } + + #[derive(serde::Deserialize)] + struct Shield1ZecUtxo { + txid: String, + index: u32, + value_zat: i64, + script_pubkey: String, + } + + fn shield1zec_details() -> Shield1ZecDetails { + serde_json::from_str( + &crate::fixed_script_wallet::test_utils::fixtures::load_fixture( + "zcash/v6_shield1zec_details.json", + ) + .unwrap(), + ) + .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, and the PSBT's + /// `v6_txid` reproduces the transaction's **real** txid. + /// + /// This closes a gap the synthetic end-to-end test cannot: there the sighash is computed for + /// both signing and verifying by the same code, so a consistently-wrong spent-output + /// value/script would still verify. 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 and scriptCode into the sighash correctly. + /// + /// The txid assertion is the complementary half: the ZIP-244 txid does not commit to input + /// amounts/scripts (so it cannot catch what the sighash check catches), but it does commit to + /// the shielded action data, which pins `pczt_bytes_from_action_data` → `ironwood_action_data` + /// → `to_v6_transaction` against a value produced entirely outside this codebase. + #[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); + + // Cross-check the decoded tx against the reference's record of it, so a fixture/raw-tx + // mismatch fails here rather than as a confusing sighash mismatch below. + let details = shield1zec_details(); + assert_eq!(details.transparent_utxos_spent.len(), 1); + let spent = &details.transparent_utxos_spent[0]; + assert_eq!(tx.expiry_height, details.expiry_height); + assert_eq!( + tx.transparent.lock_time.to_consensus_u32(), + details.lock_time + ); + assert_eq!( + tx.transparent.input[0].previous_output.txid.to_string(), + spent.txid + ); + assert_eq!(tx.transparent.input[0].previous_output.vout, spent.index); + assert_eq!(bundle.actions.len(), details.num_actions); + assert_eq!(bundle.value_balance, details.value_balance_zat); + assert_eq!(bundle.flags, details.flags_byte); + + // Spent output committed by ZIP-244 but absent from the wire (from the reference build). + let prevout_value: i64 = spent.value_zat; + let prevout_script = ScriptBuf::from(hex::decode(&spent.script_pubkey).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]; + assert_eq!( + hex::encode(pubkey_bytes), + details.transparent_public_key_sec1, + "on-chain scriptSig pubkey == the reference's signing key" + ); + + // 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() + }; + // `redeem_script` is overloaded here to carry the scriptCode, not a real P2SH redeem + // script: for a P2PKH input the scriptCode is just the scriptPubKey, and the wrapper below + // reads scriptCode 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"); + + // And the PSBT reproduces the transaction's real, externally-produced txid — the shielded + // action data survived the PCZT round-trip byte-for-byte. `v6_txid` is internal byte order; + // the fixture records the display order. + let mut psbt_txid = z.v6_txid().unwrap(); + psbt_txid.reverse(); + assert_eq!( + hex::encode(psbt_txid), + details.txid, + "PSBT-derived v6 txid == the on-chain txid" + ); + } +} diff --git a/packages/wasm-utxo/src/zcash/ironwood_build.rs b/packages/wasm-utxo/src/zcash/ironwood_build.rs index 3eb8e3ca985..b5cc33ee62f 100644 --- a/packages/wasm-utxo/src/zcash/ironwood_build.rs +++ b/packages/wasm-utxo/src/zcash/ironwood_build.rs @@ -15,8 +15,7 @@ //! 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`. +//! service so the shipped WASM never links the circuit. //! //! ## Why the proof can be filled in after signing //! diff --git a/packages/wasm-utxo/test/fixtures/zcash/v6_shield1zec_details.json b/packages/wasm-utxo/test/fixtures/zcash/v6_shield1zec_details.json new file mode 100644 index 00000000000..5710c312914 --- /dev/null +++ b/packages/wasm-utxo/test/fixtures/zcash/v6_shield1zec_details.json @@ -0,0 +1,26 @@ +{ + "description": "Transparent -> Ironwood shielding transaction (v6, ironwood_v3, PostNu6_3 circuit)", + "network": "test", + "txid": "667e8ed32b6066f90304cb67e0833f4c1c429817ebe699f648f4237319d6a701", + "consensus_branch_id": "0x37a5165b", + "version_group_id": "0xd884b698", + "expiry_height": 0, + "lock_time": 0, + "transparent_utxos_spent": [ + { + "txid": "058886a928ab51e1e4ded8174717cb5b386644463601afe44f7ea8e302150dfa", + "index": 0, + "value_zat": 313990000, + "script_pubkey": "76a9147c6b843a25873c036aff575516e3802bcc47f63488ac" + } + ], + "shield_amount_zat": 100000000, + "fee_zat": 20000, + "change_zat": 213970000, + "value_balance_zat": -100000000, + "ironwood_anchor": "56165f00523efc8379b8495dd92029cee3fcf32547135cd2653e4fd1abfbe415", + "flags_byte": 7, + "num_actions": 1, + "proof_size_bytes": 4992, + "transparent_public_key_sec1": "0349f86487c6bfe475672cae5bf468ab6985de32b1b2aa8729aa7758bf57c6f747" +}