From eb2cd89fe89468b1bc491af19dfece304e686ce8 Mon Sep 17 00:00:00 2001 From: Veetrag Jain Date: Tue, 21 Jul 2026 18:15:12 +0530 Subject: [PATCH 1/8] feat(wasm-utxo): add v6 constants and BLAKE2b-personal helper Groundwork for Zcash v6 (Ironwood/NU6.3) transaction support: - ZCASH_IRONWOOD_VERSION_GROUP_ID (0xD884B698), v4/v6 version headers - crate-local blake2b_256_personal for ZIP-244 hash trees (the fork's helper is pub(crate) and unreachable here); includes the block-aligned finalization fix Co-Authored-By: Claude Opus 4.8 --- packages/wasm-utxo/Cargo.lock | 1 + packages/wasm-utxo/Cargo.toml | 3 ++ packages/wasm-utxo/src/zcash/blake2b.rs | 57 +++++++++++++++++++++ packages/wasm-utxo/src/zcash/mod.rs | 1 + packages/wasm-utxo/src/zcash/transaction.rs | 11 +++- 5 files changed, 72 insertions(+), 1 deletion(-) create mode 100644 packages/wasm-utxo/src/zcash/blake2b.rs diff --git a/packages/wasm-utxo/Cargo.lock b/packages/wasm-utxo/Cargo.lock index 216fb5bf513..d63b6170ecf 100644 --- a/packages/wasm-utxo/Cargo.lock +++ b/packages/wasm-utxo/Cargo.lock @@ -3164,6 +3164,7 @@ version = "0.1.0" dependencies = [ "base64 0.22.1", "bech32", + "blake2", "crossbeam-epoch", "getrandom 0.2.16", "hex", diff --git a/packages/wasm-utxo/Cargo.toml b/packages/wasm-utxo/Cargo.toml index d377970448c..56f3ceacc82 100644 --- a/packages/wasm-utxo/Cargo.toml +++ b/packages/wasm-utxo/Cargo.toml @@ -30,6 +30,9 @@ js-sys = "0.3" strum = { version = "0.27", features = ["derive"] } miniscript = { git = "https://github.com/BitGo/rust-miniscript", tag = "miniscript-13.0.0-bitgo.5" } bech32 = "0.11" +# BLAKE2b with personalization for Zcash ZIP-243/ZIP-244 hashing. +# Already present transitively via the miniscript fork; pinned here as a direct dependency. +blake2 = "0.10" musig2 = { version = "0.3.1", default-features = false, features = ["k256"] } getrandom = { version = "0.2", features = ["js"] } pastey = "0.1" diff --git a/packages/wasm-utxo/src/zcash/blake2b.rs b/packages/wasm-utxo/src/zcash/blake2b.rs new file mode 100644 index 00000000000..614c3d06d31 --- /dev/null +++ b/packages/wasm-utxo/src/zcash/blake2b.rs @@ -0,0 +1,57 @@ +//! BLAKE2b-256 with personalization, as used by Zcash ZIP-243 (sighash) and +//! ZIP-244 (txid / v6 sighash) hash trees. +//! +//! Every digest in the ZIP-244 tree is a BLAKE2b-256 hash keyed by a 16-byte +//! personalization string. This mirrors the `blake2b_256_personal` helper in the +//! BitGo miniscript fork (which is `pub(crate)` there and therefore not reachable +//! from this crate), including the fix for block-aligned inputs. + +use blake2::digest::core_api::{Buffer, UpdateCore, VariableOutputCore}; +use blake2::digest::Output; +use blake2::Blake2bVarCore; + +/// Compute a BLAKE2b-256 digest of `data` with a 16-byte `personalization` string. +/// +/// The personalization must be at most 16 bytes (ZIP personalization strings are +/// exactly 16 bytes, e.g. `b"ZTxIdIronwd_H_v6"`). +/// +/// The `Buffer`/`digest_blocks` path retains the final block until finalization so +/// the correct finalization flag is used even when `data.len()` is an exact multiple +/// of the 128-byte BLAKE2b block size (the block-aligned bug fixed in the fork). +pub fn blake2b_256_personal(data: &[u8], personalization: &[u8]) -> [u8; 32] { + let mut core = Blake2bVarCore::new_with_params(&[], personalization, 0, 32); + let mut buffer: Buffer = Default::default(); + buffer.digest_blocks(data, |blocks| core.update_blocks(blocks)); + + let mut full_output: Output = Default::default(); + VariableOutputCore::finalize_variable_core(&mut core, &mut buffer, &mut full_output); + + let mut result = [0u8; 32]; + result.copy_from_slice(&full_output[..32]); + result +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn personalization_affects_output() { + let data = b"the quick brown fox"; + let a = blake2b_256_personal(data, b"ZTxIdPrevoutHash"); + let b = blake2b_256_personal(data, b"ZTxIdSequencHash"); + assert_ne!(a, b); + // Deterministic + assert_eq!(a, blake2b_256_personal(data, b"ZTxIdPrevoutHash")); + } + + #[test] + fn block_aligned_input_is_stable() { + // 256 bytes = exactly 2 BLAKE2b blocks — exercises the finalization-flag fix. + let data = vec![0xabu8; 256]; + let h = blake2b_256_personal(&data, b"ZTxIdOutputsHash"); + // Just assert it produces a non-trivial, deterministic 32-byte output. + assert_ne!(h, [0u8; 32]); + assert_eq!(h, blake2b_256_personal(&data, b"ZTxIdOutputsHash")); + } +} diff --git a/packages/wasm-utxo/src/zcash/mod.rs b/packages/wasm-utxo/src/zcash/mod.rs index a8646c422ff..72c8161ea11 100644 --- a/packages/wasm-utxo/src/zcash/mod.rs +++ b/packages/wasm-utxo/src/zcash/mod.rs @@ -9,6 +9,7 @@ //! //! Tests verify parity with `zebra-chain` crate. +pub mod blake2b; pub mod transaction; /// Zcash network upgrade identifiers diff --git a/packages/wasm-utxo/src/zcash/transaction.rs b/packages/wasm-utxo/src/zcash/transaction.rs index bd02b0f35ea..e57916336b4 100644 --- a/packages/wasm-utxo/src/zcash/transaction.rs +++ b/packages/wasm-utxo/src/zcash/transaction.rs @@ -7,9 +7,18 @@ use miniscript::bitcoin::consensus::{Decodable, Encodable}; use miniscript::bitcoin::{Transaction, TxIn, TxOut}; -/// Zcash Sapling version group ID +/// Zcash Sapling version group ID (v4 transactions) pub const ZCASH_SAPLING_VERSION_GROUP_ID: u32 = 0x892F2085; +/// Zcash Ironwood version group ID (v6 / NU6.3 transactions) +pub const ZCASH_IRONWOOD_VERSION_GROUP_ID: u32 = 0xD884B698; + +/// Transaction version header for v4 transactions (Sapling), overwintered bit set. +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; + /// Parsed Zcash transaction fields, preserving Zcash-specific data needed for round-tripping. #[derive(Debug, Clone)] pub struct ZcashTransactionParts { From f4260fb8016c854827e2d54d778cee276a2f5b48 Mon Sep 17 00:00:00 2001 From: Veetrag Jain Date: Tue, 21 Jul 2026 18:19:43 +0530 Subject: [PATCH 2/8] feat(wasm-utxo): add v6 (Ironwood) transaction parse/encode Implements the standalone v6/NU6.3 wire codec (S1). The v6 header is reordered vs v4 (consensusBranchId/lockTime/expiryHeight move to the front) so it is a separate codec, leaving the v4 path untouched: - IronwoodAction (820B), IronwoodBundle, ZcashV6Transaction types - decode_v6_transaction / encode_v6_transaction with round-trip tests - empty Sapling + empty Orchard v6 slots supported; non-empty rejected - v4 decoder now guards against v6 bytes instead of mis-parsing them Co-Authored-By: Claude Opus 4.8 --- packages/wasm-utxo/src/zcash/mod.rs | 1 + packages/wasm-utxo/src/zcash/transaction.rs | 10 + packages/wasm-utxo/src/zcash/v6.rs | 522 ++++++++++++++++++++ 3 files changed, 533 insertions(+) create mode 100644 packages/wasm-utxo/src/zcash/v6.rs diff --git a/packages/wasm-utxo/src/zcash/mod.rs b/packages/wasm-utxo/src/zcash/mod.rs index 72c8161ea11..2a258ce1536 100644 --- a/packages/wasm-utxo/src/zcash/mod.rs +++ b/packages/wasm-utxo/src/zcash/mod.rs @@ -11,6 +11,7 @@ pub mod blake2b; pub mod transaction; +pub mod v6; /// 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 e57916336b4..aaac87c90bf 100644 --- a/packages/wasm-utxo/src/zcash/transaction.rs +++ b/packages/wasm-utxo/src/zcash/transaction.rs @@ -126,6 +126,16 @@ pub fn decode_zcash_transaction_parts(bytes: &[u8]) -> Result = Vec::consensus_decode(&mut slice).map_err(|e| format!("Failed to decode inputs: {}", e))?; diff --git a/packages/wasm-utxo/src/zcash/v6.rs b/packages/wasm-utxo/src/zcash/v6.rs new file mode 100644 index 00000000000..a1a652c2384 --- /dev/null +++ b/packages/wasm-utxo/src/zcash/v6.rs @@ -0,0 +1,522 @@ +//! Zcash v6 (Ironwood / NU6.3) transaction wire format. +//! +//! A v6 transaction restructures the header relative to v4: `consensusBranchId`, +//! `lockTime`, and `expiryHeight` all move to the front (before the transparent +//! inputs/outputs), and two shielded-bundle slots are appended after Sapling — a +//! v6-personalized Orchard slot (empty for our flows, `nActionsOrchard = 0`) and a +//! new Ironwood slot. +//! +//! Wire layout (all integers little-endian, `varint` = CompactSize): +//! +//! ```text +//! Header: version(4) versionGroupId(4) consensusBranchId(4) lockTime(4) expiryHeight(4) +//! Transparent: tx_in_count(varint) tx_in[] tx_out_count(varint) tx_out[] +//! Sapling: nSpendsSapling(varint=0) nOutputsSapling(varint=0) +//! Orchard v6: nActionsOrchard(varint=0) // bundle body present only if > 0 +//! Ironwood: nActionsIronwood(varint) actions[820×n] flags(1) valueBalance(8) +//! anchor(32) proofsSize(varint) proofs[] spendAuthSigs[64×n] bindingSig(64) +//! // whole block present only if nActionsIronwood > 0 +//! ``` +//! +//! This codec supports the shapes the Ironwood flows actually produce: empty +//! Sapling and empty Orchard slots plus a populated Ironwood bundle. Non-empty +//! Sapling or Orchard bundles are rejected with a clear error rather than +//! mis-parsed, since no BitGo flow emits them. + +use super::transaction::{ZCASH_IRONWOOD_VERSION_GROUP_ID, ZCASH_V6_VERSION_HEADER}; +use miniscript::bitcoin::consensus::{Decodable, Encodable}; +use miniscript::bitcoin::{Transaction, TxIn, TxOut, VarInt}; + +/// Serialized size of a single Ironwood action on the wire. +pub const IRONWOOD_ACTION_SIZE: usize = 820; + +/// Length of the encrypted note ciphertext in an Ironwood action. +pub const ENC_CIPHERTEXT_SIZE: usize = 580; + +/// Length of the outgoing ciphertext in an Ironwood action. +pub const OUT_CIPHERTEXT_SIZE: usize = 80; + +/// A single Ironwood action (820 bytes on the wire). +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct IronwoodAction { + /// Value commitment. + pub cv: [u8; 32], + /// Spend nullifier; also serves as the `rho` input to the paired output note commitment. + pub nullifier: [u8; 32], + /// Randomized spend-auth verification key (RedPallas). + pub rk: [u8; 32], + /// Output note commitment x-coordinate (appended as a leaf to the Ironwood tree). + pub cmx: [u8; 32], + /// Ephemeral key for note encryption. + pub ephemeral_key: [u8; 32], + /// Encrypted note ciphertext: 52 (V3 note plaintext) + 512 (memo) + 16 (Poly1305 tag). + pub enc_ciphertext: [u8; ENC_CIPHERTEXT_SIZE], + /// Outgoing ciphertext: 64 (out plaintext) + 16 (tag). + pub out_ciphertext: [u8; OUT_CIPHERTEXT_SIZE], +} + +impl IronwoodAction { + /// Parse an action from exactly [`IRONWOOD_ACTION_SIZE`] bytes. + fn from_bytes(b: &[u8]) -> Result { + if b.len() != IRONWOOD_ACTION_SIZE { + return Err(format!( + "Ironwood action must be {} bytes, got {}", + IRONWOOD_ACTION_SIZE, + b.len() + )); + } + let mut off = 0; + let mut take32 = || { + let mut a = [0u8; 32]; + a.copy_from_slice(&b[off..off + 32]); + off += 32; + a + }; + let cv = take32(); + let nullifier = take32(); + let rk = take32(); + let cmx = take32(); + let ephemeral_key = take32(); + let mut enc_ciphertext = [0u8; ENC_CIPHERTEXT_SIZE]; + enc_ciphertext.copy_from_slice(&b[off..off + ENC_CIPHERTEXT_SIZE]); + off += ENC_CIPHERTEXT_SIZE; + let mut out_ciphertext = [0u8; OUT_CIPHERTEXT_SIZE]; + out_ciphertext.copy_from_slice(&b[off..off + OUT_CIPHERTEXT_SIZE]); + Ok(IronwoodAction { + cv, + nullifier, + rk, + cmx, + ephemeral_key, + enc_ciphertext, + out_ciphertext, + }) + } + + /// Serialize the action to its [`IRONWOOD_ACTION_SIZE`]-byte wire form. + fn to_bytes(&self) -> Vec { + let mut out = Vec::with_capacity(IRONWOOD_ACTION_SIZE); + out.extend_from_slice(&self.cv); + out.extend_from_slice(&self.nullifier); + out.extend_from_slice(&self.rk); + out.extend_from_slice(&self.cmx); + out.extend_from_slice(&self.ephemeral_key); + out.extend_from_slice(&self.enc_ciphertext); + out.extend_from_slice(&self.out_ciphertext); + out + } +} + +/// A parsed Ironwood bundle (present only when there is at least one action). +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct IronwoodBundle { + /// Action array (each 820 bytes on the wire). + pub actions: Vec, + /// Flag byte: `0x07` = spends | outputs | cross-address. + pub flags: u8, + /// Net value crossing the Ironwood pool boundary. + pub value_balance: i64, + /// Ironwood note-commitment tree root (separate from Orchard's). + pub anchor: [u8; 32], + /// Opaque `PostNu6_3` Halo2 proof bytes (server-provided). + pub proof: Vec, + /// One RedPallas spend-auth signature per action. + pub spend_auth_sigs: Vec<[u8; 64]>, + /// RedPallas binding signature (server-provided). + pub binding_sig: [u8; 64], +} + +/// A fully parsed Zcash v6 (Ironwood) transaction. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ZcashV6Transaction { + /// Version group id (always [`ZCASH_IRONWOOD_VERSION_GROUP_ID`]). + pub version_group_id: u32, + /// Consensus branch id carried in the header (v5/v6 only). + pub consensus_branch_id: u32, + /// Transparent portion: inputs, outputs, and lock_time (version reads as 6). + pub transparent: Transaction, + /// Expiry height. + pub expiry_height: u32, + /// Sapling net value balance (0 for our flows; non-zero bundles are unsupported). + pub sapling_value_balance: i64, + /// Ironwood bundle, if any actions are present. + pub ironwood_bundle: Option, +} + +fn read_u32_le(r: &mut &[u8]) -> Result { + u32::consensus_decode(r).map_err(|e| format!("v6: failed to read u32: {}", e)) +} + +fn read_i64_le(r: &mut &[u8]) -> Result { + i64::consensus_decode(r).map_err(|e| format!("v6: failed to read i64: {}", e)) +} + +fn read_varint(r: &mut &[u8]) -> Result { + VarInt::consensus_decode(r) + .map(|v| v.0) + .map_err(|e| format!("v6: failed to read varint: {}", e)) +} + +fn take_bytes<'a>(r: &mut &'a [u8], n: usize) -> Result<&'a [u8], String> { + if r.len() < n { + return Err(format!("v6: expected {} bytes, only {} left", n, r.len())); + } + let (head, tail) = r.split_at(n); + *r = tail; + Ok(head) +} + +fn take_array(r: &mut &[u8]) -> Result<[u8; N], String> { + let bytes = take_bytes(r, N)?; + let mut a = [0u8; N]; + a.copy_from_slice(bytes); + Ok(a) +} + +/// Decode a v6 (Ironwood) transaction from its raw wire bytes. +pub fn decode_v6_transaction(bytes: &[u8]) -> Result { + let mut r = bytes; + + // --- Header --- + let version = read_u32_le(&mut r)?; + if version != ZCASH_V6_VERSION_HEADER { + return Err(format!( + "v6: unexpected version header 0x{:08x} (expected 0x{:08x})", + version, ZCASH_V6_VERSION_HEADER + )); + } + let version_group_id = read_u32_le(&mut r)?; + if version_group_id != ZCASH_IRONWOOD_VERSION_GROUP_ID { + return Err(format!( + "v6: unexpected version group id 0x{:08x} (expected 0x{:08x})", + version_group_id, ZCASH_IRONWOOD_VERSION_GROUP_ID + )); + } + let consensus_branch_id = read_u32_le(&mut r)?; + let lock_time = read_u32_le(&mut r)?; + let expiry_height = read_u32_le(&mut r)?; + + // --- Transparent --- + let inputs: Vec = + Vec::consensus_decode(&mut r).map_err(|e| format!("v6: failed to decode inputs: {}", e))?; + let outputs: Vec = Vec::consensus_decode(&mut r) + .map_err(|e| format!("v6: failed to decode outputs: {}", e))?; + + let transparent = Transaction { + version: miniscript::bitcoin::transaction::Version::non_standard(6), + input: inputs, + output: outputs, + lock_time: miniscript::bitcoin::locktime::absolute::LockTime::from_consensus(lock_time), + }; + + // --- Sapling (empty-only support) --- + let n_spends_sapling = read_varint(&mut r)?; + let n_outputs_sapling = read_varint(&mut r)?; + if n_spends_sapling != 0 || n_outputs_sapling != 0 { + return Err("v6: non-empty Sapling bundle is not supported by this parser".to_string()); + } + // valueBalanceSapling / anchor / proofs are only present when counts > 0, so nothing to read. + let sapling_value_balance = 0i64; + + // --- Orchard v6 slot (empty-only support) --- + let n_actions_orchard = read_varint(&mut r)?; + if n_actions_orchard != 0 { + return Err("v6: non-empty Orchard v6 bundle is not supported by this parser".to_string()); + } + + // --- Ironwood bundle --- + let n_actions_ironwood = read_varint(&mut r)?; + let ironwood_bundle = if n_actions_ironwood == 0 { + None + } else { + let n = usize::try_from(n_actions_ironwood) + .map_err(|_| "v6: action count overflow".to_string())?; + let mut actions = Vec::with_capacity(n); + for _ in 0..n { + let a = take_bytes(&mut r, IRONWOOD_ACTION_SIZE)?; + actions.push(IronwoodAction::from_bytes(a)?); + } + let flags = *take_bytes(&mut r, 1)?.first().unwrap(); + let value_balance = read_i64_le(&mut r)?; + let anchor = take_array::<32>(&mut r)?; + let proof_size = read_varint(&mut r)?; + let proof_size = + usize::try_from(proof_size).map_err(|_| "v6: proof size overflow".to_string())?; + let proof = take_bytes(&mut r, proof_size)?.to_vec(); + let mut spend_auth_sigs = Vec::with_capacity(n); + for _ in 0..n { + spend_auth_sigs.push(take_array::<64>(&mut r)?); + } + let binding_sig = take_array::<64>(&mut r)?; + Some(IronwoodBundle { + actions, + flags, + value_balance, + anchor, + proof, + spend_auth_sigs, + binding_sig, + }) + }; + + if !r.is_empty() { + return Err(format!( + "v6: {} trailing bytes after Ironwood bundle", + r.len() + )); + } + + Ok(ZcashV6Transaction { + version_group_id, + consensus_branch_id, + transparent, + expiry_height, + sapling_value_balance, + ironwood_bundle, + }) +} + +/// Encode a v6 (Ironwood) transaction back to its raw wire bytes. +pub fn encode_v6_transaction(tx: &ZcashV6Transaction) -> Result, String> { + if tx.sapling_value_balance != 0 { + return Err( + "v6: non-zero Sapling value balance is not supported by this encoder".to_string(), + ); + } + let mut out = Vec::new(); + + // --- Header --- + ZCASH_V6_VERSION_HEADER + .consensus_encode(&mut out) + .map_err(|e| format!("v6: failed to encode version: {}", e))?; + tx.version_group_id + .consensus_encode(&mut out) + .map_err(|e| format!("v6: failed to encode version group id: {}", e))?; + tx.consensus_branch_id + .consensus_encode(&mut out) + .map_err(|e| format!("v6: failed to encode consensus branch id: {}", e))?; + tx.transparent + .lock_time + .consensus_encode(&mut out) + .map_err(|e| format!("v6: failed to encode lock_time: {}", e))?; + tx.expiry_height + .consensus_encode(&mut out) + .map_err(|e| format!("v6: failed to encode expiry_height: {}", e))?; + + // --- Transparent --- + tx.transparent + .input + .consensus_encode(&mut out) + .map_err(|e| format!("v6: failed to encode inputs: {}", e))?; + tx.transparent + .output + .consensus_encode(&mut out) + .map_err(|e| format!("v6: failed to encode outputs: {}", e))?; + + // --- Sapling (empty) --- + VarInt(0) + .consensus_encode(&mut out) + .map_err(|e| format!("v6: failed to encode nSpendsSapling: {}", e))?; + VarInt(0) + .consensus_encode(&mut out) + .map_err(|e| format!("v6: failed to encode nOutputsSapling: {}", e))?; + + // --- Orchard v6 slot (empty) --- + VarInt(0) + .consensus_encode(&mut out) + .map_err(|e| format!("v6: failed to encode nActionsOrchard: {}", e))?; + + // --- Ironwood bundle --- + match &tx.ironwood_bundle { + None => { + VarInt(0) + .consensus_encode(&mut out) + .map_err(|e| format!("v6: failed to encode nActionsIronwood: {}", e))?; + } + Some(bundle) => { + let n = bundle.actions.len(); + if bundle.spend_auth_sigs.len() != n { + return Err(format!( + "v6: spend_auth_sigs count {} does not match action count {}", + bundle.spend_auth_sigs.len(), + n + )); + } + VarInt(n as u64) + .consensus_encode(&mut out) + .map_err(|e| format!("v6: failed to encode nActionsIronwood: {}", e))?; + for a in &bundle.actions { + out.extend_from_slice(&a.to_bytes()); + } + out.push(bundle.flags); + bundle + .value_balance + .consensus_encode(&mut out) + .map_err(|e| format!("v6: failed to encode valueBalanceIronwood: {}", e))?; + out.extend_from_slice(&bundle.anchor); + VarInt(bundle.proof.len() as u64) + .consensus_encode(&mut out) + .map_err(|e| format!("v6: failed to encode proofsSize: {}", e))?; + out.extend_from_slice(&bundle.proof); + for sig in &bundle.spend_auth_sigs { + out.extend_from_slice(sig); + } + out.extend_from_slice(&bundle.binding_sig); + } + } + + Ok(out) +} + +#[cfg(test)] +mod tests { + use super::*; + use miniscript::bitcoin::hashes::Hash; + use miniscript::bitcoin::{Amount, OutPoint, ScriptBuf, Sequence, Txid, Witness}; + + fn sample_action(seed: u8) -> IronwoodAction { + IronwoodAction { + cv: [seed; 32], + nullifier: [seed.wrapping_add(1); 32], + rk: [seed.wrapping_add(2); 32], + cmx: [seed.wrapping_add(3); 32], + ephemeral_key: [seed.wrapping_add(4); 32], + enc_ciphertext: [seed.wrapping_add(5); ENC_CIPHERTEXT_SIZE], + out_ciphertext: [seed.wrapping_add(6); OUT_CIPHERTEXT_SIZE], + } + } + + fn sample_transparent(with_io: bool) -> Transaction { + let (input, output) = if with_io { + ( + vec![TxIn { + previous_output: OutPoint::new(Txid::all_zeros(), 0), + script_sig: ScriptBuf::from(vec![0x51u8, 0x52]), + sequence: Sequence::MAX, + witness: Witness::new(), + }], + vec![TxOut { + value: Amount::from_sat(12_345), + script_pubkey: ScriptBuf::from(vec![0x76u8, 0xa9, 0x14]), + }], + ) + } else { + (vec![], vec![]) + }; + Transaction { + version: miniscript::bitcoin::transaction::Version::non_standard(6), + input, + output, + lock_time: miniscript::bitcoin::locktime::absolute::LockTime::from_consensus(0), + } + } + + fn action_round_trip(a: &IronwoodAction) { + let bytes = a.to_bytes(); + assert_eq!(bytes.len(), IRONWOOD_ACTION_SIZE); + assert_eq!(&IronwoodAction::from_bytes(&bytes).unwrap(), a); + } + + #[test] + fn action_bytes_round_trip() { + action_round_trip(&sample_action(1)); + action_round_trip(&sample_action(200)); + } + + #[test] + fn round_trip_ironwood_only() { + let tx = ZcashV6Transaction { + version_group_id: ZCASH_IRONWOOD_VERSION_GROUP_ID, + consensus_branch_id: 0x37a5165b, + transparent: sample_transparent(false), + expiry_height: 0, + sapling_value_balance: 0, + ironwood_bundle: Some(IronwoodBundle { + actions: vec![sample_action(1), sample_action(50)], + flags: 0x07, + value_balance: -1000, + anchor: [9u8; 32], + proof: vec![0xabu8; 300], + spend_auth_sigs: vec![[1u8; 64], [2u8; 64]], + binding_sig: [7u8; 64], + }), + }; + let bytes = encode_v6_transaction(&tx).unwrap(); + let decoded = decode_v6_transaction(&bytes).unwrap(); + assert_eq!(decoded, tx); + // Re-encode is byte-stable. + assert_eq!(encode_v6_transaction(&decoded).unwrap(), bytes); + } + + #[test] + fn round_trip_shield_with_transparent_inputs() { + let tx = ZcashV6Transaction { + version_group_id: ZCASH_IRONWOOD_VERSION_GROUP_ID, + consensus_branch_id: 0x37a5165b, + transparent: sample_transparent(true), + expiry_height: 12_000, + sapling_value_balance: 0, + ironwood_bundle: Some(IronwoodBundle { + actions: vec![sample_action(3)], + flags: 0x07, + value_balance: -5000, + anchor: [4u8; 32], + proof: vec![0xcdu8; 192], + spend_auth_sigs: vec![[8u8; 64]], + binding_sig: [6u8; 64], + }), + }; + let bytes = encode_v6_transaction(&tx).unwrap(); + assert_eq!(decode_v6_transaction(&bytes).unwrap(), tx); + } + + #[test] + fn round_trip_empty_ironwood_slot() { + let tx = ZcashV6Transaction { + version_group_id: ZCASH_IRONWOOD_VERSION_GROUP_ID, + consensus_branch_id: 0x37a5165b, + transparent: sample_transparent(true), + expiry_height: 0, + sapling_value_balance: 0, + ironwood_bundle: None, + }; + let bytes = encode_v6_transaction(&tx).unwrap(); + assert_eq!(decode_v6_transaction(&bytes).unwrap(), tx); + } + + #[test] + fn rejects_trailing_bytes() { + let tx = ZcashV6Transaction { + version_group_id: ZCASH_IRONWOOD_VERSION_GROUP_ID, + consensus_branch_id: 0x37a5165b, + transparent: sample_transparent(false), + expiry_height: 0, + sapling_value_balance: 0, + ironwood_bundle: None, + }; + let mut bytes = encode_v6_transaction(&tx).unwrap(); + bytes.push(0xff); + assert!(decode_v6_transaction(&bytes).is_err()); + } + + #[test] + fn spend_auth_sig_count_mismatch_errors() { + let tx = ZcashV6Transaction { + version_group_id: ZCASH_IRONWOOD_VERSION_GROUP_ID, + consensus_branch_id: 0x37a5165b, + transparent: sample_transparent(false), + expiry_height: 0, + sapling_value_balance: 0, + ironwood_bundle: Some(IronwoodBundle { + actions: vec![sample_action(1), sample_action(2)], + flags: 0x07, + value_balance: 0, + anchor: [0u8; 32], + proof: vec![], + spend_auth_sigs: vec![[1u8; 64]], // only one, but two actions + binding_sig: [0u8; 64], + }), + }; + assert!(encode_v6_transaction(&tx).is_err()); + } +} From 3743ca1428bc8ffb75a0ab1a2994a8bee7872515 Mon Sep 17 00:00:00 2001 From: Veetrag Jain Date: Tue, 21 Jul 2026 18:25:59 +0530 Subject: [PATCH 3/8] feat(wasm-utxo): add ZIP-244 ironwood_digest utility Implements the shared ironwood_digest (S3) used by both the v6 txid (S2) and the shielded sighash. Three-way BLAKE2b split over per-action byte ranges (compact/memos/noncompact) combined with flags + valueBalance; anchor, proof, and signatures are excluded per ZIP-244. Adds an orchard_v6_empty_digest helper for the empty Orchard slot. Co-Authored-By: Claude Opus 4.8 --- packages/wasm-utxo/src/zcash/v6.rs | 165 +++++++++++++++++++++++++++++ 1 file changed, 165 insertions(+) diff --git a/packages/wasm-utxo/src/zcash/v6.rs b/packages/wasm-utxo/src/zcash/v6.rs index a1a652c2384..72409795b6b 100644 --- a/packages/wasm-utxo/src/zcash/v6.rs +++ b/packages/wasm-utxo/src/zcash/v6.rs @@ -23,10 +23,27 @@ //! Sapling or Orchard bundles are rejected with a clear error rather than //! mis-parsed, since no BitGo flow emits them. +use super::blake2b::blake2b_256_personal; use super::transaction::{ZCASH_IRONWOOD_VERSION_GROUP_ID, ZCASH_V6_VERSION_HEADER}; use miniscript::bitcoin::consensus::{Decodable, Encodable}; use miniscript::bitcoin::{Transaction, TxIn, TxOut, VarInt}; +/// ZIP-244 personalization for the Ironwood bundle digest. +pub const ZTXID_IRONWOOD_HASH_PERSONAL: &[u8; 16] = b"ZTxIdIronwd_H_v6"; +/// ZIP-244 personalization for the Ironwood compact-actions sub-digest. +pub const ZTXID_IRONWOOD_COMPACT_PERSONAL: &[u8; 16] = b"ZTxIdIrnActCH_v6"; +/// ZIP-244 personalization for the Ironwood memos sub-digest. +pub const ZTXID_IRONWOOD_MEMOS_PERSONAL: &[u8; 16] = b"ZTxIdIrnActMH_v6"; +/// ZIP-244 personalization for the Ironwood non-compact actions sub-digest. +pub const ZTXID_IRONWOOD_NONCOMPACT_PERSONAL: &[u8; 16] = b"ZTxIdIrnActNH_v6"; +/// ZIP-244 personalization for the (v6-personalized) Orchard slot digest. +pub const ZTXID_ORCHARD_V6_HASH_PERSONAL: &[u8; 16] = b"ZTxIdOrchardH_v6"; + +/// Boundary between the compact note plaintext and the memo inside `encCiphertext`. +const ENC_COMPACT_END: usize = 52; +/// Boundary between the memo and the Poly1305 tag inside `encCiphertext`. +const ENC_MEMO_END: usize = 564; + /// Serialized size of a single Ironwood action on the wire. pub const IRONWOOD_ACTION_SIZE: usize = 820; @@ -126,6 +143,74 @@ pub struct IronwoodBundle { pub binding_sig: [u8; 64], } +impl IronwoodBundle { + /// Compute the ZIP-244 `ironwood_digest` over this bundle. + /// + /// Per ZIP-244 the digest is a three-way split over the per-action byte ranges, + /// combined with the flag byte and (little-endian) value balance: + /// + /// ```text + /// compactHash = BLAKE2b("ZTxIdIrnActCH_v6", per-action: nullifier ‖ cmx ‖ epk ‖ enc[0..52]) + /// memosHash = BLAKE2b("ZTxIdIrnActMH_v6", per-action: enc[52..564]) + /// noncompactHash = BLAKE2b("ZTxIdIrnActNH_v6", per-action: cv ‖ rk ‖ enc[564..580] ‖ outCiphertext) + /// ironwood_digest = BLAKE2b("ZTxIdIronwd_H_v6", + /// compactHash ‖ memosHash ‖ noncompactHash ‖ flags ‖ valueBalance) + /// ``` + /// + /// The anchor, proof, and signatures are intentionally excluded — the digest + /// covers action data only (see ZIP-244 §"Ironwood bundle digest"). + pub fn digest(&self) -> [u8; 32] { + let mut compact = Vec::new(); + let mut memos = Vec::new(); + let mut noncompact = Vec::new(); + + for a in &self.actions { + compact.extend_from_slice(&a.nullifier); + compact.extend_from_slice(&a.cmx); + compact.extend_from_slice(&a.ephemeral_key); + compact.extend_from_slice(&a.enc_ciphertext[..ENC_COMPACT_END]); + + memos.extend_from_slice(&a.enc_ciphertext[ENC_COMPACT_END..ENC_MEMO_END]); + + noncompact.extend_from_slice(&a.cv); + noncompact.extend_from_slice(&a.rk); + noncompact.extend_from_slice(&a.enc_ciphertext[ENC_MEMO_END..]); + noncompact.extend_from_slice(&a.out_ciphertext); + } + + let compact_hash = blake2b_256_personal(&compact, ZTXID_IRONWOOD_COMPACT_PERSONAL); + let memos_hash = blake2b_256_personal(&memos, ZTXID_IRONWOOD_MEMOS_PERSONAL); + let noncompact_hash = blake2b_256_personal(&noncompact, ZTXID_IRONWOOD_NONCOMPACT_PERSONAL); + + let mut body = Vec::with_capacity(32 * 3 + 1 + 8); + body.extend_from_slice(&compact_hash); + body.extend_from_slice(&memos_hash); + body.extend_from_slice(&noncompact_hash); + body.push(self.flags); + body.extend_from_slice(&self.value_balance.to_le_bytes()); + + blake2b_256_personal(&body, ZTXID_IRONWOOD_HASH_PERSONAL) + } +} + +/// ZIP-244 `ironwood_digest` for a v6 transaction's Ironwood slot. +/// +/// An absent bundle (`nActionsIronwood = 0`) hashes to the empty personalized digest. +pub fn ironwood_digest(bundle: Option<&IronwoodBundle>) -> [u8; 32] { + match bundle { + Some(b) => b.digest(), + None => blake2b_256_personal(&[], ZTXID_IRONWOOD_HASH_PERSONAL), + } +} + +/// ZIP-244 digest for the (empty) v6 Orchard slot. +/// +/// BitGo Ironwood flows never populate the Orchard v6 slot, so this is always the +/// empty personalized digest. +pub fn orchard_v6_empty_digest() -> [u8; 32] { + blake2b_256_personal(&[], ZTXID_ORCHARD_V6_HASH_PERSONAL) +} + /// A fully parsed Zcash v6 (Ironwood) transaction. #[derive(Debug, Clone, PartialEq, Eq)] pub struct ZcashV6Transaction { @@ -499,6 +584,86 @@ mod tests { assert!(decode_v6_transaction(&bytes).is_err()); } + fn sample_bundle() -> IronwoodBundle { + IronwoodBundle { + actions: vec![sample_action(1), sample_action(50)], + flags: 0x07, + value_balance: -1000, + anchor: [9u8; 32], + proof: vec![0xabu8; 300], + spend_auth_sigs: vec![[1u8; 64], [2u8; 64]], + binding_sig: [7u8; 64], + } + } + + #[test] + fn digest_is_deterministic() { + let b = sample_bundle(); + assert_eq!(b.digest(), b.digest()); + } + + #[test] + fn digest_excludes_anchor_proof_and_sigs() { + let b = sample_bundle(); + let base = b.digest(); + + let mut b2 = b.clone(); + b2.anchor = [0xffu8; 32]; + b2.proof = vec![0u8; 10]; + b2.spend_auth_sigs = vec![[9u8; 64], [9u8; 64]]; + b2.binding_sig = [0xeeu8; 64]; + assert_eq!( + b2.digest(), + base, + "anchor/proof/sigs must not affect the digest" + ); + } + + #[test] + fn digest_depends_on_flags_and_value_balance() { + let b = sample_bundle(); + let base = b.digest(); + + let mut b_flags = b.clone(); + b_flags.flags = 0x03; + assert_ne!(b_flags.digest(), base); + + let mut b_vb = b.clone(); + b_vb.value_balance = 1000; + assert_ne!(b_vb.digest(), base); + } + + #[test] + fn digest_depends_on_action_data() { + let b = sample_bundle(); + let base = b.digest(); + + let mut b2 = b.clone(); + b2.actions[0].cmx = [0xaau8; 32]; + assert_ne!(b2.digest(), base); + } + + #[test] + fn empty_ironwood_digest_is_empty_personalized_hash() { + let expected = super::blake2b_256_personal(&[], super::ZTXID_IRONWOOD_HASH_PERSONAL); + assert_eq!(ironwood_digest(None), expected); + } + + #[test] + fn ironwood_digest_dispatches_to_bundle() { + let b = sample_bundle(); + assert_eq!(ironwood_digest(Some(&b)), b.digest()); + } + + #[test] + fn personalization_strings_are_16_bytes() { + assert_eq!(ZTXID_IRONWOOD_HASH_PERSONAL.len(), 16); + assert_eq!(ZTXID_IRONWOOD_COMPACT_PERSONAL.len(), 16); + assert_eq!(ZTXID_IRONWOOD_MEMOS_PERSONAL.len(), 16); + assert_eq!(ZTXID_IRONWOOD_NONCOMPACT_PERSONAL.len(), 16); + assert_eq!(ZTXID_ORCHARD_V6_HASH_PERSONAL.len(), 16); + } + #[test] fn spend_auth_sig_count_mismatch_errors() { let tx = ZcashV6Transaction { From aefce599a59a4dffcd1a4704b62acd7aa27aeb5b Mon Sep 17 00:00:00 2001 From: Veetrag Jain Date: Tue, 21 Jul 2026 18:39:35 +0530 Subject: [PATCH 4/8] feat(wasm-utxo): add ZIP-244 v6 txid computation Implements the five-component ZIP-244 hash tree (S2): header, transparent (txid form), sapling, orchard-v6, and ironwood digests combined under the ZcashTxHash_ personalization. Adds zcash_compute_v6_txid WASM export. v4 txid (sha256d) path is unchanged. Validated against a real testnet shielding transaction captured from the Ironwood reference (txid b5af4de4...): the parser, encoder (byte-identical re-encode), and txid all match the golden vector. Co-Authored-By: Claude Opus 4.8 --- .../src/wasm/fixed_script_wallet/mod.rs | 12 ++ .../src/zcash/testdata_v6_shield_rawtx.hex | 1 + .../src/zcash/testdata_v6_shield_txid.hex | 1 + packages/wasm-utxo/src/zcash/v6.rs | 162 ++++++++++++++++++ 4 files changed, 176 insertions(+) create mode 100644 packages/wasm-utxo/src/zcash/testdata_v6_shield_rawtx.hex create mode 100644 packages/wasm-utxo/src/zcash/testdata_v6_shield_txid.hex 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 3c57d01f3a9..13cb0cbe07e 100644 --- a/packages/wasm-utxo/src/wasm/fixed_script_wallet/mod.rs +++ b/packages/wasm-utxo/src/wasm/fixed_script_wallet/mod.rs @@ -2020,3 +2020,15 @@ pub fn zcash_branch_id_for_height(network: &str, height: u32) -> Result Result, JsValue> { + crate::zcash::v6::compute_v6_txid_from_bytes(tx_bytes) + .map(|h| h.to_vec()) + .map_err(|e| JsValue::from_str(&e)) +} diff --git a/packages/wasm-utxo/src/zcash/testdata_v6_shield_rawtx.hex b/packages/wasm-utxo/src/zcash/testdata_v6_shield_rawtx.hex new file mode 100644 index 00000000000..f303bff75ca --- /dev/null +++ b/packages/wasm-utxo/src/zcash/testdata_v6_shield_rawtx.hex @@ -0,0 +1 @@ +0600008098b684d85b16a537000000000000000002beeb1e0d90b3865e9ec38007621cce4a5962b4df0ae8fa7322f080e9d80e9abb000000006b483045022100ec8b4dae84c657bee2e476f3d4d34ec8a3eca52a76063eea502a1865291082b502200a725e011990a4a6f1b18b7fe44374cd6f8f98779f2aaa3441894544ec5de13201210349f86487c6bfe475672cae5bf468ab6985de32b1b2aa8729aa7758bf57c6f747ffffffff43581a8023f6fd2780aad97f36162747281de28679b0c4a18e870f758c360eac000000006a4730440220592190973848743df5ada03361fed26250b6063f5f60ebe08e93dae10cfc1fa0022041059d509146d86730c13a65ba8a9dee127618cc500e0106a0423c321ca482c501210349f86487c6bfe475672cae5bf468ab6985de32b1b2aa8729aa7758bf57c6f747ffffffff0000000001306ad1c03b2b19fe9bf732651d6d2bc6a2e8c2d0d4fcf16bda014530c04bd1b7e1316540f064433e164b1f85da6bd16317e1462db3a91b866600d62ab9d4c9265b5370b42edeacb3d24210a3d4cc096415e54c13d9b3b1ca818fca850523613cfd897aeef33102e7142210f8ac1d3ea964a53f935fd6683d04f26ac6e359e80ddb104afd368a02a06a3a2baf08612630fb707c637d8cb625ff2f0e9cb6efaf1e45586504186efc901fa2413a81b6393306e23b011dd4a7065cb9eaad471ffc1caed0aeff7c4d0352826f8f1c44f15074a335cec01418211fb869e845ca8df10bd1833235aef0f8bfddb6b61e0da6d90cae4b321e1e94d21791d0593e090804e8873ca02e44a4b4098a84576c230e558799d9ba436c0c87f1a5efe5f09547b035d01775e5e17398db1e8260a57535d0f663f5812bd1abc72728e7400ba0779fb625cc47c604bebb046b2f9d2172d16a3d37300955e2dd03b940f135c36a52da97a4f76f2163c5f4b62be501c17651b6719d7e4ff710b1eeb37add6f30bae2f524dbccc71737810cfdfaccd59b332666d35ffeea42ab34f9000c2e761f0949eb2b908718082e847ac773599c1b5e446eda498a2bf060c4e0fe2011bb506a217a3d77ef495bdb248127012b5ba5c022106b10b1e8eb4d289c1f331b0abc676e07b1dcef440c44bbf44a652f06a26368fd25a5a7e169580ef9fa0c507ac85b511575b2abd9cb44898ea276d611eb1cea6ef3fbc5860d5f294cb75a01e519aa230e3a4722e8a067f1d8931a3d2d06c7ef3652b2df5ced9edeab1c0f6a24ddc1746442e11f653bcc356f09d435e8cefa425d20e3caa0c233c5bd845266c383e2e51e600c34f66a61d275669ea093f11af3dd983629d702bdc9ecd41818cc4f0966a436c02e2ee2d46d0c058d298b750e111c760cfa310128a76904c17a133411c4043f1ab2f119bcb2001965b806235b093f437c43b86ef3b5f1f527815ca782abed61d3562a04244a87506ddb687cbe4ad5bee4e9255040c658f73486514fe9018aea8c732b1bac63ac8045ca39afd5392b1cdfc9115604c70d3df644787f255737595a7c1aeacaffce6151d383175007dc805c22b17aad0eaa0969d34b87d0f273cbc4a170c8758187db3c601c071613db69a9da8ea407504848edffffffff078ea7c31d1eba2b82661ab6f5d6678b5c1e1d402ab1045a515c73ba7bee4535fd80132356e71ff4ce333bb61bcb4436a99066c9711f3fc9fd56c81d53ef594dccf13270238bc48bec8ef7007113a15153c92117be2730b4e309c1a63d1aec2aec911a6d8bea4f1574e3bdd7f2a25393177d8516f02425e0272a53a096f62f6c86a00de4bae753415f25ada0c79e53c10e7a21f81969f2ee4a199e1a5a57004635b31b073d1703aeec4dfa2c237264557731a8bd3174f01832d9b5c59989e090d08fb003fae2538556b4c379170e5206ca188fe5169b52d2ba3f240889eea12b5e1c91de24c1edd98ae47cc10106484d9397891023523e536aebafe7b050bdc81724ae3d25a0af747104a4d3995e87d8f0989ada6be7b32bcc5388598d85c5329bb8b755b7f3cc92f3bb39b003db831c49a1096c6f549f7d7cbc7a8fdece34af7375bc267360d6df946c3504a730da229ddee03011ad6b985c8b93c3e8660880ce30b0fca0642c3620a9196e6e29799a842db46bf7159032036300b8c4f8443f757e2b1f1611105472c484f896680efdd98a6a01b6c7d53387c7f4803bc8e94c7ad8a6cd99efe4c97fef0a28debed634421981476a9e506cab571a1256bdd7b173fb0e309086e2979984cad8eaac6e115891c0552fffa837b2954c0f436a210e417c17685ec5343d9d1a7777f576c47120a198a77170c360b3fd28d510f01b64ed3a1f0423fb222ffe5e3ccca64aff43a186eef7f4f86c88751dbd06444d9d2d026189a0e6c8c2f9892d5f9b353f65ea18d969b8e1df20f2d930c4dcf41fc3ac816b3170decb8e3bc82a65db74eb9e6f9a6bb25114cad2a701291a0974a8959854a09de9a390d626e61bb15981c3d45a94856e3d437959b0a88d5723520ae6d585032bee81bc06d265027987062ffe6fd7a079d469350176c38b35122b9d20adea0934a607e042e35f7639a961ccd7e13ca7735c7b5779d6e9d0bd41c490970e7e43a1626e68859aa5b3b97cbb69e049dcbe7b3dc6e75a4ac421f7246ad1b82bb2368e7c97adc28c03eb752dcfd44d8a93a0e6740a115b10d45891fbafca9c0f5f77a6a5b741d4d06d6fa7b53bc84a3f46e273ca4a5f48639545bf919ce1ed8c747020948947488f41860ecf519691b6f61b4f8ef139abe7b9dbdc44dd09ce11844e208dd65ef040f1e548e80c795a6cf66837b082b32772fc4e25ca3965c48f69efbc93b4485d87db15e827214ee156a95b158b9b303e9c727a0f815f777d86d3cc98e3efd24f9a9bffd26e309763bb865d955dbdd6db9af607829e70b3ec607fd0ad054db44f2ef8bf6fc1e14b4492cf8d8c1f70a48654fcf66ed623cc17feeeb586f4cd9a7eab836df082c162573759128c9bc86a04a48474813068a1f52513678961aad2fdeb0937ac6951fbce08d283340cac7ad6f12c4a49bba819cf0005c41413b849bd6709b5daacece98b77d5832ad5ac2ca512f68a5579757acb821a17198b17e2e4b8cde556ff1f3e736dc310a2c115e178367a1cc9996af51760853d2c5fb0257b4b4fe76b6baf99b0483c555fd35f1d32a0b27ca4b5f1566992268426557a8a53689e216d0af0efb89e439552eabd1fc0a85343bcb470f051566b9d31cd8bdffa0edb6bcc81c91262ad10ee2fda3317d459cf1326e61059cd06111e122bfe1fbdf1890fb41bf618bd448112c9a8ba583d1cb62b9723f509dab01530384f660a1a0b0d2a1c53880260c5c31eb1ede3f5e3a066534ae78b6e428bf7673aaf688778053dcbe16d1336f2b34476387cb41e649f0ec7bb919bc377d9ecc40604c91eff3f5f0459a8df55f0393b82c385632c0df1263ddc8ff73c994a9dbd16f2118a3c95ddc7d39db779e8307ffb7846f9655668244f2d89e8e50ea2c3e716374fd303a924c2735a921bcec485c903ff0b4178c65c5c3ca60fe6c1b1267c3d1cc8475f6ad56b7ca8c2b4fa8ca5e0f0780a97848b343e76cb17077f1ee515144aa44ee01676a66725d7c6feda237aa988779a13e4a16036e857aec50bd37e0dceb52ccde521d03303eb05373d533295a2af1963e87714074911dde48bea2a1d0ed6de091648e8f53ced982f094d17f7a295bca6bd90805a6da0632253e34616d8df532c6f98b00b67ca4f3099a82cf65fee186f6daf1782e367e4eeae6155190e822f9354e45215846c95dd99eacd5b13f23d4e7cde2ce64ef44b6bf411fc3d69708fe0571407ed74d01043c55eafdd532b0ab7288c1a71cea6e4198c0258129971b93183b633f5669e149106bf99c28c094c36d557680c862815e4692bfc18a575ebe483c072b4fa3c244a22dd410bfc594c737052f8c2b68142ad0d477e0f6339b2788be2b0929ea5d44f252b00afce9fb916d282acc30e940689c4c3c81d53acc0b87df4e4020d37e990d7c6e39c253315a6bfd3cd149c3baedd25b89315e8a5191b9d3e3e6237fe0871f1e7fcc151ed8789d920d69304d33a9ecc04c8063c0035107a249471f1f8f56b15e68fa5f363c4c5a04048d7075124fa9b46021ec89c2d2b3331efe384d4e812257fbc137fdca045b4686b23d855491f5628ea10d7443e5911d0b10f30611851d65b19ddb30305a29c4f8870f74d5b23e784101900875d6b8602fe839bcbacf65f6f7e9992ca5a80cdc837a819bc102de337c130daff5a3850237fc3c762c2eaab361c237ce2e5803c2f8137405bf83bb293731644e84a581b2e80aa534becdd55820bf5e28a8781e57c2f639da77eb5667fa11fde55200897b015289ab44c2159f0fd1948379607f6419bad1c8fed73dc93b30a1b6cb9cc8c7a695981f67af1fd9c3b7d6d8fbcce56da84c019e3efdd2e2b8812fe5bf75e7781cf3f692a49c5e6715e846c83a593fc3ddb3bbe2bff85c2cdbb0b33444212baf17c7ec862cedec8e5d497181ae3ed1b9d547e3fafb33d33333b0876aa590158652830ba49d4916c0703f47e79265f26f182dd5410cc2142d339367cdef69d2c8c74493eeb27e89dc7fc54f4d64558746139487d13cef3267c5d03f123399c356aabf69ac06f9de0d929f945b040b2b2e1258c3310efe4a20aec1ff9339525d4e4b31568a2b8ea05e9f0e63803ff71c4ac3157a388485e4442723dc1bd068ed48c354ed095316ab817773a4778d04dc96e727ae21dcdafd46378094d69fae3dba04d10e826486c9876ee790d7a3bfc6d60d256dd4933e0c795e40740b5e58c952305ad12e80391ccbc807231e4087d59d14dcc91e40ef4e758bf04b91a13d9f390818d00ff6926edf5b3ab7f8bd3c4ae70fe94632cccd9b0570a340dce0c6f5f9e0f4cc6a62d3f9e635032527e9276471d4d6063e1ea7a8edf072bb1b86cefc96e8d49094d8d58a439c92a04ff93ec929d86cadadc2c23ad741a3ca6fc3ce6e17cbf3ceb281a03206302a0bda99845e89765e42f03f1b24cde7b034c26e982443e4ec465e13212f9015c01ac46ccf30fcc6117b3869dc5c898c02a6bf927b48fa4155650d66004b22d8184c27d793e735ca312eb16df7c3734d104f045b50b239c748fc670b5065c184c755690f6080a3c8e1154ece95958711200a46c13cfff10ff34d9c5eaf6fa20a479fb1f36425f04f710c12461309018dd21d14eb7b0e2af464442b0f9e6bbaab9eca18dda49af44e982b6d59ff7c42d761feed88fec71b1d7faca79f84f01a6b7cd375f6f2e6c1c05a744064859a12c023584d9bf443fa8e8f8f2bdb62fd657fd4d066485a3171d011dd2f0050b09e27c1077ab7de9ed9dfc13d3e1a37ffe8f2cc01ada172f8da0cb8f093e8100d8781f1b08cb9f276450403e8f9c2e5ca2d0ca3c9da1f5df81d1814df8c77fbb69c8880ab24c53990c83ea1351f1c827d44fedbcd1880d8cfdc712853381f302e1bac208a0a74dbd414fddf27c6e1ea4cf674d9705d233a47a4096b4befb703206b08c3b4785ca8750661b100628a1082d63014a002a153da5d18c49c1e2eb4da0a01c2537e507fd2bf281303c087ae422918bb85df78015b89aeeadbdfd377518a87020f3fdf8df00d862146ac0a0177f65702326f5ab58bfc64aec6272465e1abfb800b63d7dad843988db8c787be0c8909a3a0c2d2eeaa2efe65db540bd57862d2f2e0e51566984076f5b058e6d0e1ee8b8d613e61d9a2ceebfbfbc834adfb59cf72d485bd42cdda955d2735440747a1ae8ec2588a5d05cbfaaeaefe9ad7af7def803e3c3971fd1daf3313ae9e201ef2df38de3b22b516e75ca118101847181d2543f6360880aa15ccb65df7f25caab99b1cf3a915cd2b0700da3b6942c66baa9440077515af4d18052d753751925fc0467fe4136087cc26ded17f281b423bf342b1ac87417f4b2a069a9529d11fc89b81e67edbf24f5573c0f8f8586fafdfc80942f1aa35120868be411809b93aca2313c27a0718c5e13219fe1fcc0b0bf4b790e3612a1788842a595c93c9f6ec11d3b6f6aba391503a38981df467b3d3265fb35208e66e94df61d4bbc28d5a04ea664f02344c6c736e68274476e2b0a796ad0290173be26a1c2a6314abff962a64f1376561e63d9f8901e983b9d17b26def1b171a0ec1283e75abc9b34d4cc31f9fee90f59a6cd751873f97b2215fed9a6b10a32a6140ccf6e8989728756fd07b4fc59dfca45cd6eadb96e3cbb85f89d1a9a82e21c304304a0ab930961586782a00fbd9fde305b37f55c7fe0489bdb257caff2d3a706c3139a984d3468f145ad6a95fb79020329eb0957aa99fd2b79f0a4669490a8ae0ce9977db8ea7d1585f7cc2cb46c7afc3dfb5b3cb25c8b258c30d8964c22fafbb6cff581c094d114e6a5d8e429dec7b083293da7ec33cae1c91dd9e5df209bd59e9dafd401f322acf028ff0b565b5363bc8e4158f06f8f848d402088bfa2d772c248edaeda9364762bf80628ebfda18ed5c5a15d18841434825f124ca2c3b8211bb8ef2eca1f01480b85284ef13e83722990146347a9cff0aaa39280cbe3eb97aecdfb72edf50452168e6aea5bb501d296bce0278a1bc5885597df397051ce2649bc8f8172c4ac06bd240f73129ba84c0c9ca6fa3c368b9693a84a99f5b2ba9be7b088f037a8b03e11979e24fbee57ec34b631858f65f44950ba8abaf37118212637b4b5f3c4c4b29b80a0cb2194b02e0f9f10ab5460950e1d27736de20297d73931fe1bf3fade4eb9efd151d34fb6a6364ec3ee611e55bb538a5daa74533f01082fc3dce2e2ecc746cd379697c508933edc59e4f983a6f43f0c92b2c1439c8b33d3e164de85bb0fcda7abf079811072ee1a755eb3d7104ab961244d7392096e8e2b9dc625930833c3a7f02af264d005b5c024746bd276320c0ebb314810446c93da487f00d95ab82a0ed3b3b00800aad23eccbe6acc365540d7233423710eaf8be1243453eb777e3432f130a756ad40bb63acb02d14039b3d2c36871121802834b51c91af0323ab3dab73bacae8fd951db2d52bf4af853a22884af017624120533c9657aa1076620a89903b785f6f0627c1d1277faaaa4d01447f6174404c1c1df78328a84ab043e697f7ff2ee0d93e0cadebf5a1566853e3e95681e631b0e4e1dfdfba6b6c24a0083023b46f39694f5c823a1970c737a485f5d4cde05023ad9cb8460e2462728101af57a35a07984eb52280c597cf0775c4a3523053000190d07b5948b254ead72a5d5bb1e26194cc3e28cd33de3358335fee3d11a591e7dcf2b4fe8e6eb032e09a4867cdc2b42464c492dfb45700c839ca2eb5b25a8babe8a215d65467b4a4100fbb5c2c2fd019f25f37944ba882c2e6d57713915c90fa5bb05119a1cd7953ae287b3a4cf61b451b4801420d2f383abaa1d281f13c0048a2e3760f6d931d7ed38d461faa90bd5c967e0661c8e57f4a6d35cfe72e6760ddeed8f29da89380be9e730f042b0a33513142adead348ac297afed57f133fe32c0e6c8d04843bc14b7d16e2a9bc45bada5313fc4d03180e60e85f609d496882bd8cc83423c28e6cf40a856c68eeead08173bde282264a2d9497fe419fdfe43a04347f310c4b345995fec083c653b9989831f71dfaf041a8e8602cff0e0519ebb58399a2cd2e09bf5f68a83f33f5a57b643780ee796183c8019935b07358dd40bed1dca295e7a651df0315e0ca97284f9d057c14c1af7f3d39ef74cc9bb6b650537e7e96f239738c8ce3df1d8fdb05cfe29a22894096772b8f84f755bb09740aa4a9d4bd7450fd521af805477e6dab5b6896c9a29a39d1795ef04bdeab93639b2c8c0cfec8ff18d353d3323a26d29802d2c9d116e34cfcf7763e2898118d4053e5927e9dc89f49579cfcf7eb5da55f904083def326fe7dfc90d772f574c578b944fa9789ad72f1b19b97417060d2a5855c9d563b11184923b58e204f23b606195ddf67da2c5c64eca56447cacc4f2fd4d54f4ef43a445ca10eab1ecfa28e1e48f9287725db3e45f84eedcdd23d238f80a76caa31760f32b2c846096ddfbd910363a03973a81f8e4b3ebe909cbbbc887e0d80b9ba1c2c9c530815fe03a844a68216b1c2d0ffb95c53a491ab8e9e3f9f3ac29af812e74b4d0e9705b5c23d8abcda822480fb41bcb09eb5a1794e67755425c4513f74ed7464aa1058c31e8ff357b813d98329cdd6ed786444901016a92ca69c1f5ccf459d08572d445cf8a22b2c32fa078379b2f3ce3377bcbaaab4d20db3ea136dfbc58c8ee49d75edd9accd3c7a19643b485e52208e21682b09c27ebc0e9440220e7598c33f35a4ee6162783ce16573b3e1c41f8066c9a075a5057b6042706e2821c0317019e6b9c9a2daf1fa5b6cbce9e30046ccf73459e0d5fd7f55c3c563fef0b732952a7e7a6ba813de4843dc9fd398163a19b5eae63999d083d4e38d1d3472fab428f152ad39c96f08837b573f231561cdedea703985ee0efa18da384cf7e0f72444f9999140cf26f9eed86121d41fd84ef3b386365131a78a1d7a994b1b31d4fb8f4d606b93c8032a5490e25a480d445b9fa0fe40eea194781c5ea3d9e82b3215d1d357e67d145317154816b8555511e7dcd94eaae4a0cbe51290d1b42e1e6563eb3224c9bc15b821b5b3ea57eaacfa77aad25d7a86238ebbcad9a2436fb42263a1a5227757ea972eede1384db5edff287f906e4e851c9e396c7316fcb3326de1acac31b799ef0286fcc86f04193821850954f39cacdbb747ff8e9337e01dc7692b942baccf5ae334d1a2703834050e009e55dd440c7437c92d5853aeb6538341dfb928a0e102d896d0e100e18e2c20486f9799e51377b3312f2b88f05559a744fe78a2051aa37a7e7b93d \ No newline at end of file diff --git a/packages/wasm-utxo/src/zcash/testdata_v6_shield_txid.hex b/packages/wasm-utxo/src/zcash/testdata_v6_shield_txid.hex new file mode 100644 index 00000000000..3e0fbdcad32 --- /dev/null +++ b/packages/wasm-utxo/src/zcash/testdata_v6_shield_txid.hex @@ -0,0 +1 @@ +b5af4de43476ca70a9b3e2b9441473acb142e045865f99e80a7f0df57f094541 \ No newline at end of file diff --git a/packages/wasm-utxo/src/zcash/v6.rs b/packages/wasm-utxo/src/zcash/v6.rs index 72409795b6b..07402f682bf 100644 --- a/packages/wasm-utxo/src/zcash/v6.rs +++ b/packages/wasm-utxo/src/zcash/v6.rs @@ -38,6 +38,21 @@ pub const ZTXID_IRONWOOD_MEMOS_PERSONAL: &[u8; 16] = b"ZTxIdIrnActMH_v6"; pub const ZTXID_IRONWOOD_NONCOMPACT_PERSONAL: &[u8; 16] = b"ZTxIdIrnActNH_v6"; /// ZIP-244 personalization for the (v6-personalized) Orchard slot digest. pub const ZTXID_ORCHARD_V6_HASH_PERSONAL: &[u8; 16] = b"ZTxIdOrchardH_v6"; +/// ZIP-244 personalization for the header digest. +pub const ZTXID_HEADERS_PERSONAL: &[u8; 16] = b"ZTxIdHeadersHash"; +/// ZIP-244 personalization for the transparent digest. +pub const ZTXID_TRANSPARENT_PERSONAL: &[u8; 16] = b"ZTxIdTranspaHash"; +/// ZIP-244 personalization for the prevouts sub-digest. +pub const ZTXID_PREVOUTS_PERSONAL: &[u8; 16] = b"ZTxIdPrevoutHash"; +/// ZIP-244 personalization for the sequences sub-digest. +pub const ZTXID_SEQUENCE_PERSONAL: &[u8; 16] = b"ZTxIdSequencHash"; +/// ZIP-244 personalization for the outputs sub-digest. +pub const ZTXID_OUTPUTS_PERSONAL: &[u8; 16] = b"ZTxIdOutputsHash"; +/// ZIP-244 personalization for the sapling digest. +pub const ZTXID_SAPLING_PERSONAL: &[u8; 16] = b"ZTxIdSaplingHash"; +/// ZIP-244 outer txid personalization prefix; the 4-byte consensus branch id +/// (little-endian) is appended to form the full 16-byte personalization. +pub const ZCASH_TXID_PERSONAL_PREFIX: &[u8; 12] = b"ZcashTxHash_"; /// Boundary between the compact note plaintext and the memo inside `encCiphertext`. const ENC_COMPACT_END: usize = 52; @@ -211,6 +226,99 @@ pub fn orchard_v6_empty_digest() -> [u8; 32] { blake2b_256_personal(&[], ZTXID_ORCHARD_V6_HASH_PERSONAL) } +/// ZIP-244 header digest: version, versionGroupId, consensusBranchId, lockTime, expiryHeight. +fn header_digest(tx: &ZcashV6Transaction) -> [u8; 32] { + let mut data = Vec::with_capacity(20); + data.extend_from_slice(&ZCASH_V6_VERSION_HEADER.to_le_bytes()); + data.extend_from_slice(&tx.version_group_id.to_le_bytes()); + data.extend_from_slice(&tx.consensus_branch_id.to_le_bytes()); + data.extend_from_slice(&tx.transparent.lock_time.to_consensus_u32().to_le_bytes()); + data.extend_from_slice(&tx.expiry_height.to_le_bytes()); + blake2b_256_personal(&data, ZTXID_HEADERS_PERSONAL) +} + +/// ZIP-244 transparent (txid) digest. +/// +/// When the transaction has neither transparent inputs nor outputs, this is the +/// empty personalized hash. Otherwise it combines the prevouts, sequences, and +/// outputs sub-digests (each computed over an empty input list when the +/// corresponding component is absent, e.g. the unshield case). +fn transparent_txid_digest(tx: &ZcashV6Transaction) -> [u8; 32] { + use miniscript::bitcoin::consensus::Encodable; + + let inputs = &tx.transparent.input; + let outputs = &tx.transparent.output; + + if inputs.is_empty() && outputs.is_empty() { + return blake2b_256_personal(&[], ZTXID_TRANSPARENT_PERSONAL); + } + + let mut prevouts = Vec::new(); + let mut sequences = Vec::new(); + for txin in inputs { + txin.previous_output + .consensus_encode(&mut prevouts) + .expect("vec write is infallible"); + txin.sequence + .consensus_encode(&mut sequences) + .expect("vec write is infallible"); + } + let mut outputs_data = Vec::new(); + for txout in outputs { + txout + .consensus_encode(&mut outputs_data) + .expect("vec write is infallible"); + } + + let prevouts_hash = blake2b_256_personal(&prevouts, ZTXID_PREVOUTS_PERSONAL); + let sequence_hash = blake2b_256_personal(&sequences, ZTXID_SEQUENCE_PERSONAL); + let outputs_hash = blake2b_256_personal(&outputs_data, ZTXID_OUTPUTS_PERSONAL); + + let mut data = Vec::with_capacity(96); + data.extend_from_slice(&prevouts_hash); + data.extend_from_slice(&sequence_hash); + data.extend_from_slice(&outputs_hash); + blake2b_256_personal(&data, ZTXID_TRANSPARENT_PERSONAL) +} + +/// Compute the ZIP-244 v6 txid: the five-component BLAKE2b hash tree. +/// +/// ```text +/// txid = BLAKE2b-256("ZcashTxHash_" ‖ consensusBranchId(LE), +/// header_digest ‖ transparent_digest ‖ sapling_digest +/// ‖ orchard_v6_digest ‖ ironwood_digest) +/// ``` +/// +/// The result is in internal byte order (as with the v4 sha256d txid); reverse it +/// for display. +pub fn compute_v6_txid(tx: &ZcashV6Transaction) -> [u8; 32] { + let header = header_digest(tx); + let transparent = transparent_txid_digest(tx); + // Sapling bundle is empty for all BitGo flows. + let sapling = blake2b_256_personal(&[], ZTXID_SAPLING_PERSONAL); + let orchard = orchard_v6_empty_digest(); + let ironwood = ironwood_digest(tx.ironwood_bundle.as_ref()); + + let mut data = Vec::with_capacity(32 * 5); + data.extend_from_slice(&header); + data.extend_from_slice(&transparent); + data.extend_from_slice(&sapling); + data.extend_from_slice(&orchard); + data.extend_from_slice(&ironwood); + + let mut personal = [0u8; 16]; + personal[..12].copy_from_slice(ZCASH_TXID_PERSONAL_PREFIX); + personal[12..].copy_from_slice(&tx.consensus_branch_id.to_le_bytes()); + + blake2b_256_personal(&data, &personal) +} + +/// Decode raw v6 transaction bytes and compute their ZIP-244 txid. +pub fn compute_v6_txid_from_bytes(bytes: &[u8]) -> Result<[u8; 32], String> { + let tx = decode_v6_transaction(bytes)?; + Ok(compute_v6_txid(&tx)) +} + /// A fully parsed Zcash v6 (Ironwood) transaction. #[derive(Debug, Clone, PartialEq, Eq)] pub struct ZcashV6Transaction { @@ -664,6 +772,60 @@ mod tests { assert_eq!(ZTXID_ORCHARD_V6_HASH_PERSONAL.len(), 16); } + /// Real testnet shielding transaction (branch id 37a5165b), captured from the + /// Ironwood reference sandbox: 2 transparent inputs, 0 outputs, 1 Ironwood action. + const GOLDEN_RAWTX_HEX: &str = include_str!("testdata_v6_shield_rawtx.hex"); + /// Its txid, in display (reversed) byte order. + const GOLDEN_TXID_DISPLAY: &str = include_str!("testdata_v6_shield_txid.hex"); + + #[test] + fn golden_shield_tx_parses_and_round_trips() { + let raw = hex::decode(GOLDEN_RAWTX_HEX.trim()).unwrap(); + let tx = decode_v6_transaction(&raw).unwrap(); + + assert_eq!(tx.version_group_id, ZCASH_IRONWOOD_VERSION_GROUP_ID); + assert_eq!(tx.consensus_branch_id, 0x37a5165b); + assert_eq!(tx.expiry_height, 0); + assert_eq!(tx.transparent.input.len(), 2); + assert_eq!(tx.transparent.output.len(), 0); + + let bundle = tx + .ironwood_bundle + .as_ref() + .expect("ironwood bundle present"); + assert_eq!(bundle.actions.len(), 1); + assert_eq!(bundle.flags, 0x07); + assert_eq!(bundle.value_balance, -314_030_000); + assert_eq!(bundle.proof.len(), 4992); + assert_eq!( + hex::encode(bundle.anchor), + "078ea7c31d1eba2b82661ab6f5d6678b5c1e1d402ab1045a515c73ba7bee4535" + ); + assert_eq!( + hex::encode(bundle.actions[0].cmx), + "fd897aeef33102e7142210f8ac1d3ea964a53f935fd6683d04f26ac6e359e80d" + ); + // The action's on-wire nullifier field doubles as the paired output note's + // `rho` input (see IronwoodAction::nullifier docs) — hence it equals the + // reference's output_note.rho, not output_note.nullifier. + assert_eq!( + hex::encode(bundle.actions[0].nullifier), + "e1316540f064433e164b1f85da6bd16317e1462db3a91b866600d62ab9d4c926" + ); + + // Re-encode must be byte-identical to the captured wire bytes. + assert_eq!(encode_v6_transaction(&tx).unwrap(), raw); + } + + #[test] + fn golden_shield_tx_txid_matches() { + let raw = hex::decode(GOLDEN_RAWTX_HEX.trim()).unwrap(); + // compute_v6_txid returns internal byte order; the canonical txid is displayed reversed. + let mut internal = compute_v6_txid_from_bytes(&raw).unwrap(); + internal.reverse(); + assert_eq!(hex::encode(internal), GOLDEN_TXID_DISPLAY.trim()); + } + #[test] fn spend_auth_sig_count_mismatch_errors() { let tx = ZcashV6Transaction { From f3498d22f1d6b8b0ded3cf28932384581f00bddf Mon Sep 17 00:00:00 2001 From: Veetrag Jain Date: Tue, 21 Jul 2026 19:00:34 +0530 Subject: [PATCH 5/8] feat(wasm-utxo): add ZIP-316 unified address parsing (S4) Ironwood reuses the existing Orchard receiver (typecode 0x03), so no new typecode is needed. Implements ZIP-316 UA decoding: Bech32m + F4Jumble inverse + typecode scanning. - blake2b: add variable-length blake2b_var_personal (F4Jumble needs it) - parse_unified_address: ironwood=true -> Orchard/Ironwood receiver (43B); ironwood=false -> transparent receiver as P2PKH/P2SH scriptPubKey - WASM exports zcash_parse_unified_address and zcash_compute_v6_txid; TS wrappers ZcashBitGoPsbt.parseUnifiedAddress / computeV6Txid - F4Jumble validated against the ZIP-316 vector; UA parse validated against official ZIP-316 unified-address test vectors; TS integration tests Co-Authored-By: Claude Opus 4.8 --- .../js/fixedScriptWallet/ZcashBitGoPsbt.ts | 38 +- .../src/wasm/fixed_script_wallet/mod.rs | 19 + packages/wasm-utxo/src/zcash/blake2b.rs | 27 +- packages/wasm-utxo/src/zcash/mod.rs | 1 + .../wasm-utxo/src/zcash/unified_address.rs | 330 ++++++++++++++++++ .../test/fixedScript/zcashIronwood.ts | 57 +++ 6 files changed, 465 insertions(+), 7 deletions(-) create mode 100644 packages/wasm-utxo/src/zcash/unified_address.rs create mode 100644 packages/wasm-utxo/test/fixedScript/zcashIronwood.ts diff --git a/packages/wasm-utxo/js/fixedScriptWallet/ZcashBitGoPsbt.ts b/packages/wasm-utxo/js/fixedScriptWallet/ZcashBitGoPsbt.ts index a43efe751e7..b8d0bd3630b 100644 --- a/packages/wasm-utxo/js/fixedScriptWallet/ZcashBitGoPsbt.ts +++ b/packages/wasm-utxo/js/fixedScriptWallet/ZcashBitGoPsbt.ts @@ -1,4 +1,9 @@ -import { BitGoPsbt as WasmBitGoPsbt, zcash_branch_id_for_height } from "../wasm/wasm_utxo.js"; +import { + BitGoPsbt as WasmBitGoPsbt, + zcash_branch_id_for_height, + zcash_compute_v6_txid, + zcash_parse_unified_address, +} from "../wasm/wasm_utxo.js"; import { type WalletKeysArg, RootWalletKeys } from "./RootWalletKeys.js"; import { BitGoPsbt, type CreateEmptyOptions, type HydrationUnspent } from "./BitGoPsbt.js"; import { ZcashTransaction, type ITransaction } from "../transaction.js"; @@ -280,6 +285,37 @@ export class ZcashBitGoPsbt extends BitGoPsbt { return zcash_branch_id_for_height(network, height); } + /** + * Compute the ZIP-244 txid of a Zcash v6 (Ironwood/NU6.3) transaction. + * + * @param txBytes - Raw v6 transaction bytes + * @returns The 32-byte txid in internal byte order (reverse for display) + * @throws If the bytes are not a valid v6 transaction + */ + static computeV6Txid(txBytes: Uint8Array): Uint8Array { + return zcash_compute_v6_txid(txBytes); + } + + /** + * Parse a ZIP-316 Unified Address and return the requested receiver bytes. + * + * Ironwood reuses the existing Orchard receiver (typecode 0x03). + * + * @param address - The Bech32m unified address string + * @param network - Zcash network name ("zcash", "zcashTest", "zec", "tzec") + * @param ironwood - `true` → Orchard/Ironwood receiver (43 bytes: diversifier + + * pk_d); `false` → transparent receiver as scriptPubKey bytes (P2PKH/P2SH) + * @returns The receiver bytes + * @throws If the address is malformed or lacks a receiver of the requested type + */ + static parseUnifiedAddress( + address: string, + network: ZcashNetworkName, + ironwood: boolean, + ): Uint8Array { + return zcash_parse_unified_address(address, network, ironwood); + } + /** * Extract the final Zcash transaction from a finalized 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 13cb0cbe07e..52c361d4c33 100644 --- a/packages/wasm-utxo/src/wasm/fixed_script_wallet/mod.rs +++ b/packages/wasm-utxo/src/wasm/fixed_script_wallet/mod.rs @@ -2032,3 +2032,22 @@ pub fn zcash_compute_v6_txid(tx_bytes: &[u8]) -> Result, JsValue> { .map(|h| h.to_vec()) .map_err(|e| JsValue::from_str(&e)) } + +/// Parse a ZIP-316 Unified Address and return the requested receiver bytes. +/// +/// `network`: "zcash"/"zec" (mainnet) or "zcashTest"/"tzec" (testnet). +/// +/// * `ironwood = true` → the Orchard receiver's raw 43 bytes (11-byte diversifier + +/// 32-byte pk_d); Ironwood reuses the Orchard receiver. +/// * `ironwood = false` → the transparent receiver as scriptPubKey bytes (P2PKH/P2SH). +/// +/// Throws if the address is malformed or lacks a receiver of the requested type. +#[wasm_bindgen] +pub fn zcash_parse_unified_address( + address: &str, + network: &str, + ironwood: bool, +) -> Result, JsValue> { + crate::zcash::unified_address::parse_unified_address(address, network, ironwood) + .map_err(|e| JsValue::from_str(&e)) +} diff --git a/packages/wasm-utxo/src/zcash/blake2b.rs b/packages/wasm-utxo/src/zcash/blake2b.rs index 614c3d06d31..2a74883979f 100644 --- a/packages/wasm-utxo/src/zcash/blake2b.rs +++ b/packages/wasm-utxo/src/zcash/blake2b.rs @@ -10,24 +10,39 @@ use blake2::digest::core_api::{Buffer, UpdateCore, VariableOutputCore}; use blake2::digest::Output; use blake2::Blake2bVarCore; -/// Compute a BLAKE2b-256 digest of `data` with a 16-byte `personalization` string. +/// Compute a BLAKE2b digest of `data` with a `personalization` string and an +/// explicit output length (1..=64 bytes). /// -/// The personalization must be at most 16 bytes (ZIP personalization strings are -/// exactly 16 bytes, e.g. `b"ZTxIdIronwd_H_v6"`). +/// The output length is part of the BLAKE2b parameter block, so it changes the +/// digest — this is not truncation. Used by F4Jumble (ZIP-316), which needs both +/// 64-byte and shorter outputs. /// /// The `Buffer`/`digest_blocks` path retains the final block until finalization so /// the correct finalization flag is used even when `data.len()` is an exact multiple /// of the 128-byte BLAKE2b block size (the block-aligned bug fixed in the fork). -pub fn blake2b_256_personal(data: &[u8], personalization: &[u8]) -> [u8; 32] { - let mut core = Blake2bVarCore::new_with_params(&[], personalization, 0, 32); +pub fn blake2b_var_personal(data: &[u8], personalization: &[u8], out_len: usize) -> Vec { + debug_assert!( + (1..=64).contains(&out_len), + "BLAKE2b output length out of range" + ); + let mut core = Blake2bVarCore::new_with_params(&[], personalization, 0, out_len); let mut buffer: Buffer = Default::default(); buffer.digest_blocks(data, |blocks| core.update_blocks(blocks)); let mut full_output: Output = Default::default(); VariableOutputCore::finalize_variable_core(&mut core, &mut buffer, &mut full_output); + full_output[..out_len].to_vec() +} + +/// Compute a BLAKE2b-256 digest of `data` with a 16-byte `personalization` string. +/// +/// The personalization must be at most 16 bytes (ZIP personalization strings are +/// exactly 16 bytes, e.g. `b"ZTxIdIronwd_H_v6"`). +pub fn blake2b_256_personal(data: &[u8], personalization: &[u8]) -> [u8; 32] { + let out = blake2b_var_personal(data, personalization, 32); let mut result = [0u8; 32]; - result.copy_from_slice(&full_output[..32]); + result.copy_from_slice(&out); result } diff --git a/packages/wasm-utxo/src/zcash/mod.rs b/packages/wasm-utxo/src/zcash/mod.rs index 2a258ce1536..3bf672cbb0c 100644 --- a/packages/wasm-utxo/src/zcash/mod.rs +++ b/packages/wasm-utxo/src/zcash/mod.rs @@ -11,6 +11,7 @@ pub mod blake2b; pub mod transaction; +pub mod unified_address; pub mod v6; /// Zcash network upgrade identifiers diff --git a/packages/wasm-utxo/src/zcash/unified_address.rs b/packages/wasm-utxo/src/zcash/unified_address.rs new file mode 100644 index 00000000000..650c6700075 --- /dev/null +++ b/packages/wasm-utxo/src/zcash/unified_address.rs @@ -0,0 +1,330 @@ +//! ZIP-316 Unified Address (UA) parsing. +//! +//! A UA is a Bech32m string whose payload is F4Jumble-encoded (ZIP-316 §F4Jumble). +//! The un-jumbled payload is a sequence of receivers — each a +//! `CompactSize(typecode) ‖ CompactSize(length) ‖ data` record — followed by a +//! 16-byte padding equal to the human-readable part (HRP), zero-extended. +//! +//! Ironwood reuses the existing **Orchard** receiver (typecode `0x03`): an Orchard +//! unified receiver routes to the Ironwood pool once NU6.3 rules are active, so no +//! new typecode is required. +//! +//! Parsing here only needs Bech32m + F4Jumble⁻¹ + typecode scanning — no `orchard` +//! crate, no ZK machinery. + +use super::blake2b::blake2b_var_personal; +use bech32::primitives::decode::CheckedHrpstring; +use bech32::Bech32m; +use miniscript::bitcoin::consensus::Decodable; +use miniscript::bitcoin::VarInt; + +/// ZIP-316 receiver typecodes. +const TYPECODE_P2PKH: u64 = 0x00; +const TYPECODE_P2SH: u64 = 0x01; +/// Orchard receiver typecode; Ironwood reuses it. +const TYPECODE_ORCHARD: u64 = 0x03; + +/// Expected raw length of an Orchard/Ironwood receiver (11-byte diversifier + 32-byte pk_d). +const ORCHARD_RECEIVER_LEN: usize = 43; +/// Length of a transparent receiver's 20-byte hash (pubkey hash or script hash). +const TRANSPARENT_HASH_LEN: usize = 20; + +/// Length of the trailing HRP padding, in bytes. +const PADDING_LEN: usize = 16; +/// Minimum valid F4Jumbled message length (ZIP-316). +const F4JUMBLE_MIN_LEN: usize = 48; +/// BLAKE2b output size in bytes. +const OUTBYTES: usize = 64; + +/// F4Jumble H-round personalization: `b"UA_F4Jumble_H" ‖ [i, 0, 0]`. +fn h_pers(i: u8) -> [u8; 16] { + let mut p = [0u8; 16]; + p[..13].copy_from_slice(b"UA_F4Jumble_H"); + p[13] = i; + p +} + +/// F4Jumble G-round personalization: `b"UA_F4Jumble_G" ‖ [i, j_lo, j_hi]`. +fn g_pers(i: u8, j: u16) -> [u8; 16] { + let mut p = [0u8; 16]; + p[..13].copy_from_slice(b"UA_F4Jumble_G"); + p[13] = i; + p[14] = (j & 0xff) as u8; + p[15] = (j >> 8) as u8; + p +} + +fn xor_into(target: &mut [u8], source: &[u8]) { + for (t, s) in target.iter_mut().zip(source.iter()) { + *t ^= s; + } +} + +fn ceildiv(num: usize, den: usize) -> usize { + num.div_ceil(den) +} + +fn h_round(msg: &mut [u8], left_len: usize, i: u8) { + let (left, right) = msg.split_at_mut(left_len); + let hash = blake2b_var_personal(right, &h_pers(i), left.len()); + xor_into(left, &hash); +} + +fn g_round(msg: &mut [u8], left_len: usize, i: u8) { + let (left, right) = msg.split_at_mut(left_len); + for j in 0..ceildiv(right.len(), OUTBYTES) { + let hash = blake2b_var_personal(left, &g_pers(i, j as u16), OUTBYTES); + xor_into(&mut right[j * OUTBYTES..], &hash); + } +} + +/// Invert F4Jumble in place (the 4-round unkeyed Feistel network run backwards). +fn f4jumble_inv(msg: &mut [u8]) -> Result<(), String> { + if msg.len() < F4JUMBLE_MIN_LEN { + return Err(format!( + "unified address payload too short ({} bytes) for F4Jumble", + msg.len() + )); + } + let left_len = core::cmp::min(OUTBYTES, msg.len() / 2); + // Inverse order of apply: h(1), g(1), h(0), g(0). + h_round(msg, left_len, 1); + g_round(msg, left_len, 1); + h_round(msg, left_len, 0); + g_round(msg, left_len, 0); + Ok(()) +} + +/// The expected Bech32m HRP for a Zcash network. +fn hrp_for_network(network: &str) -> Result<&'static str, String> { + match network { + "zcash" | "zec" => Ok("u"), + "zcashTest" | "tzec" => Ok("utest"), + "zcashRegtest" => Ok("uregtest"), + _ => Err(format!( + "unknown Zcash network {:?}: expected \"zec\"/\"zcash\" or \"tzec\"/\"zcashTest\"", + network + )), + } +} + +/// A single parsed unified-address receiver. +struct Receiver { + typecode: u64, + data: Vec, +} + +/// Decode a UA string to its list of receivers (typecode + raw data). +fn decode_receivers(address: &str, expected_hrp: &str) -> Result, String> { + let checked = CheckedHrpstring::new::(address) + .map_err(|e| format!("invalid Bech32m unified address: {}", e))?; + let hrp = checked.hrp(); + if hrp.as_str() != expected_hrp { + return Err(format!( + "unified address HRP {:?} does not match network HRP {:?}", + hrp.as_str(), + expected_hrp + )); + } + + let mut payload: Vec = checked.byte_iter().collect(); + f4jumble_inv(&mut payload)?; + + // Strip and validate the 16-byte HRP padding. + if payload.len() < PADDING_LEN { + return Err("unified address payload shorter than padding".to_string()); + } + let split = payload.len() - PADDING_LEN; + let padding = &payload[split..]; + let mut expected_padding = [0u8; PADDING_LEN]; + expected_padding[..expected_hrp.len()].copy_from_slice(expected_hrp.as_bytes()); + if padding != expected_padding { + return Err("unified address padding does not match HRP".to_string()); + } + payload.truncate(split); + + // Parse the TLV receiver records. + let mut cursor = payload.as_slice(); + let mut receivers = Vec::new(); + let mut last_typecode: Option = None; + while !cursor.is_empty() { + let typecode = VarInt::consensus_decode(&mut cursor) + .map(|v| v.0) + .map_err(|e| format!("failed to read receiver typecode: {}", e))?; + let len = VarInt::consensus_decode(&mut cursor) + .map(|v| v.0) + .map_err(|e| format!("failed to read receiver length: {}", e))?; + let len = usize::try_from(len).map_err(|_| "receiver length overflow".to_string())?; + if cursor.len() < len { + return Err(format!( + "receiver data truncated: need {} bytes, have {}", + len, + cursor.len() + )); + } + // Typecodes must be strictly ascending (ZIP-316 encoding order). + if let Some(prev) = last_typecode { + if typecode <= prev { + return Err("unified address receivers not in typecode order".to_string()); + } + } + last_typecode = Some(typecode); + let (data, rest) = cursor.split_at(len); + receivers.push(Receiver { + typecode, + data: data.to_vec(), + }); + cursor = rest; + } + + Ok(receivers) +} + +/// Build a P2PKH scriptPubKey from a 20-byte pubkey hash. +fn p2pkh_script(hash: &[u8]) -> Vec { + // OP_DUP OP_HASH160 <20> {hash} OP_EQUALVERIFY OP_CHECKSIG + let mut s = Vec::with_capacity(25); + s.extend_from_slice(&[0x76, 0xa9, 0x14]); + s.extend_from_slice(hash); + s.extend_from_slice(&[0x88, 0xac]); + s +} + +/// Build a P2SH scriptPubKey from a 20-byte script hash. +fn p2sh_script(hash: &[u8]) -> Vec { + // OP_HASH160 <20> {hash} OP_EQUAL + let mut s = Vec::with_capacity(23); + s.extend_from_slice(&[0xa9, 0x14]); + s.extend_from_slice(hash); + s.push(0x87); + s +} + +/// Parse a ZIP-316 Unified Address and return the requested receiver bytes. +/// +/// * `ironwood = true` → the Orchard receiver's raw 43 bytes (11-byte diversifier +/// + 32-byte `pk_d`); Ironwood reuses the Orchard receiver. +/// * `ironwood = false` → the transparent receiver as scriptPubKey bytes (P2PKH or +/// P2SH), ready to use directly as a `TxOut.script_pubkey`. +/// +/// Returns an error if the UA lacks a receiver of the requested type. +pub fn parse_unified_address( + address: &str, + network: &str, + ironwood: bool, +) -> Result, String> { + let expected_hrp = hrp_for_network(network)?; + let receivers = decode_receivers(address, expected_hrp)?; + + if ironwood { + let orchard = receivers + .iter() + .find(|r| r.typecode == TYPECODE_ORCHARD) + .ok_or_else(|| "unified address has no Ironwood/Orchard receiver".to_string())?; + if orchard.data.len() != ORCHARD_RECEIVER_LEN { + return Err(format!( + "Orchard receiver must be {} bytes, got {}", + ORCHARD_RECEIVER_LEN, + orchard.data.len() + )); + } + Ok(orchard.data.clone()) + } else { + // Prefer P2PKH, then P2SH. + for r in &receivers { + if r.typecode == TYPECODE_P2PKH { + if r.data.len() != TRANSPARENT_HASH_LEN { + return Err(format!( + "P2PKH receiver must be {} bytes, got {}", + TRANSPARENT_HASH_LEN, + r.data.len() + )); + } + return Ok(p2pkh_script(&r.data)); + } + } + for r in &receivers { + if r.typecode == TYPECODE_P2SH { + if r.data.len() != TRANSPARENT_HASH_LEN { + return Err(format!( + "P2SH receiver must be {} bytes, got {}", + TRANSPARENT_HASH_LEN, + r.data.len() + )); + } + return Ok(p2sh_script(&r.data)); + } + } + Err("unified address has no transparent receiver".to_string()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + /// ZIP-316 F4Jumble test vector 0 (48-byte message). + const F4JUMBLE_NORMAL: [u8; 48] = [ + 0x5d, 0x7a, 0x8f, 0x73, 0x9a, 0x2d, 0x9e, 0x94, 0x5b, 0x0c, 0xe1, 0x52, 0xa8, 0x04, 0x9e, + 0x29, 0x4c, 0x4d, 0x6e, 0x66, 0xb1, 0x64, 0x93, 0x9d, 0xaf, 0xfa, 0x2e, 0xf6, 0xee, 0x69, + 0x21, 0x48, 0x1c, 0xdd, 0x86, 0xb3, 0xcc, 0x43, 0x18, 0xd9, 0x61, 0x4f, 0xc8, 0x20, 0x90, + 0x5d, 0x04, 0x2b, + ]; + const F4JUMBLE_JUMBLED: [u8; 48] = [ + 0x03, 0x04, 0xd0, 0x29, 0x14, 0x1b, 0x99, 0x5d, 0xa5, 0x38, 0x7c, 0x12, 0x59, 0x70, 0x67, + 0x35, 0x04, 0xd6, 0xc7, 0x64, 0xd9, 0x1e, 0xa6, 0xc0, 0x82, 0x12, 0x37, 0x70, 0xc7, 0x13, + 0x9c, 0xcd, 0x88, 0xee, 0x27, 0x36, 0x8c, 0xd0, 0xc0, 0x92, 0x1a, 0x04, 0x44, 0xc8, 0xe5, + 0x85, 0x8d, 0x22, + ]; + + #[test] + fn f4jumble_inverse_matches_vector() { + let mut buf = F4JUMBLE_JUMBLED.to_vec(); + f4jumble_inv(&mut buf).unwrap(); + assert_eq!(buf, F4JUMBLE_NORMAL); + } + + // ZIP-316 unified-address test vector (mainnet, HRP "u") containing a P2PKH + // receiver and an Orchard receiver. + const UA: &str = "u1pg2aaph7jp8rpf6yhsza25722sg5fcn3vaca6ze27hqjw7jvvhhuxkpcg0ge9xh6drsgdkda8qjq5chpehkcpxf87rnjryjqwymdheptpvnljqqrjqzjwkc2ma6hcq666kgwfytxwac8eyex6ndgr6ezte66706e3vaqrd25dzvzkc69kw0jgywtd0cmq52q5lkw6uh7hyvzjse8ksx"; + const UA_P2PKH_HASH: &str = "cad268758c5e71493066446b98e71df9d1d6a5ca"; + const UA_ORCHARD: &str = + "cecbe5e689a453a3fe10ccf7617e6c1fb382819d7fc9200a1f42092ac84a30378f8c1fb90dff71a6d5042d"; + + #[test] + fn parses_orchard_ironwood_receiver() { + let out = parse_unified_address(UA, "zec", true).unwrap(); + assert_eq!(hex::encode(&out), UA_ORCHARD); + assert_eq!(out.len(), ORCHARD_RECEIVER_LEN); + } + + #[test] + fn parses_transparent_receiver_as_p2pkh_script() { + let out = parse_unified_address(UA, "zec", false).unwrap(); + let expected = super::p2pkh_script(&hex::decode(UA_P2PKH_HASH).unwrap()); + assert_eq!(out, expected); + // Sanity: script wraps the 20-byte hash. + assert_eq!(out.len(), 25); + } + + #[test] + fn wrong_network_hrp_is_rejected() { + // Mainnet UA parsed as testnet must fail on the HRP check. + assert!(parse_unified_address(UA, "tzec", true).is_err()); + } + + #[test] + fn unknown_network_is_rejected() { + assert!(parse_unified_address(UA, "bitcoin", true).is_err()); + } + + #[test] + fn corrupted_address_is_rejected() { + let mut chars: Vec = UA.chars().collect(); + // Flip a character in the data part to break the Bech32m checksum / jumble. + let idx = chars.len() - 5; + chars[idx] = if chars[idx] == 'q' { 'p' } else { 'q' }; + let corrupted: String = chars.into_iter().collect(); + assert!(parse_unified_address(&corrupted, "zec", true).is_err()); + } +} diff --git a/packages/wasm-utxo/test/fixedScript/zcashIronwood.ts b/packages/wasm-utxo/test/fixedScript/zcashIronwood.ts new file mode 100644 index 00000000000..1f2531d268c --- /dev/null +++ b/packages/wasm-utxo/test/fixedScript/zcashIronwood.ts @@ -0,0 +1,57 @@ +import * as assert from "assert"; +import * as fs from "fs"; +import * as path from "path"; +import { fileURLToPath } from "url"; +import { ZcashBitGoPsbt } from "../../js/fixedScriptWallet/ZcashBitGoPsbt.js"; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const srcZcash = path.resolve(__dirname, "../../src/zcash"); + +// ZIP-316 unified-address test vector (mainnet), Orchard + P2PKH receivers. +const UA = + "u1pg2aaph7jp8rpf6yhsza25722sg5fcn3vaca6ze27hqjw7jvvhhuxkpcg0ge9xh6drsgdkda8qjq5chpehkcpxf87rnjryjqwymdheptpvnljqqrjqzjwkc2ma6hcq666kgwfytxwac8eyex6ndgr6ezte66706e3vaqrd25dzvzkc69kw0jgywtd0cmq52q5lkw6uh7hyvzjse8ksx"; +const UA_ORCHARD = + "cecbe5e689a453a3fe10ccf7617e6c1fb382819d7fc9200a1f42092ac84a30378f8c1fb90dff71a6d5042d"; +const UA_P2PKH_HASH = "cad268758c5e71493066446b98e71df9d1d6a5ca"; + +function hex(u: Uint8Array): string { + return Buffer.from(u).toString("hex"); +} + +describe("ZcashBitGoPsbt Ironwood (v6) helpers", function () { + describe("parseUnifiedAddress", function () { + it("extracts the Orchard/Ironwood receiver", function () { + const out = ZcashBitGoPsbt.parseUnifiedAddress(UA, "zec", true); + assert.strictEqual(hex(out), UA_ORCHARD); + assert.strictEqual(out.length, 43); + }); + + it("extracts the transparent receiver as a P2PKH scriptPubKey", function () { + const out = ZcashBitGoPsbt.parseUnifiedAddress(UA, "zec", false); + assert.strictEqual(hex(out), `76a914${UA_P2PKH_HASH}88ac`); + }); + + it("rejects an address on the wrong network", function () { + assert.throws(() => ZcashBitGoPsbt.parseUnifiedAddress(UA, "tzec", true)); + }); + }); + + describe("computeV6Txid", function () { + it("matches the golden shielding transaction txid", function () { + const rawHex = fs + .readFileSync(path.join(srcZcash, "testdata_v6_shield_rawtx.hex"), "utf8") + .trim(); + const expectedTxid = fs + .readFileSync(path.join(srcZcash, "testdata_v6_shield_txid.hex"), "utf8") + .trim(); + const internal = ZcashBitGoPsbt.computeV6Txid(Buffer.from(rawHex, "hex")); + // Internal byte order; reverse for the canonical display txid. + const display = Buffer.from(internal).reverse(); + assert.strictEqual(display.toString("hex"), expectedTxid); + }); + + it("throws on non-v6 bytes", function () { + assert.throws(() => ZcashBitGoPsbt.computeV6Txid(Buffer.from("00010203", "hex"))); + }); + }); +}); From 716adb621ed98447965bfd1031f230b13bd58168 Mon Sep 17 00:00:00 2001 From: Veetrag Jain Date: Tue, 21 Jul 2026 19:10:24 +0530 Subject: [PATCH 6/8] test(wasm-utxo): add v6 golden vectors for self-send and shielding Two more real testnet transactions captured from the Ironwood reference: - Ironwood->Ironwood self-send (aadfe41f...): no transparent I/O, exercises the empty transparent-digest branch of the txid tree - Transparent->Ironwood 1 ZEC shield (667e8ed3...) Both parse, re-encode byte-identically, and reproduce their canonical txid. Co-Authored-By: Claude Opus 4.8 --- .../src/zcash/testdata_v6_selfsend_rawtx.hex | 1 + .../src/zcash/testdata_v6_selfsend_txid.hex | 1 + .../zcash/testdata_v6_shield1zec_rawtx.hex | 1 + .../src/zcash/testdata_v6_shield1zec_txid.hex | 1 + packages/wasm-utxo/src/zcash/v6.rs | 38 +++++++++++++++++++ 5 files changed, 42 insertions(+) create mode 100644 packages/wasm-utxo/src/zcash/testdata_v6_selfsend_rawtx.hex create mode 100644 packages/wasm-utxo/src/zcash/testdata_v6_selfsend_txid.hex create mode 100644 packages/wasm-utxo/src/zcash/testdata_v6_shield1zec_rawtx.hex create mode 100644 packages/wasm-utxo/src/zcash/testdata_v6_shield1zec_txid.hex diff --git a/packages/wasm-utxo/src/zcash/testdata_v6_selfsend_rawtx.hex b/packages/wasm-utxo/src/zcash/testdata_v6_selfsend_rawtx.hex new file mode 100644 index 00000000000..ed8193398cb --- /dev/null +++ b/packages/wasm-utxo/src/zcash/testdata_v6_selfsend_rawtx.hex @@ -0,0 +1 @@ +0600008098b684d85b16a5370000000000000000000000000001b6153fbdc4f13e167349f2e203d4e86ad2a43f3aa87bebe4ed54c0565a7458b24b627b0a2cc6605c343f29440709bef5ea7ca1f783095d78a9ad4e56bbef981aa355bc60f9352fbf3687338c4c4dc0b51ad6c280bcd66646e68b4f50e3f140a2abcc7e818c61ee9a2dca2478517f927ceb8c9a025a6df1005e970c9cfd3dd210fe297af013bb54ac18c6a20d19a82b01c84b1b01ac04cb83a5a5c16050a6758e882c09fbe7a7ec5f5f6cc9f806b9edaf92b22edbf3b0b335e8b469f4167a6686b39ad15791e0c3a21037d1948b9b48981020fe9443cd36bd69dd8fafe1d0f1265cb6f5ec4a7fa35ea11692028876b240dae263b7846002fa08409602d51be18957552c54b52d03335dbb23b1be21c4bb929b2528e49966d2f19e4889e81c3758bfa6f1df1d054ccdfcfb69d2648b67281c4e96c97b10e0b91f11ee43a0420a477453e8139d9b78cb55679e857f77d8514fa715b12ff22451fd70260f0a2f2ebd4000065f56c3eefecdd413bab0c62bb899e8d1ff8020ceb1e669037e9a5d87549b0c138329501796b1f42466b23af76f1da7068f4581eeaaa246aee0c593a30649efb5ca3b6f7eed1a6890909b786eb1d0cad911feabf1b91503a4adc1ac3c8e4923706e8a8588b87f3d91ce89e2d29408a1ce4c12342ae95f7beac4ca858b9f713153c079dcdfc5bc72c1f257873a3ccce317b8589a545ed681fa3a54affadc0d807f17abf6e8a40dd534393a76b795e522cc5cde43cec7508f59fcb58be4fac3edf3023f2edb475dff3fa64fbdc5b79fd048bed18a9bf8303f31d5908e9ad86eabb0431ef0204ff9c370f526ef009e9e2daf0046e6382d60a17bc0808a2a36668120f2105f19704e79622b2faca5f9397d3503c08cc93f85b35375f8ac3bd1ac3ec09056d6a00621e7e5b11c937b1971b526fe88570e49738ae9ebbc8e8baf1746769f1c2be07aa2f0217c309256c21af4913a885a3a373b6ad151ad6510e89edbfee48bc214126ab498beda293700b1c4e425ece1a5044c0d748209eb8fe1a5005df95d1ba0efd7892d881f3fa601b445c3b5601e0ba80248c74923d30905092a0938f41d5ddc2856343776288ecd8128d2cafa8290e1c222dfde27bff346b89da4cd4599bd95f3d85ed987dd5ff7c2af517b07204e000000000000ae2d7544031d375a34518b78ef321fa171a02458e9419a4088a1ea917fb4033efd8013389f64ace0948ebb1e77eed919024f9a6c33066614f3431f6aee6a7df752e493e8819e2a3ba582f5be1bd9bafa85387abfdb4179efc6d2ba80c0638713c982ad6472b15f414a7446843374bca80a779bc7a2902a723598fe211ae8bcc6d7710e0d2a96ac61bed6ec4f550e651e9e4d6726f55779d5d870ba548ee5312d60f92aa1760b2657e82a7160d1e2fb5bd3dc50ffdc713562c3613513c65a81fa876b00102524ad069b7ffaed4b882808a9e098cfe72c59a08fcb54934c11597b67e3070f63389449cf296c5c61c4191c35a6235a49c90ffc8204b2bff49ad4725cea886478ed9d5d0084f510ecfe6bd37cbc7706c3598d0a32c563a4b7af179b82fba63daad9698f5075f8fa1fc0c00be0e25b5ce882368f87498a40799dd06c6a610d82315e556b359b13e6472f5f33da13d6e426fd995c9751ad076efaa9cfc61637892390f110052f6244c6c2051cfc8055ba20bdacebe51485e3d403f7115c4aafc5807c3cd43c7973f239b5cb92c7a2b35e4e6b59842adfa3dcfc88fee607e719d0e004d328107b8a8f03410973b320ce02d17a1f18c31382fa6327042c68d00dcbfc4370d13ffcb48f72e888d0e9cec77e044dd9f6490d09123b0d1900fcd80671089c7a23f1de6db621d063b9a99bad807ca7975b4026df82d9f7b657e1a696cbd9753593121e76b0989f45c165e0aa5e339ce496f97b345d35099d105af7ac2052e01bf9dcbc0bf724edd710ca526e4336618db1074a2ce0204055d6b29f0afc23ea58eb3db63c6f5535afdf53d40e4f67c65bdd99a4811fe040ca8b132f2def0acc4cd7935237ccd7d36be3f443f397a33bf6ef45234be606eff806e94f1de333e53a2a7cd857fbaaac2f6b9f9608eedcf2c8631fcb889acd233d1ac4de3f75ed2e17871d7fdd300c33d841e24105cd753c9de22b513b75a111015787cba8f2d397e8537a51e998c5b7e6b645d32933c04d916ca200387e4cc841185a6e95acdf27efb4844ca1b0bd8e1a866f9066abb96af779da022e30b374379f8ca5a7174fa0a3ed1b2e26b13158619d3d2b16aaca26ccdff9f630b23b1a9406c2ea172e398cc2ca25ea3b04ff8a7df5a927f22453bba3863706680e80d3cbf1b14d8d39654ad88a20cee3babe76a520709f5ec2cebf7f2ab0535669db7bb026367294d5608fe992fe3e314f7a7be52fbd777518c250ff1fb70a17631dc028b0163e0edfcf67d557d17bb5f9b7d839c0c8f09576fe1c7dc750c0e40e39803b6e94d18df4e897d8ecacbd90d4885c56a9bad4a8f94aa5c55b6c45152f0b6742fcf4758b4723f7bad661f83f1c5c716958ce0322c4bc898a30cb6f7fcf1ba2bd3c98c200af9e63e810a4ba906c5af0ebf236343b778078ecff6a390b73d2de14c8f5b9ad0c6b5d49b14f57afaa0ca1cc34c3a65bf5fc287901aa3e565922199e5201ee18cea6c22fcf0338b4a653d3bbe6b4d17c26749b3e16ec366df8b578c3a02d5d26aa95aeb71888490b8197d2cc0af88107be4eee094d0bf5c077d8743c7b7b613fc020e60bcd5e0df1d33ace88a9881c60afa4a4015eb6eecef959bfb01f38ba17cd8bdf04a0e4f76ed9637949f9e871a0f0936299535f8550ac2265522407b52669c8370abec0b8aeb70d3a48e39ba4962039631c48828175ff7ac83eb8dced270ec4ca453650f243d9d1730b6be9c4293f69368c1cc45669b981beb17ed4473fa5900737c8445a6d40dc76a5249a44012d7d52879ad9f70eabf3bb3b553b5f0b4ae5b24be9428379037c41931fb6395c94e98a39efc9a941d254b23f0d6d603296b638bea56156f70df1f63dba5aa26200a2d60c669ca0c7ed8dd60a3761f51fd7bba733c495420f2bd23a6117ebec7d474fa89ef5311767c971baf3f9fc53192baad971580f6027b04ec027b128ab70891d70bc3b500131733990a3f9e0d4282fe3626e33e6ced2f6f339690581dac7736e712b855b13e3028c4af7d2faf92c2449c503fb4723f41913e3feb69e5ded94d03a7457ac7ae1068894d653c1ed3105f6f0421b25a746168612c6cd3483372ae8654c503a1421797c6bb0962a3d29e868b2bcf5a6fbc9fb22102a979b2991e85430bdbd2465209e29e17c16ec9905224025bdaefd86ca42ed10ec9e2f86efa007f8b27f3ca930022e24aa2c666e07960c6eb97039d79f9b1400e8fc8d3c18e179a60af5801e93a1238137b586951e11c8bfa03f3f990018eb219dd2e6718ae582a5611033e53a60fe0b9df15e7514aeabcc15ef04433856ca74975b6e7f28d7c7f58bd4feeddd0f2e7d8d1cbd62294dbed44fc9c0f9ca5cb982eef34507d9511bc8178fc327ebdd8c2c1dffb0503d22c9f68a1d25d1d3bbbf57147cfe1a970ee1bfb0651e958ec18681936220a0068f7760529ad0625e518c6bc150f5bf7665ad6021d5c6073ab3c5133ac222001952bb3a4c84ac24cc2e1539d6f4398189a36cb8a559a1c59da948374293c8d43e512bae9bfd4b46f3e279424265596f4cd93886e1dcd098cb13b4ec5532772020598b525cb8647f8dbe63bbfd5770e71a4aa609e0ccd92a12d0fb8f7a6980603809cc22973210d77dc053b47c3badddef0b34b60b4ba902d22e76ff0380243f1544ff2408b603c8aae5e40540f6b9baabd93bf6d335da5ecaae4980a8439d6706741a311656c35c20e54079018709921d0c0d8c8aaae790d4a8588a7319b3872361e1c8e0f3e38a320dcf81ee8a9293e15a69d2ad4eaad84a449c7ad67f1028151794d475443e53258fea791958c815a0918f17a1e0be8cedd1edc201e890c3253745dcf750e2746bfa4c2922c4b75bfe044b6100e27378855e2c7dc28fab58196469e146ce51d8700fd821115e3860e71542fe732a4d611206258bd30d17dc3d8a6149ac3ea9624edfde62df966c1cf4ae61215fc9ed850e502592564bd09b00907253ca6f36c5da193a8366cbfbec5f91c48aee8c06d46fa97930c2222a842d382311bcc008efe16dce530afd4e8fafc766d81afffda1169535a3532709eb1075b5a6839c279f0dcbd649e3026456b9ac45bc9ad7e8a40b08dfc815b3fd793fddf5243682f221b3aaa02e24f53acd613d085d214909211b63c621dfe02a853fcc7fcd461af2a094e199d1ae450f75f5c87cfdc161cb628fe005ce8474802e183e324fa2542cb4d764a5d9de5bf0e838a54cd0b7cf9f7fe031c17cf338d6a33fefd1fa2061741341e04dad13e15f932fbb1a7057224ab78c7fd60c729eb02f39309c48f3da7ce1238bf973243a688b0faa7e3357427fe9360ff5f688cf315805ef2f5acbaa3d608626cfdf2d226d6505915e6f0f5ade151e6fd4319ef682362f95b1d1c189e936c8794eac9b8c648e883b0a7755b1c9a54e2fc130aaa925b9330a2b4e3ef1e6515b4863a264d852d16afeebf1999cac566820599e6a7062d605860b859abec8d9ff1b8ff960e59cc9ddb97a6db74e431d6d0c4a1f80490d593f050540c147974b453e3380c28ac75b6438078c5caee4594a61b9fef22c471d3027f592986e9de0aaea90ef3d59b8fdfcf99679e34e7e4d1d9f9d30c5a69e081740a67570938fe7fa1ba2c14c9d30fec7f4ef652e71950233855315124689723f1c51956ee8ccf8286563660c92af24ca3628a84b70d5eb26f2595498c5073829d46bb0c509ea8741856a5d6876684b5622ea4af07128adbc2090afe8adec3a260e13ed8d584adccc297e394cfc9d535b19e3c9acd0c34657f33c72a8019c542e720093f4c1a845d8da7c58de73b5f38dc2a053ba570cf75486b387d4e597b4098998c555c326887f79d7fe7d1853ae0c63f21b61aa0d2ecf5965f07ae1878425d7d40678e8f09eb52dd29938b5df1fcca7918d5f233bd93e3fd7e641f1b0c62b698854611af28fed9a6669cb95c9a68346e9a72ecb607344c6b3399d0a199509aa580017bc1ee4223c2c52d58c03f74dbc47755dba1af088f5843ef17782910302b265e2658816979614e710a2462597750db54998979f4de7b8ff878978542b4c8563ced4b6bfa32b48507eb3d962c2163057150c2b898e3f51a094837af03f3ca3043ce201093a3f1add484228618912c673410d2477f7195056e45652200f453687f6ffb6ec1604b3a56973d396f98d702bff351f7d398d59266df7c35936683355fab11beb524593aee15a83672c2033c3912c949855f064e1ea05092304a85c7b48fd4a10666853c4a09cc040610881ac4b42c5674357ace08801040e22faa3ae458cec4e753b5cd6d5a7aa8c9ffd19b90e44c476a294fc78fecf941931779e3126c354a4f5e866138380373161ed1ef050068cd4b7ac712fbf9784173b958c0407bb697ab8b7dd9f145c45e26db394bf513ed55698f8d5b8f2c6faf60572f3f3d28b31384d53b739f1c50fa38440e09b5e5bba7fd7b7a4fb99ee3c5401796371ccdca22d119ae94098fd26c536cb9b11388a980a75ac49d9d6f5d433169bb445d6079f2ad3c27b3c0b7947b631c170a56d3c4cdefeefb003e0b05a640ce22f21bbb4946264c98120551bc3c94fd8d0484f543e41313d820c0e01e7a11857a2544ad9e953415394779a61d4ce949848867e649671079d397cba0effc303cc09285a249abbd79786740d3996ca479bf502d11d1c9acd94289737e00e89075b5e7f0486d889702e5b7c586d7a18cfa910c566d9bef3cbeb709a9ed56f3336fe3c629d28d81549ee8193dd95263d4fe54cd23ac784d5164530f5ae9c61380c8b30792bdd16a6b30a0a92d3d1d472c37881d792496d78be169ee3e6d9676b31d3244ac7a36e85be9b360cda88bb0a1562d492e9f54c59d393d60d3a847f9e1a2c16ac1c48bb47ebe27e4751795aec7b548bbac56a79161db41ae6b9c855a22e318c0b4b6ad92cdd0c0105b29c979f0658399aba29558d1decd9750c49aa3d2f4cfec83137ebeffc93373d92c3b43f54a4e0a1fa5ba2d29a47fd70b84e9b69001e573a65e0ac7f65122e77da3841b09a56a28b1505ea3995120b32d5ccf1bb2e8c5bd13c388ef1623098ac9f2d906f8f72a46aa88fd68836d5a5916842a66d02628931a35df29f41073cf2d21b3a6344a98e834efcdf2ffb572d391738576a1eac9b03ff92d1a4bc60e26d2fd4556239b3dfcecb5bcc43b431ab2ed26564bc1863a06adbbbb1cfb301cea0aad1b9f49b30a2ff20a8fd58b0f3b53c21bbdec824207f271960b79cc4c4fca12a11612fc7f007e5b6a4584a3b4a2ff3666d9ce9153c24c2bb5ce58050aeca661b19b1a9fb36558026afce32aba0158e0fd6665f2f60d04017dd5d59f93caa3a3bd2b9ccec23a09b0333804073f4506a0e714cd51fdfad241dc9c866130d9c20978beadff640b993f106c182fd6ad67fa0725bce0228124ba9b685e1cc7fbf294cd148ff047d48bb8647cdec0afe6bfa7f66129f3b523194e63031e294a0bca2ca853affbb8f267618c64d2baf41c265e2e5b408015407761f5269b4d864aaffaef39c773f5b65298459f22721c5b540bf6255441db55a8420d17dcf0530188ae1d46a238e63957906e33d5c7c8f960f6e6badb814f3ce7d3ab422f06c91d8362ed325853740a73bf35e2fc70349e90efe0331500509035a9841fdf6b077f4664d87cbdd01175a9cce4b58f3655810736d1ce2b726c57f7b189302401af49a66710f2cbb03c30dbf893e8b3327f73cddd5d4629db416e04dcf7520318c85ef70d033f6c44fa959a5cd629865a38d2d526c63a75825220b0e8d6be340f93a629a6849cf28eb9a2370c74df422a08c1f181ce09825224bff550c7beabf084b63967e188b7b7938e4e8194f76aedb3352b371b6423819915bea9e0752d2bc6d34079f8b644f9379050b2ddeec2bf5e4d91adc8c856b30bc91366d26d9086fc2225079aab36a887de5ed7f98b2e83585dd90ba73feb816ecee9d1740a1350d26e37108c79764116d31cb4da0f15ce04634063ae2a26f065429dc1431e6961b7e498391230a925261fb804fed305e62c97d33718690523d573d592dbd2b5d68df193aaf44b26aaedaa699c825e760062a71f00900b701953e50a314b8aca0928f09edbcaffd358368e6360336ae01de5dafaedd75ac4182fc958349a314dc5258255a5b2019873cd7b9ef3557f96141ea5b765ba67aff89328659e69209d8c11d00473c56a566284eacc5570c9c4edb5645dfae1fad3896662e8f7b3d1aad8ab943cea12610fa7d2a49879a8342649d033b03740b5bceb12ed719f9adc77f2a1c8ce6094aabc96463c9a1887901b98a0bb0d712825c5aa16264be12e6273732b18a0351a7e488fa1d9f5b519d05fc9472fa8071b1837c97b898e552ba0be13d6cdbb2f1d1e07733999dd8195bb11bd1dda2e149f9c9052a1ed9ffb7e0e5e7f6922fae449d9101d5ed3164055433704f196b076140a2298b71bf8e26d5c4332f97a0410b1573d6ac74672e388640ca9723f6435da3d907b996dcbc555cc49a06c047646410a7f6ce4e075ddc78a13e3e88533d0497fee9b0c6de3e2fdb9f341c5caa3b660ac7d23a13b23bccb85d2ac1b75e1c13bb2a0e1ec8cb63ec07a407e2dacfb3add2579ff1d8696a1d28b3739e16ab0721bfd3c63a3bfb6c6f57191c1bda0d3b30f604a1d371c9adac86a8cb0dfdc37b9939464683c8e0b44dc0632cd363839de9f99716ba7147e890b52816c31fffd697e1d84685391999ed5ee9d2836df3fc67a2d0af3ec7587797ce1508d9fba1e81b76783a1f927a6b16fb8d33b8a807c26c64cb856dcbeadde6b8c08a049b2f0a59ff850ba30ac9bc7c7fe642c7a3931e1d18b12d6246ececf518e2dd44d793e7a67c0c4d0b8c070be64a2269ad967fab9d4b084ddab2e9e76678febbd5b08a53bb1e5b9c02ce9f8e9f09c86a50018f0502fd37eb0d8aff73d00019f79f39515f1d94155c1d4bda0f00bebf2d6d24304d78f95c72ee87a785991fb7ec4bfef83f2ae1f95133c6e478e451bac3080b28e0465e45ad9c95d910bc12e9401eb15363e215dfea33372dfd043124ca119b1b2de1aa30291e944df138f7069243fc1183faed347105d31f2a4c3ed04d1e14936c81af1ed239725975b05ad65e0fb5e3bde096c4e88832bf27f692d59e1b6384772d71f3df60413526996363444a0b0039e67f63e724c08590bf7cc4b653e4d6c3996fcbf9dd7bfda987c951c64286791993b1808024e1204331ab2e5f7ad465d61c3b1b2098eaccc7c1ef8849b5a89f57afc4b16412 \ No newline at end of file diff --git a/packages/wasm-utxo/src/zcash/testdata_v6_selfsend_txid.hex b/packages/wasm-utxo/src/zcash/testdata_v6_selfsend_txid.hex new file mode 100644 index 00000000000..363b525b5c5 --- /dev/null +++ b/packages/wasm-utxo/src/zcash/testdata_v6_selfsend_txid.hex @@ -0,0 +1 @@ +aadfe41ff0881b40d2b4f47e3b0eccd1b451d2121fa0676387e736211f3c59ed \ No newline at end of file diff --git a/packages/wasm-utxo/src/zcash/testdata_v6_shield1zec_rawtx.hex b/packages/wasm-utxo/src/zcash/testdata_v6_shield1zec_rawtx.hex new file mode 100644 index 00000000000..f0326ef0338 --- /dev/null +++ b/packages/wasm-utxo/src/zcash/testdata_v6_shield1zec_rawtx.hex @@ -0,0 +1 @@ +0600008098b684d85b16a537000000000000000001fa0d1502e3a87e4fe4af0136464466385bcb174717d8dee4e151ab28a9868805000000006b483045022100a64d4a5ca450f8de4e736dab50fb50117eced130fb51418252b955771cb482e10220153eb84424eba7f972dfc44e035c4c8f2a382dd0a96045c4b651faa8e01484d801210349f86487c6bfe475672cae5bf468ab6985de32b1b2aa8729aa7758bf57c6f747ffffffff0150ecc00c000000001976a9147c6b843a25873c036aff575516e3802bcc47f63488ac0000000117e880863fc8fc4db329c4d8f2f6f002cf1c54b0db7762181cee79cff7fdea1f46ac325a03acfb50a9b4159a441d247b978f67da81a20d9fe94793433b3e133d567ca296f47d1915f09a79c8a7790216fe9175b469debcb78a8cff40a73e811af01ae6c6c6accd24d0676c749525b8aaacb0a7e98e9258839145a7c6e7d900022b27e8b7f35fc71e7eeed8b9096aeb0c206655e0fef9e3a471bb369bc8b38f870e478e55c9dce3b3682f432f296de72f043320eecac4b557151e8567556e61dbd2e496761a8b25c691a531701d971db41dbe157d453fcb84d1e80ea5e381b5a89bcccbd4dcfe82a32a5a0151f10d9ffe2a67495a087974f9df7d3704a4889a1ce14aa9d612bdd9f3a5f239adea9ec00b919dfec497315e2e86dfb84a8b21b4d1e9d07bb82532edd3a80eddf274dcbb972facc9e6a43b4b52f3248eb44f42fa83dac1037f0e912e38e9c13865f2edaea8bad0159820eb00876315f79bc7c462f5b40285a72dc585ff4526e7cf6a6611706d53e57e74b40ff895ec2b4db5994e8e376f5d4733e89d65bf197315dece2c3d18a6e69614a42adb1bd3ee2badf0dc8df054a4ece25a1ce19e3748d4a5a49b4d7ebf6cf8256ec6592e0480588d3a9c7dfe432217c3d675f123954b74d39fe741fbd6a44103c30b7b8f3c867ed06d10fd232326f93d0c2a071844fc9176a7fb131f29b3f17ddcb4ec2f739004273a57c2ed6f89367753040d1646b07dd181ec0f37686a6e1b345cffa4e649ec6ec0fa880396bf4a62445c1ac82653ccc0e77dd96ea8350a473832f7a62589425019ed009b290891fdf75162153b908228fde6c555155308ddc819459db0bb714da356eefefbd9a5ccb0088465c072320e18a22f084a3445147fc69bc7d75535429a20f6dc5b762ca33ec336262e8cbecd88dbe372783fc2993161615504f065c0716c38ac2f0f6770dd0ae8e24a41fb0bb8001c2c9b1467dcf3ba4b5cbad2ae68664ffc661d9382a09573d086af8824f2c6f65ee450d150b43c795c7ef9ed78d5e8b2fd23d4b653a5b92c69ccdc133563126522247ed095ded03bf0326521a32428f0aec818ed3b9131090286d26f585e81088c76f2c404f239322ce36e7fbbe960973046fd8c31721209ab479c76dad4323df5b1b1aeff07001f0afaffffffff56165f00523efc8379b8495dd92029cee3fcf32547135cd2653e4fd1abfbe415fd80137fc01c5977ac03e19b76b36608a5c5583cdf57251b150ea4a3f3dbaca0c27f8d428c48e6ba8a98a92d489325db409d22fdd6be00da6f69bd8c5ce23ee2c2efb1deb54e08a1d5bcbbdc96817db5e3b19bd789eb74cc9da2798ec8c1756187391cf775605fe013251d0a720a31ad884ffb54b82b7cf57f5c51bfbef1c803c0ec88e9d8f6d0ff0a226ded5b37c4c54903d6254eda3c3313042312330df5f514d30a1dbcece5598e0193037580fddf85376b45fbff626974ce7e9eca0ee86223483f7582b20f6daa98762b82590c5fbe59f9f64f7c9357d638cd60b717e128e5b43e79a43492b259b023f6e1422a14ba6351eb2abd2c05257d9d2db6c9c7d31010b74d009804548cbd6e092793f3256cd3ac96e23253e7ad6c84d2917d5b180b72a134f94935d0112a3a1e61ed03e167b35b192eb3734e44eb97c749f746c0bb4c075ea93512c23e8739b3a79d3afa4ef9ebef35b83bb41127293dd8ccd4c3d5c92ee0f8e11a86a2c271d67236b98fb77ed7020d106e391c36b0f453dc4e3d924dbcd5e32aacd8aec03bfdbf4247cd55989c5cad49535aa78cc3627da50bbef6292c4d2b662132efc6d8e5ff3c4772aa4a5b68c7f3376f0becbccc69eb3b2dfaca1a0985635a8a647aaf7803810e4ec630a531e3adaf0654f578e60148de98efa608acb90dea907bc6a5b0dafbf6cf1df533c775a4a730862ce7e8cb8caaba5597a46c1efbb592eadd3248ce45f16bd0a7311f72f6a1dca14afbe45850149ca4cf0e878e69c607175db78f88de60915e6b15519f21015d9bdc6fe4ba89ba1f1d4b0233577a6232b628fde2fe06a30c52bc3cf42a9d6a8c23eb4e989ff3a1d4b35e9ff79a0987620f5792e1b3c0e437d8a1705b6937710694f3a82ff3148a000e5431e02d8d8b160fa6089ce0648056416e14c962c38ff4296d32322d0f74674d60aea682dfce56aca13582ef8b819f48beb222418b80dd11ef3d4904322741dc0136ca4f03a487f1ab76d096d7ec284b128f86a7bf3fdf4e32c3969bc093ed7c7f045dc4b9a6a753e63f1014d1c43a2db6e187e1e28e1149c4b87cd481926bf69f845e30ad2e5093aa246c8c5af0e42f998a5d925271006780b78e72a810fe98c60c4b76636802434a21bbb428971d8b4fc5cdb6da0f58a21c6945b1f7fcc0473abe06b76a9f74d26546262fffdfbc696236656ead3188cd91aadafbdd8819dd4f373b8cdce395fff63d923d6f03e4c6426eeeb394ef07a208d69f73c12688ae0597cf4181435923d87f7cad0a416c61ffdece448e469418d1afa3c41029ec739fbdfaaf2e78c04b573ecb75fc1c628e906c13cf8eeddce09fb05b86722f4c90afb6f87651cd57da5243d3228212c71aca733e63ea2472d6996e37c88d6bf5570799971f5220092c15d52a6946f847cf1793521256176594798a841a1eb7eb26642b146c74f4837db9227ec2a149d1bdd00480b41a4834b45a4cc7ba604e43ff981ff82a3fd01b63d0cb950bc727207d039adf694dba57ce916143479f8aa411ba1b22cb1b5f0c5be578871d7d0352cc0c9536dc587619f0e3fff06b59560757650740f58ef5b99feb88daa9ea65cd33af9877aafede2d9235701daaf52859a5740f915dda06b490a5fcae40c9a989290981ecf24e46f48327168a4366f1bccd3416a83cb635198a6050af0ed39c2ca1276cbe2be8dc5849e2112906cb28c23e4d0badbaff874083ddf9fd972ef9e06bc70f1d2df58d3253919c051428a742e39725ba13583e741ce5976529edefb3c771478a1a59ab67112e027b35893cc7449f083b48058c2903652c40967671140a8714fffff0985c34ea1a501be4cd9f733d1c9342d29a3b9fb6ad99f687eaa314a570b1229dcdf590a35178939be2033d0d0efba3a98609c439b443a5ab5ff6d1be517dff3facacc89ecc00f05ba7bdbcfb283d2dff2ccf5f264c9e6b1effa56d48003bffcff2155a8d65a577ec752c91ca25db6c0f228e38534723b84b2313529ddc770c43509d05cc98b279ac363663ff28efb05cedcec61f4adf47331cd1e6c5b34a94d7f6e0a3f86274cc6b75fb883c0b3ce451745b021a7eac5389b6fca11ca2caffd0c07e9e30c6c9c9264789b7c334b3156c4020066f7c18d7256786206bbadbdd9473715ecfd09287d5afeabb5d0072a9b102897d7d16b67a0d07caab716d5ce7f2bbe0f5b13285e746e74e1ab228560b2d506fdbe11ff4d16467b066e7cc6ae489806165f0bcc7d16e5d9e27ed14036cbd63daa45902bd8744fd8f7de8435944c8bee1018caa3bd57ac18a98d416a427755e99cb7799379253bcdc0c62f384a0cbf52b0eb7744cee3faedcda941291117cd000bb10d6b31ef50995c4558ef16efe16062954bcf5613cf9039ca10e44b5fee5344104eb821183e7912e165dbb987cc32a16a9278a50b604f31893252bc6bf9c377e69cd228e62d2413ef17f0027b425f388cb5a6c67095557ae8d37761cee076426421cf0bca59a6aca42565eaa8eebd230167ccdf63af3dd43dd0d8810c7d78987cd0506b7c090f662a2694518aa81dbb5b78d9484641a9556243169bbe6ebfb550a6ce218f2565e8b093d7c42582d517606297f37dabbc0b64d0b6638fca9a74dd9b33c92407bc2155d545cbac9ec024b50e5b7a21a161d47103d26d7051ce610e59c1b2cb8629d91ed2fe47011f7df24a0bd66462130b135b5240f8c55bbaba1d0f8b241b59531bbd959495a1bd478df75e1436f262e4de2ef302329a0ccadd86fca9df16d8d7fb52cdbc261c4194b59f5d83e11e47312596a30bf3cec567f7341897a8c324cf4ba43422980478fba925a5ce9d916c58a352437b706368a873eb1df66764b0a8c951d6f6a92aa047bb738a8c2f958f913590838ce7f66c529945151d743c68cbfb66f041555f30a625787c7a784b78049a7683b9bc42b0c34cd4cb7d9cbb2a5931ee901319b07766df361c647d857d5bd612e20bb70f2f4189b805ae72ffbe1aa4d3b991d619197ffb40ce2fd680ce2e62aa60b7a64024a3d451339c0fd7c2c0922ea5fd8c5eb6b1d63c6b93e52fdf5f10b4902c7c69c1655a0b435509cdeb35a7d1411e83ecd720fad98e74509e31e02f35413cd7dab48c93465b6fc920cf394f5503448846a82d7ffab054026d7489047433b72f319911cfb3b821ec4d81fb037980f26455d8482df03d2baaab5fdbd489e2b17010f14657ef6d674ac3af0fc64693bcf129b5ce03c21c9f94a71a392766420857121fecdf0bcc4bebace9b8346c4380c3e054b42f67e46f45ecf22ee8e5c1524eae6bd8767efe7b3bccc063de6d9980534de9962b8169321548f0815b81000fffc67fb2308108856f8aa27d1b478abd759120ac1c7155a974dbf3a38d7cd1b61a8b16265da5402046e41c5fed9f95ec11542276a162e2def75613cc2f6e131b4a5178182c5a3bc3705b7e2b3e7c487a2741694b054020231f43e7afeae95300f74098ebf399f4f2f43eeec804009622fc37d14d186303511320edfc2ccfc14e06d3bd4734b2321d9e74143e503ccf20afd2e55060bdc0d9b9258a6955fbe3e16bb58a93dd71e24ae2cd8fd26c9f6f52d82d60ab17c994ad26e725f9d39de08936d28c69df1cbcc011e4548997e83f1c37f8c1bedaa8408927222b936a59e0dcfcedd297432ed93e213d94c6e013131f4847cd2f5b1b500fce29d85fd82d23e2f0a7cee8d0349bafa2774b306bfc6dd3633934a37eeac292912264d75d4e3175406b6d1a0dbaf4d20d55dd86f22029a4cf58febea88422883d29d500a5c59255a928a54ff7dab50fbc6375e8fb93798c83d5d3221b6e348d5bafb584c329a13e401a466a8ee6d0ecd04796ca821bd38959f96d95ca3ae236c72d7d1fb535b260457666248da076b3e682e29811bb6129976240ead2de9d884fac9acd7ca072903d0259745dffd58b3578f19851f1190566edb8a903242ddfb8ae8fa865a300e4b5510fc00b80e850f20db615579ce137d1d35af12d574071077e2a9f5a4252f2f9fb912115453cbbf77aca6b3ac83a2ca464173e69bd98c5d22a2668eba260e8625dad8ce3d2833228c4bc7f86c3bd38a26190e1fb98f9b587af8640829b630590d9f7fcf1f28ba542b4b2c9100ae19c2e9afea1920aa076f3cb0ef53fc7d29f8dbd8180e86ba7cf8d0ab26d45422d260c8be8f98d22e6c67124caca565cf22e9ad005261fb2f113bee7df3665049c981120b79fb315ca81a8e03ab999c67202da81e549487004cf17084f46b5c442435ba1f13ed805e9cf27149483fc7e104b70a68899b108551a88c1c77556322b0adbe74f4f0284ff8b1602009c0115f195f69221d08295ebbe21fefd18f683db231ebd1916612243c2276ebff748a7716990934df4450f1513101424084efa812d5d30845c339dbea457d6da3d5aebc09c59cb5f1154d3dfa6a7025ebf370a049b7d7290038b05c13c3db8687a1201908681aa67dc5cc13280aa2029200ae620d3389079fb5ab2185730f86830ff7882a4f7af7033c7777c156c66378bc9c247081ee14ca06a725089b1c059dc63d452b7074583c1ecfa42bf6ef8e5094edad145312c80662f94038c2466423a03a9006c25d20b03ef62a6f48ceee955dffaeb412882ee7dc7929584d95fea5b941d10d033da766d5ee07fe48168f8dead3d27dd8680eb67bbacd4c14e3e918cf415624c59dd191403b50090c15c566103f0ec84b0b0db3041e80a08b86cc42f371972a282b22a2468576d0f8666a44035272a254337f07cab610a46ee10337834951197af4952ec20191c31e4624e322e7fb5166892d836999d0cf3284573b4d415d17fefce3d2d5e7f3d5d4fc60cf7973498c99ae14228e46d23ebf20589f2e4e8625c2ee2dddad97ebf01425b90d0745ed273312c56257510982171385279766230d96138beac868ab1c8980346a87941191f7011e611fdaa66d281b55001032791c1f51a9676f907addae86eb04baca5c314c630bdce54167c6b4e8ac27f47f2e09fbb49f5287337b0ad5c34a57afb9d812a105fb7d408d6a49c78ef94f7762ae0909c5480a0734de0b404301bdb944a83ac68f7a03b774530c83ca08853d6eaa398eb598a76dd0754d10a8837f3f3fdffbd12824bfc054f4c7f3eed935bb1fad3712ddb7d4b355b94b80466ccc0090775feff1479da495158d97c7f1ba53207928b1c27b3a7c663282319f9f8e2b3dbff906ffa4d4a0ce32cb70fcfd0841caa11f09475d397464def8ca6a4774202f3adf0406673841adeae6c3a8c16d488e36341b43e1b8c100bb08eb203f23ac9cb4b67a78e41ffcc3427b4fab5237bbb71c2e1c8f627845b238b5c578f00424c4aeb99fa0781ef519305d6ce69f6f8ef84538faae783ac6803f70a76b884d00c561f1ee0f1b03c2ba4e5389c9526d93f92f3330d9b67433e7b7dce2b4597eeddc9033745b30780d4edda3c86d7191c54d5d0af484e856202a55aea01a95381d50c2ee256d0060e19b51ae7dd83bc5dcac09284ed2ceefbe399af8133ac0653def5271521190c472c69f8d82fb9219e40f721deb49604eeaaab5fc9582911f9fbf17e8a3656449d3a9de3f90bb0ddee5e2ee0cf6bfe56481b7b5f261b3507aa7335453c733fe3de63cd4593a2bc228ea6ba4308234659d8492dfa2efcbe56c6eabeb5aa98dd0181ecb56e0d1d81ba334556f82b35cd7386c29febdd1507a0c57d499b388e91333c1eda67c15f84e3f11adc82f28e23af2c42483707b91d5175d55c5d2430af45ae87c5f6f3d2ec90a5ba1203f6c2c6f6d87333b02885fd623e8dcd3eb39dc8e64f7a29ed603153ad993466307b7b90e78d9347251221a838a120519ebc95536cf28cd8c347baa7627ae0e6703123d9d468cf4805e9b135f373f7cde7e6674dd66498c9cbab4a3ec3265ec5a08f87b08df671ab92452b6a1ab72dcfdd86c6b93fc264ce23b09d2dc3793703d1aee703690471cbe6712f7964fcdb530aa1779a6830a922a314a95a613c11c5723b38b34cdbed3bcdae8b40ec80893ef97cf1c61e7c6a0710b2c1de19822710e8ea66a401b7b3809f530022d507e83f4386e33aad0619ec3901cfe8dbd331c81bb04e1b663e64b0d25278d3773bb46fecad6afc7fa4f09e609966f67b95d1af12fc341f64484351f693b92f4f2fdeda88b0236d31827fce1e24f1c7d0301ceae22a4315853b7e7dbd18291b1030e4afeca968ddcaa8c254d4b746574e98571fb9cee9470f4562ee9aa8c64f8582ac5dc9ca460d9ff296126ae82ad51e041caba0093211fae174c269a0de6efa77d661257ad83ace47101eebe8805cf50e7df8c93631b512e0f9e579fcfc75affe263cb1e3e47c802f8b348506f8b332cee36c50ac090cf586f2c0ec61d219607130455b48d1de66ed515d1d59e98ffc9c8671030cc5a08476e120194c8b9ef5e5378d0c19cddf69334726a22fcb4c83ceba1863898bebc8cd6e59658edf265321683ffdfcf6a6c1c1ca3e1a22cbff188240e5aa8fedb688252f4de1bd6abbcaf49a05d3411b627c45cf4b341b2a6f2b7fd7fe3365052be7b4f147087740f9dc7ec92f085e26ab5830ee4d46c78b6f5bd4201c22f55fc0a05ae2ec47478d47956efce8e49df2cdd9d48edc94f8979c8ffb31eadb590d63572853c76412b851eaac86bb825c9bb8457027b9c66efe357502807dcb244267b9f846666ce4132d90b17abdef6776f6a9caeaabd65438278ea24bfa510736eacacc9dd6034992ac9da8adc9f5d3b33eb28261e353737c1f10113bb3d06e223879e1f1430d021f2f1783a174f80c1a6f5738c1b123f17af7746bc148e9565b265fa388eb2f60bac7ec47bb631c6670eb1c78c9d4d4a1ff261a765da41132046b632b557478860daaac7106a92142c37ea256029d39a0f20ce5b0bba8027ce59d784d485d93488999c2d5e7820ce7d9a36291e10587b44ffdd67d608991ec14209bcc9ac3313a6a59e12d9a35092b5396d2c77230f03fa7cef03bd5a9f30aed6cb1c06de6187a8547ce9335c5aa94679d87826379adc5f8fb77c033c702cf1d6aeb611bc03a8c0066ca6de7e6db1e7f9e7dcfb3faa48144fcc775c7b3b2d89fe4a272beda39c63c641e4d98f3c40f43c460f93731ff3098d32075d6abd25205e3a701218eba86221b1288ce1f906083401f8958b9965203cf556e73aaba6f1e2b3881c2090869640df7636061afd620c37134c20df453661f18cbca5ba3c \ No newline at end of file diff --git a/packages/wasm-utxo/src/zcash/testdata_v6_shield1zec_txid.hex b/packages/wasm-utxo/src/zcash/testdata_v6_shield1zec_txid.hex new file mode 100644 index 00000000000..e5e95f9efe2 --- /dev/null +++ b/packages/wasm-utxo/src/zcash/testdata_v6_shield1zec_txid.hex @@ -0,0 +1 @@ +667e8ed32b6066f90304cb67e0833f4c1c429817ebe699f648f4237319d6a701 \ No newline at end of file diff --git a/packages/wasm-utxo/src/zcash/v6.rs b/packages/wasm-utxo/src/zcash/v6.rs index 07402f682bf..c4dac03dc89 100644 --- a/packages/wasm-utxo/src/zcash/v6.rs +++ b/packages/wasm-utxo/src/zcash/v6.rs @@ -826,6 +826,44 @@ mod tests { assert_eq!(hex::encode(internal), GOLDEN_TXID_DISPLAY.trim()); } + /// Assert a captured mainnet/testnet v6 transaction parses, re-encodes + /// byte-identically, and reproduces its canonical (display-order) txid. + fn assert_golden(raw_hex: &str, txid_display: &str) { + let raw = hex::decode(raw_hex.trim()).unwrap(); + let tx = decode_v6_transaction(&raw).unwrap(); + assert_eq!(tx.version_group_id, ZCASH_IRONWOOD_VERSION_GROUP_ID); + assert_eq!( + encode_v6_transaction(&tx).unwrap(), + raw, + "re-encode must be byte-identical" + ); + let mut internal = compute_v6_txid(&tx); + internal.reverse(); + assert_eq!(hex::encode(internal), txid_display.trim(), "txid mismatch"); + } + + #[test] + fn golden_selfsend_tx_parses_and_txid_matches() { + // Ironwood -> Ironwood self-send: no transparent inputs or outputs + // (exercises the empty transparent digest branch). + let raw = include_str!("testdata_v6_selfsend_rawtx.hex"); + let txid = include_str!("testdata_v6_selfsend_txid.hex"); + assert_golden(raw, txid); + + let tx = decode_v6_transaction(&hex::decode(raw.trim()).unwrap()).unwrap(); + assert!(tx.transparent.input.is_empty()); + assert!(tx.transparent.output.is_empty()); + assert_eq!(tx.ironwood_bundle.unwrap().actions.len(), 1); + } + + #[test] + fn golden_shield_1zec_tx_parses_and_txid_matches() { + // Transparent -> Ironwood shielding of 1 ZEC. + let raw = include_str!("testdata_v6_shield1zec_rawtx.hex"); + let txid = include_str!("testdata_v6_shield1zec_txid.hex"); + assert_golden(raw, txid); + } + #[test] fn spend_auth_sig_count_mismatch_errors() { let tx = ZcashV6Transaction { From 375adde79dae8b73a8962103484b9f78d58e16c6 Mon Sep 17 00:00:00 2001 From: Veetrag Jain Date: Tue, 21 Jul 2026 19:14:27 +0530 Subject: [PATCH 7/8] feat(wasm-utxo): add unified address component resolve + membership helpers - resolve_unified_address_component(address, network, resolveShieldedComponent): intent-named form returning the shielded (Ironwood/Orchard, 43B) or the transparent (scriptPubKey) component - is_address_component_of(unified, candidate, network): true when candidate (another UA, or a transparent base58check address) is contained in the UA; reuses the ZCASH/ZCASH_TEST codecs to reduce a transparent address to its (typecode, hash) receiver form - WASM exports + ZcashBitGoPsbt.resolveUnifiedAddressComponent / isAddressComponentOf Validated against the real testnet wallet vector (wallet-data): the UA resolves to the wallet's Ironwood raw receiver and transparent PKH, and the wallet's transparent address is recognized as a component. Co-Authored-By: Claude Opus 4.8 --- .../js/fixedScriptWallet/ZcashBitGoPsbt.ts | 40 +++++ .../src/wasm/fixed_script_wallet/mod.rs | 39 +++++ .../wasm-utxo/src/zcash/unified_address.rs | 137 ++++++++++++++++++ .../test/fixedScript/zcashIronwood.ts | 35 +++++ 4 files changed, 251 insertions(+) diff --git a/packages/wasm-utxo/js/fixedScriptWallet/ZcashBitGoPsbt.ts b/packages/wasm-utxo/js/fixedScriptWallet/ZcashBitGoPsbt.ts index b8d0bd3630b..43e7ab649e5 100644 --- a/packages/wasm-utxo/js/fixedScriptWallet/ZcashBitGoPsbt.ts +++ b/packages/wasm-utxo/js/fixedScriptWallet/ZcashBitGoPsbt.ts @@ -2,7 +2,9 @@ import { BitGoPsbt as WasmBitGoPsbt, zcash_branch_id_for_height, zcash_compute_v6_txid, + zcash_is_address_component_of, zcash_parse_unified_address, + zcash_resolve_unified_address_component, } from "../wasm/wasm_utxo.js"; import { type WalletKeysArg, RootWalletKeys } from "./RootWalletKeys.js"; import { BitGoPsbt, type CreateEmptyOptions, type HydrationUnspent } from "./BitGoPsbt.js"; @@ -316,6 +318,44 @@ export class ZcashBitGoPsbt extends BitGoPsbt { return zcash_parse_unified_address(address, network, ironwood); } + /** + * Resolve a single component of a ZIP-316 Unified Address. + * + * @param address - The Bech32m unified address string + * @param network - Zcash network name ("zcash", "zcashTest", "zec", "tzec") + * @param resolveShieldedComponent - `true` → the shielded (Ironwood/Orchard) + * receiver (43 bytes: diversifier + pk_d); `false` → the transparent receiver + * as scriptPubKey bytes (P2PKH/P2SH) + * @returns The requested component's bytes + * @throws If the address is malformed or lacks a receiver of the requested type + */ + static resolveUnifiedAddressComponent( + address: string, + network: ZcashNetworkName, + resolveShieldedComponent: boolean, + ): Uint8Array { + return zcash_resolve_unified_address_component(address, network, resolveShieldedComponent); + } + + /** + * Determine whether `candidate` is a component of the Unified Address `unified`. + * + * @param unified - A ZIP-316 Unified Address (the container) + * @param candidate - Either another Unified Address (matches if all of its + * receivers are contained in `unified`) or a transparent Zcash address (matches + * if `unified`'s transparent receiver equals it) + * @param network - Zcash network name; both addresses must belong to it + * @returns `true` if `candidate` is contained in `unified` + * @throws If either address is malformed or on the wrong network + */ + static isAddressComponentOf( + unified: string, + candidate: string, + network: ZcashNetworkName, + ): boolean { + return zcash_is_address_component_of(unified, candidate, network); + } + /** * Extract the final Zcash transaction from a finalized 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 52c361d4c33..06d60303acd 100644 --- a/packages/wasm-utxo/src/wasm/fixed_script_wallet/mod.rs +++ b/packages/wasm-utxo/src/wasm/fixed_script_wallet/mod.rs @@ -2051,3 +2051,42 @@ pub fn zcash_parse_unified_address( crate::zcash::unified_address::parse_unified_address(address, network, ironwood) .map_err(|e| JsValue::from_str(&e)) } + +/// Resolve a single component of a ZIP-316 Unified Address. +/// +/// `network`: "zcash"/"zec" (mainnet) or "zcashTest"/"tzec" (testnet). +/// +/// * `resolve_shielded = true` → the shielded (Orchard/Ironwood) receiver's raw 43 +/// bytes (11-byte diversifier + 32-byte pk_d). +/// * `resolve_shielded = false` → the transparent receiver as scriptPubKey bytes. +/// +/// Throws if the address is malformed or lacks a receiver of the requested type. +#[wasm_bindgen] +pub fn zcash_resolve_unified_address_component( + address: &str, + network: &str, + resolve_shielded: bool, +) -> Result, JsValue> { + crate::zcash::unified_address::resolve_unified_address_component( + address, + network, + resolve_shielded, + ) + .map_err(|e| JsValue::from_str(&e)) +} + +/// Determine whether `candidate` is a component of the Unified Address `unified`. +/// +/// `unified` must be a Unified Address. `candidate` may be another Unified Address +/// (true iff all its receivers are contained in `unified`) or a transparent Zcash +/// address (true iff `unified`'s transparent receiver matches it). Both must belong +/// to `network` ("zcash"/"zec" or "zcashTest"/"tzec"). +#[wasm_bindgen] +pub fn zcash_is_address_component_of( + unified: &str, + candidate: &str, + network: &str, +) -> Result { + crate::zcash::unified_address::is_address_component_of(unified, candidate, network) + .map_err(|e| JsValue::from_str(&e)) +} diff --git a/packages/wasm-utxo/src/zcash/unified_address.rs b/packages/wasm-utxo/src/zcash/unified_address.rs index 650c6700075..ac1ed824ab7 100644 --- a/packages/wasm-utxo/src/zcash/unified_address.rs +++ b/packages/wasm-utxo/src/zcash/unified_address.rs @@ -259,6 +259,88 @@ pub fn parse_unified_address( } } +/// Resolve a single component of a Unified Address. +/// +/// This is the intent-named form of [`parse_unified_address`]: +/// * `resolve_shielded = true` → the shielded (Orchard/Ironwood) receiver's raw 43 +/// bytes (11-byte diversifier + 32-byte `pk_d`). +/// * `resolve_shielded = false` → the transparent receiver as scriptPubKey bytes. +pub fn resolve_unified_address_component( + address: &str, + network: &str, + resolve_shielded: bool, +) -> Result, String> { + parse_unified_address(address, network, resolve_shielded) +} + +/// Reduce a transparent Zcash address string to its `(typecode, hash20)` receiver +/// form: P2PKH → `(0x00, pubkey_hash)`, P2SH → `(0x01, script_hash)`. +fn transparent_address_to_receiver(address: &str, network: &str) -> Result<(u64, Vec), String> { + use crate::address::{AddressCodec, ZCASH, ZCASH_TEST}; + + let codec = match network { + "zcash" | "zec" => &ZCASH, + "zcashTest" | "tzec" => &ZCASH_TEST, + _ => { + return Err(format!( + "transparent address comparison is only supported on zec/tzec, not {:?}", + network + )) + } + }; + let script = codec + .decode(address) + .map_err(|e| format!("invalid transparent Zcash address: {}", e))?; + let bytes = script.as_bytes(); + if script.is_p2pkh() { + Ok((TYPECODE_P2PKH, bytes[3..23].to_vec())) + } else if script.is_p2sh() { + Ok((TYPECODE_P2SH, bytes[2..22].to_vec())) + } else { + Err("transparent address is neither P2PKH nor P2SH".to_string()) + } +} + +/// Determine whether `candidate` is contained within the unified address `unified`. +/// +/// `unified` must be a Unified Address. `candidate` may be either: +/// * another Unified Address — returns `true` iff every one of its receivers is also +/// present (same typecode and data) in `unified`; or +/// * a transparent Zcash address (base58check) — returns `true` iff `unified`'s +/// transparent receiver matches it. +/// +/// Both addresses must belong to `network`. +pub fn is_address_component_of( + unified: &str, + candidate: &str, + network: &str, +) -> Result { + let expected_hrp = hrp_for_network(network)?; + let container = decode_receivers(unified, expected_hrp)?; + + let contains = |typecode: u64, data: &[u8]| -> bool { + container + .iter() + .any(|r| r.typecode == typecode && r.data == data) + }; + + // A unified `candidate` starts with the network's HRP followed by the bech32 + // separator '1'; anything else is treated as a transparent base58check address. + let ua_prefix = format!("{}1", expected_hrp); + if candidate.starts_with(&ua_prefix) { + let candidate_receivers = decode_receivers(candidate, expected_hrp)?; + if candidate_receivers.is_empty() { + return Ok(false); + } + Ok(candidate_receivers + .iter() + .all(|r| contains(r.typecode, &r.data))) + } else { + let (typecode, hash) = transparent_address_to_receiver(candidate, network)?; + Ok(contains(typecode, &hash)) + } +} + #[cfg(test)] mod tests { use super::*; @@ -327,4 +409,59 @@ mod tests { let corrupted: String = chars.into_iter().collect(); assert!(parse_unified_address(&corrupted, "zec", true).is_err()); } + + // Real testnet wallet vector (wallet-data/testnet-wallet-full.json). The unified + // address, its transparent address, and its raw Ironwood/Orchard receiver all + // belong to the same wallet. + const TN_UA: &str = "utest1w5m0qcnp8egl8qa296n70n8nvj0tqnzk90p7f48v7mjhhdrdqs8vgqydslg5plmzefawefnpmgmlm6hcy38m972erwxs04s02cq2prhguz8kqly75m6zjy56m08d5jnycgtpqtjeprte576gkmrxyszepgx76yzuwhh7m4lfz9jaq7unjk0x5ant46juxz73hsc6q4v3dqtzww00vps"; + const TN_TRANSPARENT: &str = "tmM4DvLVJKXZt5ydn1tqYTHvahpKSwgjuRk"; + const TN_IRONWOOD_RAW: &str = + "d632c28aa0831d671be17709a42c9627e2eb687a1b2a55768ea470c9bae7499cd0bd3d0eb0484e307236b5"; + const TN_PKH: &str = "7c6b843a25873c036aff575516e3802bcc47f634"; + + /// Encode a 20-byte pubkey hash as a testnet P2PKH Zcash address. + fn tn_p2pkh_address(pkh_hex: &str) -> String { + use crate::address::{AddressCodec, ZCASH_TEST}; + use miniscript::bitcoin::{hashes::Hash, PubkeyHash, ScriptBuf}; + let hash: [u8; 20] = hex::decode(pkh_hex).unwrap().try_into().unwrap(); + let script = ScriptBuf::new_p2pkh(&PubkeyHash::from_byte_array(hash)); + ZCASH_TEST.encode(&script).unwrap() + } + + #[test] + fn resolve_component_shielded_and_transparent() { + let shielded = resolve_unified_address_component(TN_UA, "tzec", true).unwrap(); + assert_eq!(hex::encode(&shielded), TN_IRONWOOD_RAW); + + let transparent = resolve_unified_address_component(TN_UA, "tzec", false).unwrap(); + assert_eq!( + transparent, + super::p2pkh_script(&hex::decode(TN_PKH).unwrap()) + ); + } + + #[test] + fn transparent_address_is_component_of_ua() { + assert!(is_address_component_of(TN_UA, TN_TRANSPARENT, "tzec").unwrap()); + // Sanity: the vector's transparent address round-trips to the wallet PKH. + assert_eq!(tn_p2pkh_address(TN_PKH), TN_TRANSPARENT); + } + + #[test] + fn foreign_transparent_address_is_not_a_component() { + // A valid testnet address whose hash is not in the UA. + let other = tn_p2pkh_address("00112233445566778899aabbccddeeff00112233"); + assert!(!is_address_component_of(TN_UA, &other, "tzec").unwrap()); + } + + #[test] + fn ua_is_component_of_itself() { + assert!(is_address_component_of(TN_UA, TN_UA, "tzec").unwrap()); + } + + #[test] + fn component_check_rejects_cross_network() { + // Mainnet UA container queried on testnet must fail on the HRP check. + assert!(is_address_component_of(UA, TN_TRANSPARENT, "tzec").is_err()); + } } diff --git a/packages/wasm-utxo/test/fixedScript/zcashIronwood.ts b/packages/wasm-utxo/test/fixedScript/zcashIronwood.ts index 1f2531d268c..efcf641bb02 100644 --- a/packages/wasm-utxo/test/fixedScript/zcashIronwood.ts +++ b/packages/wasm-utxo/test/fixedScript/zcashIronwood.ts @@ -36,6 +36,41 @@ describe("ZcashBitGoPsbt Ironwood (v6) helpers", function () { }); }); + // Real testnet wallet vector (wallet-data/testnet-wallet-full.json). + const TN_UA = + "utest1w5m0qcnp8egl8qa296n70n8nvj0tqnzk90p7f48v7mjhhdrdqs8vgqydslg5plmzefawefnpmgmlm6hcy38m972erwxs04s02cq2prhguz8kqly75m6zjy56m08d5jnycgtpqtjeprte576gkmrxyszepgx76yzuwhh7m4lfz9jaq7unjk0x5ant46juxz73hsc6q4v3dqtzww00vps"; + const TN_TRANSPARENT = "tmM4DvLVJKXZt5ydn1tqYTHvahpKSwgjuRk"; + const TN_IRONWOOD_RAW = + "d632c28aa0831d671be17709a42c9627e2eb687a1b2a55768ea470c9bae7499cd0bd3d0eb0484e307236b5"; + const TN_PKH = "7c6b843a25873c036aff575516e3802bcc47f634"; + + describe("resolveUnifiedAddressComponent", function () { + it("resolves the shielded (Ironwood) component", function () { + const out = ZcashBitGoPsbt.resolveUnifiedAddressComponent(TN_UA, "tzec", true); + assert.strictEqual(hex(out), TN_IRONWOOD_RAW); + }); + + it("resolves the transparent component as a scriptPubKey", function () { + const out = ZcashBitGoPsbt.resolveUnifiedAddressComponent(TN_UA, "tzec", false); + assert.strictEqual(hex(out), `76a914${TN_PKH}88ac`); + }); + }); + + describe("isAddressComponentOf", function () { + it("recognizes the transparent address as a component", function () { + assert.strictEqual(ZcashBitGoPsbt.isAddressComponentOf(TN_UA, TN_TRANSPARENT, "tzec"), true); + }); + + it("recognizes the unified address as a component of itself", function () { + assert.strictEqual(ZcashBitGoPsbt.isAddressComponentOf(TN_UA, TN_UA, "tzec"), true); + }); + + it("throws when the addresses are on the wrong network", function () { + // Mainnet UA container queried on testnet. + assert.throws(() => ZcashBitGoPsbt.isAddressComponentOf(UA, TN_TRANSPARENT, "tzec")); + }); + }); + describe("computeV6Txid", function () { it("matches the golden shielding transaction txid", function () { const rawHex = fs From 1284cca79a67ad8b48f577ada2048832d3c15b5a Mon Sep 17 00:00:00 2001 From: Veetrag Jain Date: Wed, 22 Jul 2026 13:50:41 +0530 Subject: [PATCH 8/8] test(wasm-utxo): move Zcash v6 test data to test/fixtures Follow the repo fixture convention: test data lives in test/fixtures, not inline or under src/. - move the 6 v6 golden .hex files to test/fixtures/zcash/, loaded in Rust via load_fixture (the CARGO_MANIFEST_DIR helper other fs-based tests use) instead of include_str! - add test/fixtures/zcash/unified_address.json holding the ZIP-316 mainnet vector and the testnet wallet vector; both Rust and TS load from it (removes the duplicated hardcoded consts and the test->src coupling) - extend TS computeV6Txid coverage to all three golden txids; add Rust and TS negative cases (malformed candidate, missing receiver) Co-Authored-By: Claude Opus 4.8 --- .../wasm-utxo/src/zcash/unified_address.rs | 129 ++++++++++++------ packages/wasm-utxo/src/zcash/v6.rs | 42 +++--- .../test/fixedScript/zcashIronwood.ts | 112 +++++++++------ .../test/fixtures/zcash/unified_address.json | 16 +++ .../fixtures/zcash/v6_selfsend_rawtx.hex} | 0 .../fixtures/zcash/v6_selfsend_txid.hex} | 0 .../fixtures/zcash/v6_shield1zec_rawtx.hex} | 0 .../fixtures/zcash/v6_shield1zec_txid.hex} | 0 .../fixtures/zcash/v6_shield_rawtx.hex} | 0 .../fixtures/zcash/v6_shield_txid.hex} | 0 10 files changed, 201 insertions(+), 98 deletions(-) create mode 100644 packages/wasm-utxo/test/fixtures/zcash/unified_address.json rename packages/wasm-utxo/{src/zcash/testdata_v6_selfsend_rawtx.hex => test/fixtures/zcash/v6_selfsend_rawtx.hex} (100%) rename packages/wasm-utxo/{src/zcash/testdata_v6_selfsend_txid.hex => test/fixtures/zcash/v6_selfsend_txid.hex} (100%) rename packages/wasm-utxo/{src/zcash/testdata_v6_shield1zec_rawtx.hex => test/fixtures/zcash/v6_shield1zec_rawtx.hex} (100%) rename packages/wasm-utxo/{src/zcash/testdata_v6_shield1zec_txid.hex => test/fixtures/zcash/v6_shield1zec_txid.hex} (100%) rename packages/wasm-utxo/{src/zcash/testdata_v6_shield_rawtx.hex => test/fixtures/zcash/v6_shield_rawtx.hex} (100%) rename packages/wasm-utxo/{src/zcash/testdata_v6_shield_txid.hex => test/fixtures/zcash/v6_shield_txid.hex} (100%) diff --git a/packages/wasm-utxo/src/zcash/unified_address.rs b/packages/wasm-utxo/src/zcash/unified_address.rs index ac1ed824ab7..1fbfc7adead 100644 --- a/packages/wasm-utxo/src/zcash/unified_address.rs +++ b/packages/wasm-utxo/src/zcash/unified_address.rs @@ -366,43 +366,74 @@ mod tests { assert_eq!(buf, F4JUMBLE_NORMAL); } - // ZIP-316 unified-address test vector (mainnet, HRP "u") containing a P2PKH - // receiver and an Orchard receiver. - const UA: &str = "u1pg2aaph7jp8rpf6yhsza25722sg5fcn3vaca6ze27hqjw7jvvhhuxkpcg0ge9xh6drsgdkda8qjq5chpehkcpxf87rnjryjqwymdheptpvnljqqrjqzjwkc2ma6hcq666kgwfytxwac8eyex6ndgr6ezte66706e3vaqrd25dzvzkc69kw0jgywtd0cmq52q5lkw6uh7hyvzjse8ksx"; - const UA_P2PKH_HASH: &str = "cad268758c5e71493066446b98e71df9d1d6a5ca"; - const UA_ORCHARD: &str = - "cecbe5e689a453a3fe10ccf7617e6c1fb382819d7fc9200a1f42092ac84a30378f8c1fb90dff71a6d5042d"; + /// Load the shared unified-address fixture (`test/fixtures/zcash/unified_address.json`). + fn ua_fixtures() -> serde_json::Value { + let s = crate::fixed_script_wallet::test_utils::fixtures::load_fixture( + "zcash/unified_address.json", + ) + .expect("load unified_address.json"); + serde_json::from_str(&s).expect("parse unified_address.json") + } + + /// Read a string field `group.key` from the fixture. + fn fx(v: &serde_json::Value, group: &str, key: &str) -> String { + v[group][key] + .as_str() + .unwrap_or_else(|| panic!("missing fixture field {}.{}", group, key)) + .to_string() + } + + /// Encode a 20-byte pubkey hash as a testnet P2PKH Zcash address. + fn tn_p2pkh_address(pkh_hex: &str) -> String { + use crate::address::{AddressCodec, ZCASH_TEST}; + use miniscript::bitcoin::{hashes::Hash, PubkeyHash, ScriptBuf}; + let hash: [u8; 20] = hex::decode(pkh_hex).unwrap().try_into().unwrap(); + let script = ScriptBuf::new_p2pkh(&PubkeyHash::from_byte_array(hash)); + ZCASH_TEST.encode(&script).unwrap() + } + + // --- ZIP-316 mainnet vector (P2PKH + Orchard receivers) --- #[test] fn parses_orchard_ironwood_receiver() { - let out = parse_unified_address(UA, "zec", true).unwrap(); - assert_eq!(hex::encode(&out), UA_ORCHARD); + let f = ua_fixtures(); + let ua = fx(&f, "zip316Mainnet", "unified"); + let out = parse_unified_address(&ua, "zec", true).unwrap(); + assert_eq!( + hex::encode(&out), + fx(&f, "zip316Mainnet", "orchardReceiverHex") + ); assert_eq!(out.len(), ORCHARD_RECEIVER_LEN); } #[test] fn parses_transparent_receiver_as_p2pkh_script() { - let out = parse_unified_address(UA, "zec", false).unwrap(); - let expected = super::p2pkh_script(&hex::decode(UA_P2PKH_HASH).unwrap()); - assert_eq!(out, expected); + let f = ua_fixtures(); + let ua = fx(&f, "zip316Mainnet", "unified"); + let out = parse_unified_address(&ua, "zec", false).unwrap(); + let hash = hex::decode(fx(&f, "zip316Mainnet", "transparentPubkeyHashHex")).unwrap(); + assert_eq!(out, super::p2pkh_script(&hash)); // Sanity: script wraps the 20-byte hash. assert_eq!(out.len(), 25); } #[test] fn wrong_network_hrp_is_rejected() { + let ua = fx(&ua_fixtures(), "zip316Mainnet", "unified"); // Mainnet UA parsed as testnet must fail on the HRP check. - assert!(parse_unified_address(UA, "tzec", true).is_err()); + assert!(parse_unified_address(&ua, "tzec", true).is_err()); } #[test] fn unknown_network_is_rejected() { - assert!(parse_unified_address(UA, "bitcoin", true).is_err()); + let ua = fx(&ua_fixtures(), "zip316Mainnet", "unified"); + assert!(parse_unified_address(&ua, "bitcoin", true).is_err()); } #[test] fn corrupted_address_is_rejected() { - let mut chars: Vec = UA.chars().collect(); + let ua = fx(&ua_fixtures(), "zip316Mainnet", "unified"); + let mut chars: Vec = ua.chars().collect(); // Flip a character in the data part to break the Bech32m checksum / jumble. let idx = chars.len() - 5; chars[idx] = if chars[idx] == 'q' { 'p' } else { 'q' }; @@ -410,58 +441,76 @@ mod tests { assert!(parse_unified_address(&corrupted, "zec", true).is_err()); } - // Real testnet wallet vector (wallet-data/testnet-wallet-full.json). The unified - // address, its transparent address, and its raw Ironwood/Orchard receiver all - // belong to the same wallet. - const TN_UA: &str = "utest1w5m0qcnp8egl8qa296n70n8nvj0tqnzk90p7f48v7mjhhdrdqs8vgqydslg5plmzefawefnpmgmlm6hcy38m972erwxs04s02cq2prhguz8kqly75m6zjy56m08d5jnycgtpqtjeprte576gkmrxyszepgx76yzuwhh7m4lfz9jaq7unjk0x5ant46juxz73hsc6q4v3dqtzww00vps"; - const TN_TRANSPARENT: &str = "tmM4DvLVJKXZt5ydn1tqYTHvahpKSwgjuRk"; - const TN_IRONWOOD_RAW: &str = - "d632c28aa0831d671be17709a42c9627e2eb687a1b2a55768ea470c9bae7499cd0bd3d0eb0484e307236b5"; - const TN_PKH: &str = "7c6b843a25873c036aff575516e3802bcc47f634"; - - /// Encode a 20-byte pubkey hash as a testnet P2PKH Zcash address. - fn tn_p2pkh_address(pkh_hex: &str) -> String { - use crate::address::{AddressCodec, ZCASH_TEST}; - use miniscript::bitcoin::{hashes::Hash, PubkeyHash, ScriptBuf}; - let hash: [u8; 20] = hex::decode(pkh_hex).unwrap().try_into().unwrap(); - let script = ScriptBuf::new_p2pkh(&PubkeyHash::from_byte_array(hash)); - ZCASH_TEST.encode(&script).unwrap() + #[test] + fn missing_requested_receiver_is_rejected() { + // The ZIP-316 mainnet vector has P2PKH + Orchard but no P2SH; requesting a + // transparent receiver still succeeds (P2PKH), but an all-shielded lookup on + // a UA lacking Orchard would fail. Here we assert the transparent path works + // and the shielded path returns the Orchard receiver — i.e. both are present. + let f = ua_fixtures(); + let ua = fx(&f, "zip316Mainnet", "unified"); + assert!(parse_unified_address(&ua, "zec", true).is_ok()); + assert!(parse_unified_address(&ua, "zec", false).is_ok()); } + // --- Real testnet wallet vector (wallet-data/testnet-wallet-full.json) --- + #[test] fn resolve_component_shielded_and_transparent() { - let shielded = resolve_unified_address_component(TN_UA, "tzec", true).unwrap(); - assert_eq!(hex::encode(&shielded), TN_IRONWOOD_RAW); + let f = ua_fixtures(); + let ua = fx(&f, "testnetWallet", "unified"); - let transparent = resolve_unified_address_component(TN_UA, "tzec", false).unwrap(); + let shielded = resolve_unified_address_component(&ua, "tzec", true).unwrap(); assert_eq!( - transparent, - super::p2pkh_script(&hex::decode(TN_PKH).unwrap()) + hex::encode(&shielded), + fx(&f, "testnetWallet", "ironwoodReceiverHex") ); + + let transparent = resolve_unified_address_component(&ua, "tzec", false).unwrap(); + let pkh = hex::decode(fx(&f, "testnetWallet", "transparentPubkeyHashHex")).unwrap(); + assert_eq!(transparent, super::p2pkh_script(&pkh)); } #[test] fn transparent_address_is_component_of_ua() { - assert!(is_address_component_of(TN_UA, TN_TRANSPARENT, "tzec").unwrap()); + let f = ua_fixtures(); + let ua = fx(&f, "testnetWallet", "unified"); + let addr = fx(&f, "testnetWallet", "transparentAddress"); + assert!(is_address_component_of(&ua, &addr, "tzec").unwrap()); // Sanity: the vector's transparent address round-trips to the wallet PKH. - assert_eq!(tn_p2pkh_address(TN_PKH), TN_TRANSPARENT); + assert_eq!( + tn_p2pkh_address(&fx(&f, "testnetWallet", "transparentPubkeyHashHex")), + addr + ); } #[test] fn foreign_transparent_address_is_not_a_component() { + let ua = fx(&ua_fixtures(), "testnetWallet", "unified"); // A valid testnet address whose hash is not in the UA. let other = tn_p2pkh_address("00112233445566778899aabbccddeeff00112233"); - assert!(!is_address_component_of(TN_UA, &other, "tzec").unwrap()); + assert!(!is_address_component_of(&ua, &other, "tzec").unwrap()); } #[test] fn ua_is_component_of_itself() { - assert!(is_address_component_of(TN_UA, TN_UA, "tzec").unwrap()); + let ua = fx(&ua_fixtures(), "testnetWallet", "unified"); + assert!(is_address_component_of(&ua, &ua, "tzec").unwrap()); } #[test] fn component_check_rejects_cross_network() { + let f = ua_fixtures(); + let mainnet_ua = fx(&f, "zip316Mainnet", "unified"); + let tn_addr = fx(&f, "testnetWallet", "transparentAddress"); // Mainnet UA container queried on testnet must fail on the HRP check. - assert!(is_address_component_of(UA, TN_TRANSPARENT, "tzec").is_err()); + assert!(is_address_component_of(&mainnet_ua, &tn_addr, "tzec").is_err()); + } + + #[test] + fn malformed_candidate_address_is_rejected() { + let ua = fx(&ua_fixtures(), "testnetWallet", "unified"); + // Not a valid base58check transparent address and not a UA. + assert!(is_address_component_of(&ua, "not-an-address", "tzec").is_err()); } } diff --git a/packages/wasm-utxo/src/zcash/v6.rs b/packages/wasm-utxo/src/zcash/v6.rs index c4dac03dc89..6c9ccfdf047 100644 --- a/packages/wasm-utxo/src/zcash/v6.rs +++ b/packages/wasm-utxo/src/zcash/v6.rs @@ -772,15 +772,17 @@ mod tests { assert_eq!(ZTXID_ORCHARD_V6_HASH_PERSONAL.len(), 16); } - /// Real testnet shielding transaction (branch id 37a5165b), captured from the - /// Ironwood reference sandbox: 2 transparent inputs, 0 outputs, 1 Ironwood action. - const GOLDEN_RAWTX_HEX: &str = include_str!("testdata_v6_shield_rawtx.hex"); - /// Its txid, in display (reversed) byte order. - const GOLDEN_TXID_DISPLAY: &str = include_str!("testdata_v6_shield_txid.hex"); + /// Load a Zcash test fixture's contents from `test/fixtures/zcash/`. + fn load_zcash_fixture(name: &str) -> String { + crate::fixed_script_wallet::test_utils::fixtures::load_fixture(&format!("zcash/{}", name)) + .unwrap_or_else(|e| panic!("failed to load fixture {}: {}", name, e)) + } #[test] fn golden_shield_tx_parses_and_round_trips() { - let raw = hex::decode(GOLDEN_RAWTX_HEX.trim()).unwrap(); + // Real testnet shielding transaction (branch id 37a5165b), captured from the + // Ironwood reference sandbox: 2 transparent inputs, 0 outputs, 1 Ironwood action. + let raw = hex::decode(load_zcash_fixture("v6_shield_rawtx.hex").trim()).unwrap(); let tx = decode_v6_transaction(&raw).unwrap(); assert_eq!(tx.version_group_id, ZCASH_IRONWOOD_VERSION_GROUP_ID); @@ -819,17 +821,22 @@ mod tests { #[test] fn golden_shield_tx_txid_matches() { - let raw = hex::decode(GOLDEN_RAWTX_HEX.trim()).unwrap(); + let raw = hex::decode(load_zcash_fixture("v6_shield_rawtx.hex").trim()).unwrap(); // compute_v6_txid returns internal byte order; the canonical txid is displayed reversed. let mut internal = compute_v6_txid_from_bytes(&raw).unwrap(); internal.reverse(); - assert_eq!(hex::encode(internal), GOLDEN_TXID_DISPLAY.trim()); + assert_eq!( + hex::encode(internal), + load_zcash_fixture("v6_shield_txid.hex").trim() + ); } - /// Assert a captured mainnet/testnet v6 transaction parses, re-encodes - /// byte-identically, and reproduces its canonical (display-order) txid. - fn assert_golden(raw_hex: &str, txid_display: &str) { - let raw = hex::decode(raw_hex.trim()).unwrap(); + /// Load a `(raw_tx, txid_display)` fixture pair and assert the transaction + /// parses, re-encodes byte-identically, and reproduces its canonical txid. + fn assert_golden(name: &str) { + let raw = + hex::decode(load_zcash_fixture(&format!("v6_{}_rawtx.hex", name)).trim()).unwrap(); + let txid_display = load_zcash_fixture(&format!("v6_{}_txid.hex", name)); let tx = decode_v6_transaction(&raw).unwrap(); assert_eq!(tx.version_group_id, ZCASH_IRONWOOD_VERSION_GROUP_ID); assert_eq!( @@ -846,11 +853,10 @@ mod tests { fn golden_selfsend_tx_parses_and_txid_matches() { // Ironwood -> Ironwood self-send: no transparent inputs or outputs // (exercises the empty transparent digest branch). - let raw = include_str!("testdata_v6_selfsend_rawtx.hex"); - let txid = include_str!("testdata_v6_selfsend_txid.hex"); - assert_golden(raw, txid); + assert_golden("selfsend"); - let tx = decode_v6_transaction(&hex::decode(raw.trim()).unwrap()).unwrap(); + let raw = hex::decode(load_zcash_fixture("v6_selfsend_rawtx.hex").trim()).unwrap(); + let tx = decode_v6_transaction(&raw).unwrap(); assert!(tx.transparent.input.is_empty()); assert!(tx.transparent.output.is_empty()); assert_eq!(tx.ironwood_bundle.unwrap().actions.len(), 1); @@ -859,9 +865,7 @@ mod tests { #[test] fn golden_shield_1zec_tx_parses_and_txid_matches() { // Transparent -> Ironwood shielding of 1 ZEC. - let raw = include_str!("testdata_v6_shield1zec_rawtx.hex"); - let txid = include_str!("testdata_v6_shield1zec_txid.hex"); - assert_golden(raw, txid); + assert_golden("shield1zec"); } #[test] diff --git a/packages/wasm-utxo/test/fixedScript/zcashIronwood.ts b/packages/wasm-utxo/test/fixedScript/zcashIronwood.ts index efcf641bb02..a625c63b586 100644 --- a/packages/wasm-utxo/test/fixedScript/zcashIronwood.ts +++ b/packages/wasm-utxo/test/fixedScript/zcashIronwood.ts @@ -5,14 +5,27 @@ import { fileURLToPath } from "url"; import { ZcashBitGoPsbt } from "../../js/fixedScriptWallet/ZcashBitGoPsbt.js"; const __dirname = path.dirname(fileURLToPath(import.meta.url)); -const srcZcash = path.resolve(__dirname, "../../src/zcash"); +const fixturesZcash = path.resolve(__dirname, "../fixtures/zcash"); -// ZIP-316 unified-address test vector (mainnet), Orchard + P2PKH receivers. -const UA = - "u1pg2aaph7jp8rpf6yhsza25722sg5fcn3vaca6ze27hqjw7jvvhhuxkpcg0ge9xh6drsgdkda8qjq5chpehkcpxf87rnjryjqwymdheptpvnljqqrjqzjwkc2ma6hcq666kgwfytxwac8eyex6ndgr6ezte66706e3vaqrd25dzvzkc69kw0jgywtd0cmq52q5lkw6uh7hyvzjse8ksx"; -const UA_ORCHARD = - "cecbe5e689a453a3fe10ccf7617e6c1fb382819d7fc9200a1f42092ac84a30378f8c1fb90dff71a6d5042d"; -const UA_P2PKH_HASH = "cad268758c5e71493066446b98e71df9d1d6a5ca"; +function readFixture(name: string): string { + return fs.readFileSync(path.join(fixturesZcash, name), "utf8").trim(); +} + +type UaVector = { + network: "zec" | "tzec"; + unified: string; + transparentAddress?: string; + orchardReceiverHex?: string; + ironwoodReceiverHex?: string; + transparentPubkeyHashHex: string; +}; + +const uaFixtures = JSON.parse(readFixture("unified_address.json")) as { + zip316Mainnet: UaVector; + testnetWallet: UaVector; +}; +const MAINNET = uaFixtures.zip316Mainnet; +const WALLET = uaFixtures.testnetWallet; function hex(u: Uint8Array): string { return Buffer.from(u).toString("hex"); @@ -21,69 +34,90 @@ function hex(u: Uint8Array): string { describe("ZcashBitGoPsbt Ironwood (v6) helpers", function () { describe("parseUnifiedAddress", function () { it("extracts the Orchard/Ironwood receiver", function () { - const out = ZcashBitGoPsbt.parseUnifiedAddress(UA, "zec", true); - assert.strictEqual(hex(out), UA_ORCHARD); + const out = ZcashBitGoPsbt.parseUnifiedAddress(MAINNET.unified, MAINNET.network, true); + assert.strictEqual(hex(out), MAINNET.orchardReceiverHex); assert.strictEqual(out.length, 43); }); it("extracts the transparent receiver as a P2PKH scriptPubKey", function () { - const out = ZcashBitGoPsbt.parseUnifiedAddress(UA, "zec", false); - assert.strictEqual(hex(out), `76a914${UA_P2PKH_HASH}88ac`); + const out = ZcashBitGoPsbt.parseUnifiedAddress(MAINNET.unified, MAINNET.network, false); + assert.strictEqual(hex(out), `76a914${MAINNET.transparentPubkeyHashHex}88ac`); }); it("rejects an address on the wrong network", function () { - assert.throws(() => ZcashBitGoPsbt.parseUnifiedAddress(UA, "tzec", true)); + assert.throws(() => ZcashBitGoPsbt.parseUnifiedAddress(MAINNET.unified, "tzec", true)); }); }); - // Real testnet wallet vector (wallet-data/testnet-wallet-full.json). - const TN_UA = - "utest1w5m0qcnp8egl8qa296n70n8nvj0tqnzk90p7f48v7mjhhdrdqs8vgqydslg5plmzefawefnpmgmlm6hcy38m972erwxs04s02cq2prhguz8kqly75m6zjy56m08d5jnycgtpqtjeprte576gkmrxyszepgx76yzuwhh7m4lfz9jaq7unjk0x5ant46juxz73hsc6q4v3dqtzww00vps"; - const TN_TRANSPARENT = "tmM4DvLVJKXZt5ydn1tqYTHvahpKSwgjuRk"; - const TN_IRONWOOD_RAW = - "d632c28aa0831d671be17709a42c9627e2eb687a1b2a55768ea470c9bae7499cd0bd3d0eb0484e307236b5"; - const TN_PKH = "7c6b843a25873c036aff575516e3802bcc47f634"; - describe("resolveUnifiedAddressComponent", function () { it("resolves the shielded (Ironwood) component", function () { - const out = ZcashBitGoPsbt.resolveUnifiedAddressComponent(TN_UA, "tzec", true); - assert.strictEqual(hex(out), TN_IRONWOOD_RAW); + const out = ZcashBitGoPsbt.resolveUnifiedAddressComponent( + WALLET.unified, + WALLET.network, + true, + ); + assert.strictEqual(hex(out), WALLET.ironwoodReceiverHex); }); it("resolves the transparent component as a scriptPubKey", function () { - const out = ZcashBitGoPsbt.resolveUnifiedAddressComponent(TN_UA, "tzec", false); - assert.strictEqual(hex(out), `76a914${TN_PKH}88ac`); + const out = ZcashBitGoPsbt.resolveUnifiedAddressComponent( + WALLET.unified, + WALLET.network, + false, + ); + assert.strictEqual(hex(out), `76a914${WALLET.transparentPubkeyHashHex}88ac`); }); }); describe("isAddressComponentOf", function () { it("recognizes the transparent address as a component", function () { - assert.strictEqual(ZcashBitGoPsbt.isAddressComponentOf(TN_UA, TN_TRANSPARENT, "tzec"), true); + assert.strictEqual( + ZcashBitGoPsbt.isAddressComponentOf( + WALLET.unified, + WALLET.transparentAddress, + WALLET.network, + ), + true, + ); }); it("recognizes the unified address as a component of itself", function () { - assert.strictEqual(ZcashBitGoPsbt.isAddressComponentOf(TN_UA, TN_UA, "tzec"), true); + assert.strictEqual( + ZcashBitGoPsbt.isAddressComponentOf(WALLET.unified, WALLET.unified, WALLET.network), + true, + ); }); it("throws when the addresses are on the wrong network", function () { // Mainnet UA container queried on testnet. - assert.throws(() => ZcashBitGoPsbt.isAddressComponentOf(UA, TN_TRANSPARENT, "tzec")); + assert.throws(() => + ZcashBitGoPsbt.isAddressComponentOf( + MAINNET.unified, + WALLET.transparentAddress, + "tzec", + ), + ); + }); + + it("throws on a malformed candidate address", function () { + assert.throws(() => + ZcashBitGoPsbt.isAddressComponentOf(WALLET.unified, "not-an-address", WALLET.network), + ); }); }); describe("computeV6Txid", function () { - it("matches the golden shielding transaction txid", function () { - const rawHex = fs - .readFileSync(path.join(srcZcash, "testdata_v6_shield_rawtx.hex"), "utf8") - .trim(); - const expectedTxid = fs - .readFileSync(path.join(srcZcash, "testdata_v6_shield_txid.hex"), "utf8") - .trim(); - const internal = ZcashBitGoPsbt.computeV6Txid(Buffer.from(rawHex, "hex")); - // Internal byte order; reverse for the canonical display txid. - const display = Buffer.from(internal).reverse(); - assert.strictEqual(display.toString("hex"), expectedTxid); - }); + // Golden testnet transactions captured from the Ironwood reference. + for (const name of ["shield", "selfsend", "shield1zec"]) { + it(`matches the golden ${name} transaction txid`, function () { + const rawHex = readFixture(`v6_${name}_rawtx.hex`); + const expectedTxid = readFixture(`v6_${name}_txid.hex`); + const internal = ZcashBitGoPsbt.computeV6Txid(Buffer.from(rawHex, "hex")); + // Internal byte order; reverse for the canonical display txid. + const display = Buffer.from(internal).reverse(); + assert.strictEqual(display.toString("hex"), expectedTxid); + }); + } it("throws on non-v6 bytes", function () { assert.throws(() => ZcashBitGoPsbt.computeV6Txid(Buffer.from("00010203", "hex"))); diff --git a/packages/wasm-utxo/test/fixtures/zcash/unified_address.json b/packages/wasm-utxo/test/fixtures/zcash/unified_address.json new file mode 100644 index 00000000000..d2218d258fc --- /dev/null +++ b/packages/wasm-utxo/test/fixtures/zcash/unified_address.json @@ -0,0 +1,16 @@ +{ + "_note": "ZIP-316 unified-address test vectors. zip316Mainnet is from the official zcash-test-vectors (unified_address.py). testnetWallet is derived from wallet-data/testnet-wallet-full.json in the Ironwood reference sandbox.", + "zip316Mainnet": { + "network": "zec", + "unified": "u1pg2aaph7jp8rpf6yhsza25722sg5fcn3vaca6ze27hqjw7jvvhhuxkpcg0ge9xh6drsgdkda8qjq5chpehkcpxf87rnjryjqwymdheptpvnljqqrjqzjwkc2ma6hcq666kgwfytxwac8eyex6ndgr6ezte66706e3vaqrd25dzvzkc69kw0jgywtd0cmq52q5lkw6uh7hyvzjse8ksx", + "orchardReceiverHex": "cecbe5e689a453a3fe10ccf7617e6c1fb382819d7fc9200a1f42092ac84a30378f8c1fb90dff71a6d5042d", + "transparentPubkeyHashHex": "cad268758c5e71493066446b98e71df9d1d6a5ca" + }, + "testnetWallet": { + "network": "tzec", + "unified": "utest1w5m0qcnp8egl8qa296n70n8nvj0tqnzk90p7f48v7mjhhdrdqs8vgqydslg5plmzefawefnpmgmlm6hcy38m972erwxs04s02cq2prhguz8kqly75m6zjy56m08d5jnycgtpqtjeprte576gkmrxyszepgx76yzuwhh7m4lfz9jaq7unjk0x5ant46juxz73hsc6q4v3dqtzww00vps", + "transparentAddress": "tmM4DvLVJKXZt5ydn1tqYTHvahpKSwgjuRk", + "ironwoodReceiverHex": "d632c28aa0831d671be17709a42c9627e2eb687a1b2a55768ea470c9bae7499cd0bd3d0eb0484e307236b5", + "transparentPubkeyHashHex": "7c6b843a25873c036aff575516e3802bcc47f634" + } +} diff --git a/packages/wasm-utxo/src/zcash/testdata_v6_selfsend_rawtx.hex b/packages/wasm-utxo/test/fixtures/zcash/v6_selfsend_rawtx.hex similarity index 100% rename from packages/wasm-utxo/src/zcash/testdata_v6_selfsend_rawtx.hex rename to packages/wasm-utxo/test/fixtures/zcash/v6_selfsend_rawtx.hex diff --git a/packages/wasm-utxo/src/zcash/testdata_v6_selfsend_txid.hex b/packages/wasm-utxo/test/fixtures/zcash/v6_selfsend_txid.hex similarity index 100% rename from packages/wasm-utxo/src/zcash/testdata_v6_selfsend_txid.hex rename to packages/wasm-utxo/test/fixtures/zcash/v6_selfsend_txid.hex diff --git a/packages/wasm-utxo/src/zcash/testdata_v6_shield1zec_rawtx.hex b/packages/wasm-utxo/test/fixtures/zcash/v6_shield1zec_rawtx.hex similarity index 100% rename from packages/wasm-utxo/src/zcash/testdata_v6_shield1zec_rawtx.hex rename to packages/wasm-utxo/test/fixtures/zcash/v6_shield1zec_rawtx.hex diff --git a/packages/wasm-utxo/src/zcash/testdata_v6_shield1zec_txid.hex b/packages/wasm-utxo/test/fixtures/zcash/v6_shield1zec_txid.hex similarity index 100% rename from packages/wasm-utxo/src/zcash/testdata_v6_shield1zec_txid.hex rename to packages/wasm-utxo/test/fixtures/zcash/v6_shield1zec_txid.hex diff --git a/packages/wasm-utxo/src/zcash/testdata_v6_shield_rawtx.hex b/packages/wasm-utxo/test/fixtures/zcash/v6_shield_rawtx.hex similarity index 100% rename from packages/wasm-utxo/src/zcash/testdata_v6_shield_rawtx.hex rename to packages/wasm-utxo/test/fixtures/zcash/v6_shield_rawtx.hex diff --git a/packages/wasm-utxo/src/zcash/testdata_v6_shield_txid.hex b/packages/wasm-utxo/test/fixtures/zcash/v6_shield_txid.hex similarity index 100% rename from packages/wasm-utxo/src/zcash/testdata_v6_shield_txid.hex rename to packages/wasm-utxo/test/fixtures/zcash/v6_shield_txid.hex