From 31f78615260688de34b3d714c03d29a889ae6b80 Mon Sep 17 00:00:00 2001 From: Veetrag Jain Date: Mon, 7 Sep 2026 19:32:36 +0530 Subject: [PATCH] feat(wasm-utxo): add Zcash PSBT version detection Ticket: CSHLD-1674 --- .../js/fixedScriptWallet/ZcashBitGoPsbt.ts | 55 +++ .../wasm-utxo/js/fixedScriptWallet/index.ts | 3 +- packages/wasm-utxo/js/index.ts | 9 + .../src/fixed_script_wallet/bitgo_psbt/mod.rs | 4 +- .../bitgo_psbt/zcash_psbt.rs | 11 +- .../src/wasm/fixed_script_wallet/mod.rs | 11 + packages/wasm-utxo/src/wasm/zcash.rs | 11 + packages/wasm-utxo/src/zcash/mod.rs | 1 + packages/wasm-utxo/src/zcash/transaction.rs | 314 ++++++++++++++++++ .../fixedScript/zcashTransactionVersion.ts | 239 +++++++++++++ 10 files changed, 654 insertions(+), 4 deletions(-) create mode 100644 packages/wasm-utxo/test/fixedScript/zcashTransactionVersion.ts diff --git a/packages/wasm-utxo/js/fixedScriptWallet/ZcashBitGoPsbt.ts b/packages/wasm-utxo/js/fixedScriptWallet/ZcashBitGoPsbt.ts index 63b477b9bfa..92c2a27af5f 100644 --- a/packages/wasm-utxo/js/fixedScriptWallet/ZcashBitGoPsbt.ts +++ b/packages/wasm-utxo/js/fixedScriptWallet/ZcashBitGoPsbt.ts @@ -1,5 +1,6 @@ import { BitGoPsbt as WasmBitGoPsbt, + getZcashTransactionVersionFromPsbt, zcash_branch_id_for_height, zcash_ironwood_version_group_id, } from "../wasm/wasm_utxo.js"; @@ -28,6 +29,43 @@ export type ZcashParsedOutput = ParsedOutput & { isShielded: boolean; }; +/** + * Zcash transaction version (v4 or v6). + */ +export enum ZcashTransactionVersion { + V4 = "v4", + V6 = "v6", +} + +/** + * Detect whether the given PSBT bytes represent a Zcash v4 or v6 (Ironwood) transaction. + * + * @param psbtBytes - Serialized PSBT bytes (as Uint8Array or Buffer) + * @returns The Zcash transaction version as a {@link ZcashTransactionVersion} enum value (`"v4"` or `"v6"`) + * @throws Error if the bytes are not a valid PSBT or not a recognized Zcash transaction + * + * @example + * ```typescript + * const version = getZcashTransactionVersion(psbtBytes); + * if (version === ZcashTransactionVersion.V6) { + * const psbt = ZcashIronwoodBitGoPsbt.fromBytes(psbtBytes, "zcashTest"); + * } else if (version === ZcashTransactionVersion.V4) { + * const psbt = ZcashBitGoPsbt.fromBytes(psbtBytes, "zcash"); + * } + * ``` + */ +export function getZcashTransactionVersion(psbtBytes: Uint8Array): ZcashTransactionVersion { + const versionStr = getZcashTransactionVersionFromPsbt(psbtBytes); + switch (versionStr) { + case "v4": + return ZcashTransactionVersion.V4; + case "v6": + return ZcashTransactionVersion.V6; + default: + throw new Error(`Unexpected Zcash transaction version: ${versionStr}`); + } +} + /** * Zcash v6 (Ironwood) version group id (0xd884b698). Its presence marks a PSBT as v6 — see * `ZcashIronwoodBitGoPsbt`. @@ -318,6 +356,23 @@ export class ZcashBitGoPsbt extends BitGoPsbt { return zcash_branch_id_for_height(network, height); } + /** + * Detect whether the given PSBT bytes represent a Zcash v4 or v6 (Ironwood) transaction. + * + * @param bytes - Serialized PSBT bytes + * @returns The Zcash transaction version enum + */ + static getTransactionVersion(bytes: Uint8Array): ZcashTransactionVersion { + return getZcashTransactionVersion(bytes); + } + + /** + * Alias for {@link getTransactionVersion}. + */ + static getZcashTransactionVersion(bytes: Uint8Array): ZcashTransactionVersion { + return getZcashTransactionVersion(bytes); + } + /** * Extract the final Zcash transaction from a finalized PSBT * diff --git a/packages/wasm-utxo/js/fixedScriptWallet/index.ts b/packages/wasm-utxo/js/fixedScriptWallet/index.ts index 0b210393af7..ff7ae060c0b 100644 --- a/packages/wasm-utxo/js/fixedScriptWallet/index.ts +++ b/packages/wasm-utxo/js/fixedScriptWallet/index.ts @@ -42,12 +42,13 @@ export { BitGoKeySubtype, type PsbtKvKey } from "./BitGoKeySubtype.js"; // Zcash-specific PSBT subclass export { ZcashBitGoPsbt, + ZcashTransactionVersion, + getZcashTransactionVersion, type ZcashNetworkName, type ZcashParsedOutput, type CreateEmptyZcashOptions, IRONWOOD_VERSION_GROUP_ID, } from "./ZcashBitGoPsbt.js"; - // Zcash v6 (Ironwood / NU6.3) shielding PSBT export { ZcashIronwoodBitGoPsbt, diff --git a/packages/wasm-utxo/js/index.ts b/packages/wasm-utxo/js/index.ts index 893cbaf6bc5..e15e3fd6ff7 100644 --- a/packages/wasm-utxo/js/index.ts +++ b/packages/wasm-utxo/js/index.ts @@ -23,6 +23,15 @@ export { ECPair } from "./ecpair.js"; export { BIP32 } from "./bip32.js"; export { Dimensions } from "./fixedScriptWallet/Dimensions.js"; export { ZcashDimensions } from "./fixedScriptWallet/ZcashDimensions.js"; +export { + ZcashTransactionVersion, + getZcashTransactionVersion, + ZcashBitGoPsbt, + ZcashIronwoodBitGoPsbt, + ZcashUnifiedAddress, + ZcashV6Transaction, + ZcashIronwoodWitness, +} from "./fixedScriptWallet/index.js"; export type WasmUtxoVersionInfo = { version: string; gitHash: string }; export function getWasmUtxoVersion(): WasmUtxoVersionInfo { 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 174b3317703..307aed64d61 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 @@ -23,8 +23,8 @@ pub use propkv::{ }; pub use sighash::validate_sighash_type; pub use zcash_psbt::{ - decode_zcash_transaction_meta, ZcashBitGoPsbt, ZcashTransactionMeta, - ZCASH_SAPLING_VERSION_GROUP_ID, + decode_zcash_transaction_meta, detect_zcash_transaction_version, ZcashBitGoPsbt, + ZcashTransactionMeta, ZcashTransactionVersion, ZCASH_SAPLING_VERSION_GROUP_ID, }; #[derive(Debug, strum::IntoStaticStr)] 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 9098c016e87..8355e3ca884 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, detect_zcash_transaction_version, ZcashTransactionMeta, + ZcashTransactionVersion, ZCASH_SAPLING_VERSION_GROUP_ID, }; /// A Zcash-compatible PSBT that can handle overwintered transactions @@ -441,6 +442,14 @@ impl ZcashBitGoPsbt { Self::decode_with_zcash_tx(bytes, network, true) } + /// Detect whether the given PSBT bytes represent a Zcash v4 or v6 (Ironwood) transaction. + pub fn get_psbt_transaction_version( + bytes: &[u8], + ) -> Result { + crate::zcash::transaction::detect_zcash_transaction_version(bytes) + .map_err(super::DeserializeError::Network) + } + /// Convert to a standard Bitcoin PSBT (losing Zcash-specific fields) pub fn into_bitcoin_psbt(self) -> Psbt { self.psbt 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 0405bbd8a17..e9f7bc7ddd8 100644 --- a/packages/wasm-utxo/src/wasm/fixed_script_wallet/mod.rs +++ b/packages/wasm-utxo/src/wasm/fixed_script_wallet/mod.rs @@ -352,6 +352,17 @@ impl BitGoPsbt { }) } + /// Detect whether the given PSBT bytes represent a Zcash v4 or v6 (Ironwood) transaction. + /// + /// Returns `"v4"` or `"v6"`. + /// Throws [`WasmUtxoError`] if the bytes are not a valid PSBT or not a Zcash transaction. + #[wasm_bindgen(js_name = get_zcash_transaction_version)] + pub fn get_zcash_transaction_version(bytes: &[u8]) -> Result { + crate::zcash::transaction::detect_zcash_transaction_version(bytes) + .map(|v| v.as_str().to_string()) + .map_err(|e| WasmUtxoError::new(&e)) + } + /// Create an empty PSBT for the given network with wallet keys /// /// # Arguments diff --git a/packages/wasm-utxo/src/wasm/zcash.rs b/packages/wasm-utxo/src/wasm/zcash.rs index bc4342d1e92..e7512468b5c 100644 --- a/packages/wasm-utxo/src/wasm/zcash.rs +++ b/packages/wasm-utxo/src/wasm/zcash.rs @@ -42,6 +42,17 @@ pub fn zcash_ironwood_version_group_id() -> u32 { crate::zcash::transaction::ZCASH_IRONWOOD_VERSION_GROUP_ID } +/// Detect whether the given PSBT bytes represent a Zcash v4 or v6 (Ironwood) transaction. +/// +/// Returns `"v4"` or `"v6"`. +/// Throws [`WasmUtxoError`] if the bytes are not a valid PSBT or not a Zcash transaction. +#[wasm_bindgen(js_name = getZcashTransactionVersionFromPsbt)] +pub fn get_zcash_transaction_version_from_psbt(psbt_bytes: &[u8]) -> Result { + crate::zcash::transaction::detect_zcash_transaction_version(psbt_bytes) + .map(|v| v.as_str().to_string()) + .map_err(|e| WasmUtxoError::new(&e)) +} + /// A validated Merkle witness for an Ironwood/Orchard note commitment, from /// [`ironwood_build_witness`]. /// diff --git a/packages/wasm-utxo/src/zcash/mod.rs b/packages/wasm-utxo/src/zcash/mod.rs index 806dc3209e7..044c6de46ed 100644 --- a/packages/wasm-utxo/src/zcash/mod.rs +++ b/packages/wasm-utxo/src/zcash/mod.rs @@ -18,6 +18,7 @@ pub mod ironwood_pczt; pub mod transaction; pub mod unified_address; pub mod v6; +pub use transaction::{detect_zcash_transaction_version, ZcashTransactionVersion}; /// Zcash network upgrade identifiers #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] diff --git a/packages/wasm-utxo/src/zcash/transaction.rs b/packages/wasm-utxo/src/zcash/transaction.rs index aaac87c90bf..5b093a6efe6 100644 --- a/packages/wasm-utxo/src/zcash/transaction.rs +++ b/packages/wasm-utxo/src/zcash/transaction.rs @@ -19,6 +19,49 @@ pub const ZCASH_V4_VERSION_HEADER: u32 = 0x80000004; /// Transaction version header for v6 transactions (Ironwood/NU6.3), overwintered bit set. pub const ZCASH_V6_VERSION_HEADER: u32 = 0x80000006; +/// Zcash transaction version in a PSBT or serialized transaction. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum ZcashTransactionVersion { + #[serde(rename = "v4")] + V4, + #[serde(rename = "v6")] + V6, +} + +impl ZcashTransactionVersion { + pub const fn as_str(&self) -> &'static str { + match self { + Self::V4 => "v4", + Self::V6 => "v6", + } + } + + pub const fn version_number(&self) -> u32 { + match self { + Self::V4 => 4, + Self::V6 => 6, + } + } +} + +impl std::fmt::Display for ZcashTransactionVersion { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", self.as_str()) + } +} + +impl std::str::FromStr for ZcashTransactionVersion { + type Err = String; + fn from_str(s: &str) -> Result { + match s.to_ascii_lowercase().as_str() { + "v4" | "4" => Ok(Self::V4), + "v6" | "6" => Ok(Self::V6), + _ => Err(format!("unknown Zcash transaction version: {s}")), + } + } +} + /// Parsed Zcash transaction fields, preserving Zcash-specific data needed for round-tripping. #[derive(Debug, Clone)] pub struct ZcashTransactionParts { @@ -243,3 +286,274 @@ pub fn encode_zcash_transaction_parts(parts: &ZcashTransactionParts) -> Result Result { + use miniscript::bitcoin::psbt::Psbt; + use miniscript::bitcoin::VarInt; + use std::io::Read; + + if psbt_bytes.len() < 5 || &psbt_bytes[0..5] != b"psbt\xff" { + return Err("Invalid PSBT: missing magic bytes".to_string()); + } + + // Fast path: standard PSBT deserialization (covers v6 shielding PSBTs and hydrated v4/v6 PSBTs) + if let Ok(psbt) = Psbt::deserialize(psbt_bytes) { + if let Some((vgid, _)) = + crate::fixed_script_wallet::bitgo_psbt::propkv::get_zec_v6_params(&psbt) + { + if vgid == ZCASH_IRONWOOD_VERSION_GROUP_ID { + return Ok(ZcashTransactionVersion::V6); + } else { + return Err(format!( + "PSBT declares unrecognized Zcash version_group_id: {:#010x}", + vgid + )); + } + } + if psbt.unsigned_tx.version.0 == 6 + && crate::fixed_script_wallet::bitgo_psbt::propkv::get_zec_v6_consensus_branch_id(&psbt) + .is_some() + { + return Ok(ZcashTransactionVersion::V6); + } + if (psbt.unsigned_tx.version.0 == 4 || psbt.unsigned_tx.version.0 == 5) + && crate::fixed_script_wallet::bitgo_psbt::propkv::get_zec_consensus_branch_id(&psbt) + .is_some() + { + return Ok(ZcashTransactionVersion::V4); + } + } + + // Scan the PSBT global key-value map to inspect PSBT_GLOBAL_UNSIGNED_TX and proprietary keys + let mut r = psbt_bytes; + let magic: [u8; 4] = + Decodable::consensus_decode(&mut r).map_err(|e| format!("Invalid PSBT magic: {}", e))?; + if &magic != b"psbt" { + return Err("Invalid PSBT magic".to_string()); + } + let separator: u8 = Decodable::consensus_decode(&mut r) + .map_err(|e| format!("Invalid PSBT separator: {}", e))?; + if separator != 0xff { + return Err("Invalid PSBT separator".to_string()); + } + + let mut found_tx_bytes: Option> = None; + let mut v6_vgid: Option = None; + let mut has_zec_branch_id = false; + + loop { + let key_len: VarInt = match Decodable::consensus_decode(&mut r) { + Ok(k) => k, + Err(e) => return Err(format!("Failed to decode PSBT key length: {}", e)), + }; + if key_len.0 == 0 { + break; + } + let mut key_data = vec![0u8; key_len.0 as usize]; + if r.read_exact(&mut key_data).is_err() { + return Err("Failed to read PSBT key data".to_string()); + } + + let val_len: VarInt = match Decodable::consensus_decode(&mut r) { + Ok(v) => v, + Err(e) => return Err(format!("Failed to decode PSBT value length: {}", e)), + }; + let mut val_data = vec![0u8; val_len.0 as usize]; + if r.read_exact(&mut val_data).is_err() { + return Err("Failed to read PSBT value data".to_string()); + } + + if key_data.len() == 1 && key_data[0] == 0x00 { + found_tx_bytes = Some(val_data); + } else if key_data.starts_with(b"\xfc\x0cBITGO/ZEC/V6\x02") && val_data.len() == 4 { + v6_vgid = Some(u32::from_le_bytes(val_data[0..4].try_into().unwrap())); + } else if key_data.windows(12).any(|w| w == b"BITGO/ZEC/V6") { + if key_data.contains( + &(crate::fixed_script_wallet::bitgo_psbt::propkv::ZecV6KeySubtype::VersionGroupId + as u8), + ) && val_data.len() == 4 + { + v6_vgid = Some(u32::from_le_bytes(val_data[0..4].try_into().unwrap())); + } + } else if key_data.windows(5).any(|w| w == b"BITGO") { + has_zec_branch_id = true; + } + } + + if let Some(vgid) = v6_vgid { + if vgid == ZCASH_IRONWOOD_VERSION_GROUP_ID { + return Ok(ZcashTransactionVersion::V6); + } else { + return Err(format!( + "PSBT declares unrecognized Zcash v6 version_group_id: {:#010x}", + vgid + )); + } + } + + if let Some(tx_bytes) = found_tx_bytes { + if tx_bytes.len() >= 4 { + let header = u32::from_le_bytes(tx_bytes[0..4].try_into().unwrap()); + if header == ZCASH_V6_VERSION_HEADER { + if tx_bytes.len() >= 8 { + let vgid = u32::from_le_bytes(tx_bytes[4..8].try_into().unwrap()); + if vgid == ZCASH_IRONWOOD_VERSION_GROUP_ID { + return Ok(ZcashTransactionVersion::V6); + } + } + return Ok(ZcashTransactionVersion::V6); + } + if header == ZCASH_V4_VERSION_HEADER { + if tx_bytes.len() >= 8 { + let vgid = u32::from_le_bytes(tx_bytes[4..8].try_into().unwrap()); + if vgid == ZCASH_SAPLING_VERSION_GROUP_ID { + return Ok(ZcashTransactionVersion::V4); + } + } + return Ok(ZcashTransactionVersion::V4); + } + if (header & 0x80000000) != 0 && tx_bytes.len() >= 8 { + let vgid = u32::from_le_bytes(tx_bytes[4..8].try_into().unwrap()); + if vgid == ZCASH_IRONWOOD_VERSION_GROUP_ID { + return Ok(ZcashTransactionVersion::V6); + } + if vgid == ZCASH_SAPLING_VERSION_GROUP_ID { + return Ok(ZcashTransactionVersion::V4); + } + } + } + + if let Ok(parts) = decode_zcash_transaction_parts(&tx_bytes) { + if parts.is_overwintered { + if parts.version_group_id == Some(ZCASH_SAPLING_VERSION_GROUP_ID) + || parts.transaction.version.0 == 4 + || parts.transaction.version.0 == 5 + { + return Ok(ZcashTransactionVersion::V4); + } + if parts.version_group_id == Some(ZCASH_IRONWOOD_VERSION_GROUP_ID) + || parts.transaction.version.0 == 6 + { + return Ok(ZcashTransactionVersion::V6); + } + } else if has_zec_branch_id + && (parts.transaction.version.0 == 4 || parts.transaction.version.0 == 5) + { + return Ok(ZcashTransactionVersion::V4); + } + } + } + + Err("Not a recognized Zcash PSBT (neither v4 nor v6)".to_string()) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::fixed_script_wallet::bitgo_psbt::ZcashBitGoPsbt; + use crate::fixed_script_wallet::test_utils::get_test_wallet_keys; + use crate::fixed_script_wallet::RootWalletKeys; + use crate::Network; + + #[test] + fn test_detect_version_v4_psbt() { + let wallet_keys = RootWalletKeys::new(get_test_wallet_keys("detect-v4")); + let psbt = ZcashBitGoPsbt::new( + Network::Zcash, + &wallet_keys, + 0xc2d6d0b4, // NU5 branch ID + Some(4), + Some(0), + Some(ZCASH_SAPLING_VERSION_GROUP_ID), + Some(0), + ); + let bytes = psbt.serialize().expect("serialize v4 psbt"); + let version = detect_zcash_transaction_version(&bytes).expect("detect v4"); + assert_eq!(version, ZcashTransactionVersion::V4); + assert_eq!(version.as_str(), "v4"); + assert_eq!(version.version_number(), 4); + } + + #[test] + fn test_detect_version_v6_psbt() { + let wallet_keys = RootWalletKeys::new(get_test_wallet_keys("detect-v6")); + let psbt = ZcashBitGoPsbt::new_v6( + Network::ZcashTestnet, + &wallet_keys, + 0x37a5165b, // NU6.3 branch ID + Some(0), + Some(0), + ); + let bytes = psbt.serialize_v6(); + let version = detect_zcash_transaction_version(&bytes).expect("detect v6"); + assert_eq!(version, ZcashTransactionVersion::V6); + assert_eq!(version.as_str(), "v6"); + assert_eq!(version.version_number(), 6); + } + + #[test] + fn test_detect_version_v6_bare_psbt() { + let psbt = ZcashBitGoPsbt::new_v6_bare(Network::ZcashTestnet, 0x37a5165b, Some(0), Some(0)); + let bytes = psbt.serialize_v6(); + let version = detect_zcash_transaction_version(&bytes).expect("detect v6 bare"); + assert_eq!(version, ZcashTransactionVersion::V6); + } + + #[test] + fn test_detect_version_rejects_non_zcash_psbt() { + let tx = miniscript::bitcoin::Transaction { + version: miniscript::bitcoin::transaction::Version::TWO, + lock_time: miniscript::bitcoin::locktime::absolute::LockTime::ZERO, + input: vec![], + output: vec![], + }; + let psbt = miniscript::bitcoin::Psbt::from_unsigned_tx(tx).unwrap(); + let bytes = psbt.serialize(); + let err = detect_zcash_transaction_version(&bytes).unwrap_err(); + assert!(err.contains("Not a recognized Zcash PSBT")); + } + + #[test] + fn test_detect_version_rejects_invalid_bytes() { + let err = detect_zcash_transaction_version(&[0, 1, 2, 3]).unwrap_err(); + assert!(err.contains("Invalid PSBT")); + } + + #[test] + fn test_zcash_transaction_version_enum_display_and_parse() { + assert_eq!(ZcashTransactionVersion::V4.to_string(), "v4"); + assert_eq!(ZcashTransactionVersion::V6.to_string(), "v6"); + assert_eq!( + "v4".parse::().unwrap(), + ZcashTransactionVersion::V4 + ); + assert_eq!( + "V4".parse::().unwrap(), + ZcashTransactionVersion::V4 + ); + assert_eq!( + "4".parse::().unwrap(), + ZcashTransactionVersion::V4 + ); + assert_eq!( + "v6".parse::().unwrap(), + ZcashTransactionVersion::V6 + ); + assert_eq!( + "V6".parse::().unwrap(), + ZcashTransactionVersion::V6 + ); + assert_eq!( + "6".parse::().unwrap(), + ZcashTransactionVersion::V6 + ); + assert!("v5".parse::().is_err()); + } +} diff --git a/packages/wasm-utxo/test/fixedScript/zcashTransactionVersion.ts b/packages/wasm-utxo/test/fixedScript/zcashTransactionVersion.ts new file mode 100644 index 00000000000..661100f9f09 --- /dev/null +++ b/packages/wasm-utxo/test/fixedScript/zcashTransactionVersion.ts @@ -0,0 +1,239 @@ +import assert from "node:assert"; +import { describe, it } from "mocha"; + +import { + getZcashTransactionVersion, + ZcashBitGoPsbt, + ZcashIronwoodBitGoPsbt, + ZcashTransactionVersion, +} from "../../js/index.js"; +import { + BitGoPsbt, + getZcashTransactionVersion as getVersionFromFixedScript, + ZcashTransactionVersion as VersionFromFixedScript, +} from "../../js/fixedScriptWallet/index.js"; +import { getKeyTriple, getWalletKeysForSeed } from "../../js/testutils/index.js"; + +const LEGACY_V4_MAINNET_HEIGHT = 1687104; +const NU6_3_TESTNET_HEIGHT = 4134000; +const NU6_3_BRANCH_ID = 0x37a5165b; + +const RECIPIENT = Buffer.from( + "4559029c0b5dbf941c5ad181a5fe8f45b34630f29d0c8dd8dc1cc3573386f416cb324133156d723df5e62d", + "hex", +); + +describe("ZcashTransactionVersion detection", function () { + const walletKeys = getWalletKeysForSeed("zcash-version-detection-test"); + + describe("ZcashTransactionVersion enum", function () { + it("has expected values for V4 and V6", function () { + assert.strictEqual(ZcashTransactionVersion.V4, "v4"); + assert.strictEqual(ZcashTransactionVersion.V6, "v6"); + }); + + it("is re-exported from top-level and fixedScriptWallet namespaces", function () { + assert.strictEqual(VersionFromFixedScript.V4, ZcashTransactionVersion.V4); + assert.strictEqual(VersionFromFixedScript.V6, ZcashTransactionVersion.V6); + assert.strictEqual(getVersionFromFixedScript, getZcashTransactionVersion); + }); + + it("allows branching with switch / case pattern matching", function () { + function formatVersionDescription(version: ZcashTransactionVersion): string { + switch (version) { + case ZcashTransactionVersion.V4: + return "Legacy (v4)"; + case ZcashTransactionVersion.V6: + return "Ironwood / NU6.3 (v6)"; + } + } + + assert.strictEqual(formatVersionDescription(ZcashTransactionVersion.V4), "Legacy (v4)"); + assert.strictEqual( + formatVersionDescription(ZcashTransactionVersion.V6), + "Ironwood / NU6.3 (v6)", + ); + }); + }); + + describe("detecting Zcash v4 PSBTs", function () { + it("identifies an empty v4 PSBT created by block height (mainnet)", function () { + const psbt = ZcashBitGoPsbt.createEmpty("zcash", walletKeys, { + blockHeight: LEGACY_V4_MAINNET_HEIGHT, + }); + const bytes = psbt.serialize(); + + const version = getZcashTransactionVersion(bytes); + assert.strictEqual(version, ZcashTransactionVersion.V4); + + // Verify static methods on ZcashBitGoPsbt + assert.strictEqual(ZcashBitGoPsbt.getTransactionVersion(bytes), ZcashTransactionVersion.V4); + assert.strictEqual( + ZcashBitGoPsbt.getZcashTransactionVersion(bytes), + ZcashTransactionVersion.V4, + ); + }); + + it("identifies a v4 PSBT with inputs and outputs added", function () { + const psbt = ZcashBitGoPsbt.createEmpty("zcash", walletKeys, { + blockHeight: LEGACY_V4_MAINNET_HEIGHT, + }); + psbt.addWalletInput({ txid: "22".repeat(32), vout: 0, value: 500_000_000n }, walletKeys, { + scriptId: { chain: 0, index: 0 }, + }); + psbt.addWalletOutput(walletKeys, { chain: 1, index: 0, value: 499_900_000n }); + const bytes = psbt.serialize(); + + assert.strictEqual(getZcashTransactionVersion(bytes), ZcashTransactionVersion.V4); + assert.strictEqual(ZcashBitGoPsbt.getTransactionVersion(bytes), ZcashTransactionVersion.V4); + }); + + it("identifies a v4 PSBT after deserialization round-trip", function () { + const psbt = ZcashBitGoPsbt.createEmpty("zcash", walletKeys, { + blockHeight: LEGACY_V4_MAINNET_HEIGHT, + }); + psbt.addWalletInput({ txid: "22".repeat(32), vout: 0, value: 500_000_000n }, walletKeys, { + scriptId: { chain: 0, index: 0 }, + }); + psbt.addWalletOutput(walletKeys, { chain: 1, index: 0, value: 499_900_000n }); + const bytes = psbt.serialize(); + const roundTrip = ZcashBitGoPsbt.fromBytes(bytes, "zcash"); + + assert.strictEqual( + getZcashTransactionVersion(roundTrip.serialize()), + ZcashTransactionVersion.V4, + ); + }); + }); + + describe("detecting Zcash v6 (Ironwood) PSBTs", function () { + it("identifies an empty v6 PSBT created by block height", function () { + const psbt = ZcashIronwoodBitGoPsbt.createEmpty("zcashTest", walletKeys, { + blockHeight: NU6_3_TESTNET_HEIGHT, + }); + const bytes = psbt.serialize(); + + const version = getZcashTransactionVersion(bytes); + assert.strictEqual(version, ZcashTransactionVersion.V6); + + // Verify static methods on ZcashBitGoPsbt + assert.strictEqual(ZcashBitGoPsbt.getTransactionVersion(bytes), ZcashTransactionVersion.V6); + assert.strictEqual( + ZcashBitGoPsbt.getZcashTransactionVersion(bytes), + ZcashTransactionVersion.V6, + ); + }); + + it("identifies an empty v6 PSBT created with explicit consensusBranchId", function () { + const psbt = ZcashIronwoodBitGoPsbt.createEmptyWithConsensusBranchId("tzec", walletKeys, { + consensusBranchId: NU6_3_BRANCH_ID, + }); + const bytes = psbt.serialize(); + + assert.strictEqual(getZcashTransactionVersion(bytes), ZcashTransactionVersion.V6); + }); + + it("identifies a v6 PSBT with transparent inputs and outputs before shielding (pre-shield)", function () { + const psbt = ZcashIronwoodBitGoPsbt.createEmpty("zcashTest", walletKeys, { + blockHeight: NU6_3_TESTNET_HEIGHT, + }); + psbt.addWalletInput({ txid: "33".repeat(32), vout: 0, value: 300_000_000n }, walletKeys, { + scriptId: { chain: 0, index: 0 }, + signPath: { signer: "user", cosigner: "bitgo" }, + }); + psbt.addWalletOutput(walletKeys, { chain: 1, index: 0, value: 199_900_000n }); + const bytes = psbt.serialize(); + + assert.strictEqual(getZcashTransactionVersion(bytes), ZcashTransactionVersion.V6); + assert.strictEqual(ZcashBitGoPsbt.getTransactionVersion(bytes), ZcashTransactionVersion.V6); + }); + + it("identifies a v6 PSBT with shielded output added", function () { + const psbt = ZcashIronwoodBitGoPsbt.createEmpty("zcashTest", walletKeys, { + blockHeight: NU6_3_TESTNET_HEIGHT, + }); + psbt.addWalletInput({ txid: "44".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 }); + psbt.addShieldedOutput(RECIPIENT, 100_000_000n, { + anchor: new Uint8Array(32).fill(7), + }); + const bytes = psbt.serialize(); + + assert.strictEqual(getZcashTransactionVersion(bytes), ZcashTransactionVersion.V6); + assert.strictEqual(ZcashBitGoPsbt.getTransactionVersion(bytes), ZcashTransactionVersion.V6); + }); + + it("identifies a v6 PSBT after signing inputs", function () { + const [userKey] = getKeyTriple("zcash-version-detection-test"); + const psbt = ZcashIronwoodBitGoPsbt.createEmpty("zcashTest", walletKeys, { + blockHeight: NU6_3_TESTNET_HEIGHT, + }); + psbt.addWalletInput({ txid: "55".repeat(32), vout: 0, value: 200_000_000n }, walletKeys, { + scriptId: { chain: 0, index: 0 }, + signPath: { signer: "user", cosigner: "bitgo" }, + }); + psbt.addShieldedOutput(RECIPIENT, 199_980_000n, { + anchor: new Uint8Array(32).fill(9), + }); + psbt.sign(userKey, walletKeys); + const bytes = psbt.serialize(); + + assert.strictEqual(getZcashTransactionVersion(bytes), ZcashTransactionVersion.V6); + }); + }); + + describe("rejecting non-Zcash PSBTs and invalid inputs", function () { + it("throws when given a Bitcoin PSBT", function () { + const btcPsbt = BitGoPsbt.createEmpty("bitcoin", walletKeys); + const bytes = btcPsbt.serialize(); + + assert.throws( + () => getZcashTransactionVersion(bytes), + /Not a recognized Zcash PSBT|neither v4 nor v6/i, + ); + assert.throws( + () => ZcashBitGoPsbt.getTransactionVersion(bytes), + /Not a recognized Zcash PSBT|neither v4 nor v6/i, + ); + }); + + it("throws when given a Litecoin PSBT", function () { + const ltcPsbt = BitGoPsbt.createEmpty("litecoin", walletKeys); + const bytes = ltcPsbt.serialize(); + + assert.throws( + () => getZcashTransactionVersion(bytes), + /Not a recognized Zcash PSBT|neither v4 nor v6/i, + ); + }); + + it("throws when given a Dogecoin PSBT", function () { + const dogePsbt = BitGoPsbt.createEmpty("dogecoin", walletKeys); + const bytes = dogePsbt.serialize(); + + assert.throws( + () => getZcashTransactionVersion(bytes), + /Not a recognized Zcash PSBT|neither v4 nor v6/i, + ); + }); + + it("throws when given non-PSBT random bytes", function () { + assert.throws( + () => getZcashTransactionVersion(new Uint8Array([1, 2, 3, 4, 5])), + /Invalid PSBT/i, + ); + }); + + it("throws when given empty byte array", function () { + assert.throws(() => getZcashTransactionVersion(new Uint8Array(0)), /Invalid PSBT/i); + }); + + it("throws when given corrupted PSBT bytes", function () { + const corruptPsbt = new Uint8Array([0x70, 0x73, 0x62, 0x74, 0xff, 0x01, 0x02]); + assert.throws(() => getZcashTransactionVersion(corruptPsbt), /PSBT/i); + }); + }); +});