From e0dd894cb9521f93fa24fcdf1bb9da34f974cd83 Mon Sep 17 00:00:00 2001 From: Veetrag Jain Date: Wed, 15 Jul 2026 11:23:34 +0530 Subject: [PATCH] feat(wasm-utxo): parse and encode zcash v5 transaction format Ticket: CSHLD-1059 --- .../src/fixed_script_wallet/bitgo_psbt/mod.rs | 1 + .../bitgo_psbt/zcash_psbt.rs | 45 +++- packages/wasm-utxo/src/inspect/psbt.rs | 7 + packages/wasm-utxo/src/zcash/transaction.rs | 241 +++++++++++++++--- 4 files changed, 245 insertions(+), 49 deletions(-) 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 3cd659b3a38..774666c4416 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 @@ -1570,6 +1570,7 @@ impl BitGoPsbt { ), expiry_height: Some(zcash_psbt.expiry_height.unwrap_or(0)), sapling_fields: zcash_psbt.sapling_fields.clone(), + consensus_branch_id: None, }; crate::zcash::transaction::encode_zcash_transaction_parts(&parts) } 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..e916ccec888 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 @@ -9,7 +9,8 @@ use miniscript::bitcoin::{Transaction, VarInt}; use std::io::Read; pub use crate::zcash::transaction::{ - decode_zcash_transaction_meta, ZcashTransactionMeta, ZCASH_SAPLING_VERSION_GROUP_ID, + decode_zcash_transaction_meta, ZcashTransactionMeta, ZCASH_NU5_VERSION_GROUP_ID, + ZCASH_SAPLING_VERSION_GROUP_ID, }; /// A Zcash-compatible PSBT that can handle overwintered transactions @@ -135,15 +136,27 @@ impl ZcashBitGoPsbt { &self, tx: &Transaction, ) -> Result, super::DeserializeError> { + let vgid = self + .version_group_id + .unwrap_or(ZCASH_SAPLING_VERSION_GROUP_ID); + let consensus_branch_id = if vgid == ZCASH_NU5_VERSION_GROUP_ID { + Some( + super::propkv::get_zec_consensus_branch_id(&self.psbt).ok_or_else(|| { + super::DeserializeError::Network( + "v5 transaction requires consensus_branch_id".to_string(), + ) + })?, + ) + } else { + None + }; let parts = crate::zcash::transaction::ZcashTransactionParts { transaction: tx.clone(), is_overwintered: true, - version_group_id: Some( - self.version_group_id - .unwrap_or(ZCASH_SAPLING_VERSION_GROUP_ID), - ), + version_group_id: Some(vgid), expiry_height: Some(self.expiry_height.unwrap_or(0)), sapling_fields: self.sapling_fields.clone(), + consensus_branch_id, }; crate::zcash::transaction::encode_zcash_transaction_parts(&parts) .map_err(super::DeserializeError::Network) @@ -180,6 +193,7 @@ impl ZcashBitGoPsbt { .unwrap_or(ZCASH_SAPLING_VERSION_GROUP_ID); let expiry_height = self.expiry_height.unwrap_or(0); let sapling_fields = self.sapling_fields; + let stored_branch_id = super::propkv::get_zec_consensus_branch_id(&self.psbt); let map_extract_err = |e: ExtractTxError| match e { ExtractTxError::AbsurdFeeRate { .. } => { @@ -203,21 +217,38 @@ impl ZcashBitGoPsbt { .map_err(map_extract_err)?, }; + let consensus_branch_id = if version_group_id == ZCASH_NU5_VERSION_GROUP_ID { + Some(stored_branch_id.ok_or_else(|| { + super::DeserializeError::Network( + "v5 transaction requires consensus_branch_id".to_string(), + ) + })?) + } else { + None + }; let parts = crate::zcash::transaction::ZcashTransactionParts { transaction: tx, is_overwintered: true, version_group_id: Some(version_group_id), expiry_height: Some(expiry_height), sapling_fields, + consensus_branch_id, }; crate::zcash::transaction::encode_zcash_transaction_parts(&parts) .map_err(super::DeserializeError::Network) } - /// Compute the transaction ID for the unsigned Zcash transaction + /// Compute the transaction ID for the unsigned Zcash transaction. /// - /// The txid is the double SHA256 of the full Zcash transaction bytes. + /// v4 (Sapling): double SHA-256 of the serialized transaction bytes. + /// v5 (NU5/ZIP-225): ZIP-244 recursive BLAKE2b hash tree — not yet implemented. pub fn compute_txid(&self) -> Result<[u8; 32], super::DeserializeError> { + if self.version_group_id == Some(ZCASH_NU5_VERSION_GROUP_ID) { + return Err(super::DeserializeError::Network( + "compute_txid for v5 transactions requires ZIP-244, which is not yet implemented" + .to_string(), + )); + } use miniscript::bitcoin::hashes::{sha256d, Hash}; let tx_bytes = self.extract_unsigned_zcash_transaction()?; let hash = sha256d::Hash::hash(&tx_bytes); diff --git a/packages/wasm-utxo/src/inspect/psbt.rs b/packages/wasm-utxo/src/inspect/psbt.rs index 61fd19aca9f..5a1c9bdd481 100644 --- a/packages/wasm-utxo/src/inspect/psbt.rs +++ b/packages/wasm-utxo/src/inspect/psbt.rs @@ -570,6 +570,13 @@ pub fn zcash_psbt_to_node(zcash_psbt: &ZcashBitGoPsbt, network: Network) -> Node version_group_id: zcash_psbt.version_group_id, expiry_height: zcash_psbt.expiry_height, sapling_fields: zcash_psbt.sapling_fields.clone(), + consensus_branch_id: if zcash_psbt.version_group_id + == Some(crate::zcash::transaction::ZCASH_NU5_VERSION_GROUP_ID) + { + crate::fixed_script_wallet::bitgo_psbt::propkv::get_zec_consensus_branch_id(psbt) + } else { + None + }, }; psbt_node.add_child(zcash_tx_to_node(&parts, network)); diff --git a/packages/wasm-utxo/src/zcash/transaction.rs b/packages/wasm-utxo/src/zcash/transaction.rs index bd02b0f35ea..069a984bea3 100644 --- a/packages/wasm-utxo/src/zcash/transaction.rs +++ b/packages/wasm-utxo/src/zcash/transaction.rs @@ -7,9 +7,12 @@ use miniscript::bitcoin::consensus::{Decodable, Encodable}; use miniscript::bitcoin::{Transaction, TxIn, TxOut}; -/// Zcash Sapling version group ID +/// Zcash Sapling (v4) version group ID pub const ZCASH_SAPLING_VERSION_GROUP_ID: u32 = 0x892F2085; +/// Zcash NU5 (v5) version group ID (ZIP-225) +pub const ZCASH_NU5_VERSION_GROUP_ID: u32 = 0x26A7270A; + /// Parsed Zcash transaction fields, preserving Zcash-specific data needed for round-tripping. #[derive(Debug, Clone)] pub struct ZcashTransactionParts { @@ -21,10 +24,12 @@ pub struct ZcashTransactionParts { pub version_group_id: Option, /// Zcash-specific: expiry height (present only for overwintered transactions) pub expiry_height: Option, - /// Remaining bytes after lock_time / expiry_height (Sapling/Orchard fields, etc.) + /// Remaining bytes after transparent fields (Sapling/Orchard bundles, etc.) /// /// Preserved verbatim so the transaction can be serialized back to the exact same bytes. pub sapling_fields: Vec, + /// Zcash-specific: consensus branch ID (v5 / ZIP-225 only; encoded before transparent fields) + pub consensus_branch_id: Option, } impl ZcashTransactionParts { @@ -41,6 +46,7 @@ impl ZcashTransactionParts { version_group_id: Some(version_group_id), expiry_height: Some(expiry_height), sapling_fields: vec![0u8; 11], + consensus_branch_id: None, } } @@ -77,6 +83,8 @@ pub struct ZcashTransactionMeta { pub expiry_height: Option, /// Whether this is a Zcash overwintered transaction pub is_overwintered: bool, + /// Zcash-specific: Consensus branch ID (v5 only) + pub consensus_branch_id: Option, } fn version_i32_to_u32(version: i32) -> Result { @@ -95,6 +103,7 @@ pub fn decode_zcash_transaction_meta(bytes: &[u8]) -> Result Result = - Vec::consensus_decode(&mut slice).map_err(|e| format!("Failed to decode inputs: {}", e))?; + let is_v5 = version_group_id == Some(ZCASH_NU5_VERSION_GROUP_ID); - // Read outputs - let outputs: Vec = Vec::consensus_decode(&mut slice) - .map_err(|e| format!("Failed to decode outputs: {}", e))?; + // ZIP-225 (v5): consensus_branch_id | lock_time | expiry_height | inputs | outputs | bundles + // ZIP-243 (v4): inputs | outputs | lock_time | expiry_height | sapling_fields + let (inputs, outputs, lock_time, expiry_height, consensus_branch_id, sapling_fields) = if is_v5 + { + let consensus_branch_id = u32::consensus_decode(&mut slice) + .map_err(|e| format!("Failed to decode consensus_branch_id: {}", e))?; - // Read lock_time - let lock_time = miniscript::bitcoin::locktime::absolute::LockTime::consensus_decode(&mut slice) - .map_err(|e| format!("Failed to decode lock_time: {}", e))?; + let lock_time = + miniscript::bitcoin::locktime::absolute::LockTime::consensus_decode(&mut slice) + .map_err(|e| format!("Failed to decode lock_time: {}", e))?; - // Read expiry height if overwintered - let expiry_height = if is_overwintered { - Some( - u32::consensus_decode(&mut slice) - .map_err(|e| format!("Failed to decode expiry_height: {}", e))?, + let expiry_height = u32::consensus_decode(&mut slice) + .map_err(|e| format!("Failed to decode expiry_height: {}", e))?; + + let inputs: Vec = Vec::consensus_decode(&mut slice) + .map_err(|e| format!("Failed to decode inputs: {}", e))?; + + let outputs: Vec = Vec::consensus_decode(&mut slice) + .map_err(|e| format!("Failed to decode outputs: {}", e))?; + + let remaining = slice.to_vec(); + ( + inputs, + outputs, + lock_time, + Some(expiry_height), + Some(consensus_branch_id), + remaining, ) } else { - None - }; + let inputs: Vec = Vec::consensus_decode(&mut slice) + .map_err(|e| format!("Failed to decode inputs: {}", e))?; + + let outputs: Vec = Vec::consensus_decode(&mut slice) + .map_err(|e| format!("Failed to decode outputs: {}", e))?; + + let lock_time = + miniscript::bitcoin::locktime::absolute::LockTime::consensus_decode(&mut slice) + .map_err(|e| format!("Failed to decode lock_time: {}", e))?; + + let expiry_height = if is_overwintered { + Some( + u32::consensus_decode(&mut slice) + .map_err(|e| format!("Failed to decode expiry_height: {}", e))?, + ) + } else { + None + }; - // Capture any remaining bytes (Sapling fields: valueBalance, nShieldedSpend, nShieldedOutput, etc.) - let sapling_fields = slice.to_vec(); + let remaining = slice.to_vec(); + (inputs, outputs, lock_time, expiry_height, None, remaining) + }; // Create transaction with standard version (without overwintered bit) let transaction = Transaction { @@ -158,6 +197,7 @@ pub fn decode_zcash_transaction_parts(bytes: &[u8]) -> Result Result