From 17b4ec7647fda11f424caa8b96fd78bfb2837376 Mon Sep 17 00:00:00 2001 From: Veetrag Jain Date: Thu, 23 Jul 2026 11:55:12 +0530 Subject: [PATCH] feat(wasm-utxo): add ZIP-316 unified address parsing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PR2 of 3 (off the blake2b base). Parses a Unified Address into its receiver components via Bech32m + F4Jumble; Ironwood reuses the Orchard receiver (0x03). API (addresses review feedback — no boolean-flag return trap, single entry point instead of parse/resolve duplicates): - ZcashUnifiedAddress.parse(address, network) decodes once; component accessors orchardReceiver / saplingReceiver / transparentScript return typed bytes or undefined; contains(candidate) answers UA/transparent membership. - Rust core UnifiedAddress with the same shape; zec-namespaced WASM module (src/wasm/zcash.rs) that also absorbs zcash_branch_id_for_height. Robustness/consistency fixes from review: - UA-vs-transparent candidate detection uses a real Bech32m try-decode, not a string prefix. - network handling is consistent (zec/tzec only; regtest dropped rather than half-supported). - F4Jumble length is bounded on both ends per ZIP-316 VALID_LENGTH. Vendors the ZIP-316/F4Jumble encoding reference into docs/. Rust + TS tests validate F4Jumble against the official vector and parsing against an official ZIP-316 vector and a real testnet wallet vector (test/fixtures/zcash/). Co-Authored-By: Claude Opus 4.8 --- .../docs/zip-0316-unified-address.md | 122 ++++ .../fixedScriptWallet/ZcashUnifiedAddress.ts | 75 +++ .../wasm-utxo/js/fixedScriptWallet/index.ts | 3 + packages/wasm-utxo/src/error.rs | 9 + .../src/wasm/fixed_script_wallet/mod.rs | 21 +- packages/wasm-utxo/src/wasm/mod.rs | 1 + packages/wasm-utxo/src/wasm/zcash.rs | 92 +++ packages/wasm-utxo/src/zcash/mod.rs | 1 + .../wasm-utxo/src/zcash/unified_address.rs | 612 ++++++++++++++++++ .../test/fixedScript/zcashUnifiedAddress.ts | 95 +++ .../test/fixtures/zcash/unified_address.json | 16 + 11 files changed, 1028 insertions(+), 19 deletions(-) create mode 100644 packages/wasm-utxo/docs/zip-0316-unified-address.md create mode 100644 packages/wasm-utxo/js/fixedScriptWallet/ZcashUnifiedAddress.ts create mode 100644 packages/wasm-utxo/src/wasm/zcash.rs create mode 100644 packages/wasm-utxo/src/zcash/unified_address.rs create mode 100644 packages/wasm-utxo/test/fixedScript/zcashUnifiedAddress.ts create mode 100644 packages/wasm-utxo/test/fixtures/zcash/unified_address.json diff --git a/packages/wasm-utxo/docs/zip-0316-unified-address.md b/packages/wasm-utxo/docs/zip-0316-unified-address.md new file mode 100644 index 00000000000..8d6d9cd14ce --- /dev/null +++ b/packages/wasm-utxo/docs/zip-0316-unified-address.md @@ -0,0 +1,122 @@ +# ZIP-316 Unified Address encoding (vendored reference) + +This is a self-contained reference for the exact encoding that +[`src/zcash/unified_address.rs`](../src/zcash/unified_address.rs) parses. It is +distilled from the canonical sources so review does not depend on an external link: + +- ZIP-316 "Unified Addresses and Unified Viewing Keys" — +- F4Jumble reference implementation — the `f4jumble` crate (Zcash Foundation) and + +- Unified-address test vectors — `zcash_address` crate + (`kind/unified/address/test_vectors.rs`) and + + +We implement **decoding only** (parse a UA → receivers). Encoding is not needed. + +--- + +## 1. Overall structure + +A Unified Address is a Bech32m string whose data part is an **F4Jumbled** byte +sequence. Decoding reverses the pipeline: + +``` +UA string + ── Bech32m decode ─────────────▶ jumbled bytes (HRP checked against network) + ── F4Jumble⁻¹ ────────────────▶ padded bytes + ── strip 16-byte HRP padding ─▶ receivers blob + ── parse TLV records ─────────▶ [ (typecode, data), … ] +``` + +### Human-readable parts (HRP) + +| Network | HRP | +| ---------------------------- | ------- | +| Mainnet (`zec`/`zcash`) | `u` | +| Testnet (`tzec`/`zcashTest`) | `utest` | + +(Regtest `uregtest` exists in ZIP-316 but is intentionally unsupported here — there +is no corresponding transparent base58check codec in this crate, so supporting it +for UA parsing but not for transparent-address comparison would be inconsistent.) + +### Receiver TLV records + +The un-jumbled, un-padded blob is a sequence of receivers, each: + +``` +CompactSize(typecode) ‖ CompactSize(length) ‖ data[length] +``` + +Receivers MUST appear in **strictly ascending typecode order**. + +| Typecode | Receiver | `data` | +| -------- | ------------------- | --------------------------------------------- | +| `0x00` | P2PKH (transparent) | 20-byte pubkey hash | +| `0x01` | P2SH (transparent) | 20-byte script hash | +| `0x02` | Sapling | 43 bytes (11-byte diversifier + 32-byte pk_d) | +| `0x03` | Orchard | 43 bytes (11-byte diversifier + 32-byte pk_d) | + +**Ironwood (NU6.3) reuses the Orchard receiver (`0x03`)** — no new typecode. An +Orchard unified receiver routes to the Ironwood pool once NU6.3 rules are active. + +A transparent receiver here is reduced to its scriptPubKey: P2PKH → +`OP_DUP OP_HASH160 <20> OP_EQUALVERIFY OP_CHECKSIG`, P2SH → `OP_HASH160 <20> OP_EQUAL`. + +### Padding + +After the last receiver, 16 bytes of padding are appended: the HRP as ASCII, +zero-extended to 16 bytes. On decode we strip the last 16 bytes and verify they +equal `HRP ‖ 0x00…`. + +--- + +## 2. F4Jumble + +F4Jumble is a length-preserving, unkeyed **4-round Feistel** network over two +unequal halves, giving cascading (avalanche) behavior so a single altered character +changes the whole decoded output. Valid message length is `48 ..= 4_194_368` bytes. + +### Split + +``` +ℓ = len(message) +left_len = min(64, ℓ / 2) # 64 = BLAKE2b output size (OUTBYTES) +left = message[..left_len] +right = message[left_len..] +``` + +### Round functions + +Both use BLAKE2b with a 16-byte personalization. + +``` +H(i): hash = BLAKE2b(personal = b"UA_F4Jumble_H" ‖ [i, 0, 0], + out_len = len(left)) over `right` + left ^= hash + +G(i): for j in 0 .. ceil(len(right) / 64): + hash = BLAKE2b(personal = b"UA_F4Jumble_G" ‖ [i, j_lo, j_hi], + out_len = 64) over `left` + right[j*64 ..] ^= hash +``` + +`j_lo`/`j_hi` are the little-endian bytes of the `u16` block index `j`. +Note the output length is folded into BLAKE2b's parameter block, so it is part of +the domain — not a truncation (see `blake2b_var_personal`). + +### Round order + +``` +apply F4Jumble : G(0) H(0) G(1) H(1) +apply F4Jumble⁻¹ : H(1) G(1) H(0) G(0) # what we implement +``` + +--- + +## 3. Test vectors used + +- **F4Jumble⁻¹** is checked against the 48-byte vector from the `f4jumble` crate + (`test_vectors.rs`, vector 0). +- **UA parsing** is checked against an official ZIP-316 mainnet vector (P2PKH + + Orchard receivers) and a real testnet wallet vector (UA ↔ transparent address ↔ + raw Ironwood receiver), both in `test/fixtures/zcash/unified_address.json`. diff --git a/packages/wasm-utxo/js/fixedScriptWallet/ZcashUnifiedAddress.ts b/packages/wasm-utxo/js/fixedScriptWallet/ZcashUnifiedAddress.ts new file mode 100644 index 00000000000..453c2d5aa39 --- /dev/null +++ b/packages/wasm-utxo/js/fixedScriptWallet/ZcashUnifiedAddress.ts @@ -0,0 +1,75 @@ +import { ZcashUnifiedAddress as WasmZcashUnifiedAddress } from "../wasm/wasm_utxo.js"; +import type { ZcashNetworkName } from "./ZcashBitGoPsbt.js"; + +/** + * A parsed ZIP-316 Unified Address. + * + * Decode once with {@link ZcashUnifiedAddress.parse}, then read each component + * through its accessor (returns `undefined` when the receiver is absent). Membership + * of another address is answered by {@link contains}. + * + * Ironwood reuses the Orchard receiver, so {@link orchardReceiver} is the shielded + * receiver used to construct an Ironwood output note. + * + * @example + * ```typescript + * const ua = ZcashUnifiedAddress.parse(uaString, "zec"); + * const ironwood = ua.orchardReceiver; // 43 bytes, or undefined + * const script = ua.transparentScript; // scriptPubKey bytes, or undefined + * ua.contains(transparentAddress); // is it one of this UA's receivers? + * ``` + */ +export class ZcashUnifiedAddress { + private constructor(private _wasm: WasmZcashUnifiedAddress) {} + + /** + * Parse a Unified Address. + * + * @param address - The Bech32m unified address string + * @param network - Zcash network name ("zcash", "zcashTest", "zec", "tzec") + * @throws If the address is malformed or on the wrong network + */ + static parse(address: string, network: ZcashNetworkName): ZcashUnifiedAddress { + return new ZcashUnifiedAddress(WasmZcashUnifiedAddress.parse(address, network)); + } + + /** + * The Orchard (a.k.a. Ironwood) receiver — 43 raw bytes (11-byte diversifier + + * 32-byte pk_d) — or `undefined` if the UA has no Orchard receiver. + */ + get orchardReceiver(): Uint8Array | undefined { + return this._wasm.orchardReceiver; + } + + /** + * The Sapling receiver — 43 raw bytes (diversifier + pk_d) — or `undefined`. + */ + get saplingReceiver(): Uint8Array | undefined { + return this._wasm.saplingReceiver; + } + + /** + * The transparent receiver as scriptPubKey bytes (P2PKH or P2SH), ready to use as + * a `TxOut.scriptPubKey`, or `undefined` if the UA has no transparent receiver. + */ + get transparentScript(): Uint8Array | undefined { + return this._wasm.transparentScript; + } + + /** + * Whether `candidate` is a receiver of this unified address. + * + * @param candidate - Another Unified Address (matches if all of its receivers are + * contained here) or a transparent Zcash address (matches this UA's transparent + * receiver). Must be on the same network as this UA. + * @throws If `candidate` is malformed or on the wrong network + */ + contains(candidate: string): boolean { + return this._wasm.contains(candidate); + } + + /** @internal */ + get wasm(): WasmZcashUnifiedAddress { + return this._wasm; + } +} diff --git a/packages/wasm-utxo/js/fixedScriptWallet/index.ts b/packages/wasm-utxo/js/fixedScriptWallet/index.ts index 7cc3879f119..d1359c339fb 100644 --- a/packages/wasm-utxo/js/fixedScriptWallet/index.ts +++ b/packages/wasm-utxo/js/fixedScriptWallet/index.ts @@ -46,6 +46,9 @@ export { type CreateEmptyZcashOptions, } from "./ZcashBitGoPsbt.js"; +// Zcash ZIP-316 Unified Address +export { ZcashUnifiedAddress } from "./ZcashUnifiedAddress.js"; + import type { ScriptType } from "./scriptType.js"; /** diff --git a/packages/wasm-utxo/src/error.rs b/packages/wasm-utxo/src/error.rs index 233802df22f..a53b35d49fa 100644 --- a/packages/wasm-utxo/src/error.rs +++ b/packages/wasm-utxo/src/error.rs @@ -23,6 +23,7 @@ macro_rules! impl_wasm_error_code { pub enum WasmUtxoError { StringError(String), Parse(ParseTransactionError), + UnifiedAddress(crate::zcash::unified_address::UnifiedAddressError), } impl std::error::Error for WasmUtxoError {} @@ -32,6 +33,7 @@ impl fmt::Display for WasmUtxoError { match self { WasmUtxoError::StringError(s) => write!(f, "{}", s), WasmUtxoError::Parse(e) => write!(f, "{}", e), + WasmUtxoError::UnifiedAddress(e) => write!(f, "{}", e), } } } @@ -41,6 +43,7 @@ impl WasmErrorCode for WasmUtxoError { match self { WasmUtxoError::StringError(_) => "WasmUtxoError.StringError".to_string(), WasmUtxoError::Parse(e) => e.code(), + WasmUtxoError::UnifiedAddress(e) => e.code(), } } } @@ -81,6 +84,12 @@ impl From for WasmUtxoError { } } +impl From for WasmUtxoError { + fn from(err: crate::zcash::unified_address::UnifiedAddressError) -> Self { + WasmUtxoError::UnifiedAddress(err) + } +} + impl WasmUtxoError { pub fn new(s: &str) -> WasmUtxoError { WasmUtxoError::StringError(s.to_string()) 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..fe27ce704a8 100644 --- a/packages/wasm-utxo/src/wasm/fixed_script_wallet/mod.rs +++ b/packages/wasm-utxo/src/wasm/fixed_script_wallet/mod.rs @@ -2001,22 +2001,5 @@ impl BitGoPsbt { impl_wasm_psbt_ops!(BitGoPsbt, psbt); -/// Return the Zcash consensus branch ID active at `height` on `network`. -/// -/// `network`: "zcash" / "zec" for mainnet, "zcashTest" / "tzec" for testnet. -/// Returns `None` if `height` is before Overwinter activation. -/// Throws if `network` is not a recognised Zcash network name. -#[wasm_bindgen] -pub fn zcash_branch_id_for_height(network: &str, height: u32) -> Result, JsValue> { - let is_mainnet = match network { - "zcash" | "zec" => true, - "zcashTest" | "tzec" => false, - _ => { - return Err(JsValue::from_str(&format!( - "unknown Zcash network {:?}: expected \"zcash\", \"zec\", \"zcashTest\", or \"tzec\"", - network - ))) - } - }; - Ok(crate::zcash::branch_id_for_height(height, is_mainnet)) -} +// Zcash-scoped bindings (`zcash_branch_id_for_height`, `ZcashUnifiedAddress`, …) +// live in `crate::wasm::zcash`. diff --git a/packages/wasm-utxo/src/wasm/mod.rs b/packages/wasm-utxo/src/wasm/mod.rs index c47f472cb4c..dda613a99ea 100644 --- a/packages/wasm-utxo/src/wasm/mod.rs +++ b/packages/wasm-utxo/src/wasm/mod.rs @@ -20,6 +20,7 @@ mod try_from_js_value; mod try_into_js_value; mod utxolib_compat; mod wallet_keys; +mod zcash; pub use address::AddressNamespace; pub use bip32::WasmBIP32; diff --git a/packages/wasm-utxo/src/wasm/zcash.rs b/packages/wasm-utxo/src/wasm/zcash.rs new file mode 100644 index 00000000000..7483a531504 --- /dev/null +++ b/packages/wasm-utxo/src/wasm/zcash.rs @@ -0,0 +1,92 @@ +//! Zcash-scoped WASM bindings. +//! +//! Groups the `zcash_*` free functions and the [`ZcashUnifiedAddress`] wrapper so +//! Zcash surface lives in one zec-namespaced module rather than spread through the +//! generic fixed-script-wallet bindings. + +use crate::error::WasmUtxoError; +use wasm_bindgen::prelude::*; + +/// Return the Zcash consensus branch ID active at `height` on `network`. +/// +/// `network`: "zcash" / "zec" for mainnet, "zcashTest" / "tzec" for testnet. +/// Returns `None` if `height` is before Overwinter activation. +/// Throws if `network` is not a recognised Zcash network name. +/// +/// Errors are thrown as the crate-standard [`WasmUtxoError`] (a marked `js_sys::Error` +/// with `.message` and `.code`), not a bare string. +#[wasm_bindgen] +pub fn zcash_branch_id_for_height( + network: &str, + height: u32, +) -> Result, WasmUtxoError> { + let is_mainnet = match network { + "zcash" | "zec" => true, + "zcashTest" | "tzec" => false, + _ => { + return Err(WasmUtxoError::from(format!( + "unknown Zcash network {network:?}: expected \"zcash\", \"zec\", \"zcashTest\", or \"tzec\"" + ))) + } + }; + Ok(crate::zcash::branch_id_for_height(height, is_mainnet)) +} + +/// A parsed ZIP-316 Unified Address. +/// +/// Decode once with [`ZcashUnifiedAddress::parse`], then read each component through +/// its accessor (returns `undefined` when absent). Ironwood reuses the Orchard +/// receiver, so `orchardReceiver` is the shielded receiver for Ironwood output notes. +#[wasm_bindgen] +pub struct ZcashUnifiedAddress { + inner: crate::zcash::unified_address::UnifiedAddress, + orchard: Option>, + sapling: Option>, + transparent: Option>, +} + +#[wasm_bindgen] +impl ZcashUnifiedAddress { + /// Parse a Unified Address for `network` ("zcash"/"zec" or "zcashTest"/"tzec"). + /// + /// All receiver components are resolved and validated eagerly, so the accessors + /// below are infallible. Throws if the address is malformed or on the wrong network. + #[wasm_bindgen] + pub fn parse(address: &str, network: &str) -> Result { + let inner = crate::zcash::unified_address::UnifiedAddress::parse(address, network)?; + let orchard = inner.orchard_receiver()?; + let sapling = inner.sapling_receiver()?; + let transparent = inner.transparent_script()?; + Ok(ZcashUnifiedAddress { + inner, + orchard, + sapling, + transparent, + }) + } + + /// The Orchard/Ironwood receiver's raw 43 bytes (diversifier + pk_d), or `undefined`. + #[wasm_bindgen(getter, js_name = orchardReceiver)] + pub fn orchard_receiver(&self) -> Option> { + self.orchard.clone() + } + + /// The Sapling receiver's raw 43 bytes (diversifier + pk_d), or `undefined`. + #[wasm_bindgen(getter, js_name = saplingReceiver)] + pub fn sapling_receiver(&self) -> Option> { + self.sapling.clone() + } + + /// The transparent receiver as scriptPubKey bytes (P2PKH/P2SH), or `undefined`. + #[wasm_bindgen(getter, js_name = transparentScript)] + pub fn transparent_script(&self) -> Option> { + self.transparent.clone() + } + + /// Whether `candidate` (another Unified Address, or a transparent Zcash address + /// on the same network) is a receiver of this Unified Address. + #[wasm_bindgen] + pub fn contains(&self, candidate: &str) -> Result { + Ok(self.inner.contains(candidate)?) + } +} diff --git a/packages/wasm-utxo/src/zcash/mod.rs b/packages/wasm-utxo/src/zcash/mod.rs index 72c8161ea11..eb6e0c59c30 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; /// Zcash network upgrade identifiers #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] 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..61daf7263f3 --- /dev/null +++ b/packages/wasm-utxo/src/zcash/unified_address.rs @@ -0,0 +1,612 @@ +//! 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 needs only Bech32m + F4Jumble⁻¹ + typecode scanning — no `orchard` +//! crate, no ZK machinery. The exact wire format and F4Jumble algorithm this file +//! implements are vendored in `docs/zip-0316-unified-address.md`. +//! +//! # API +//! +//! [`UnifiedAddress::parse`] decodes a UA once; the individual components are then +//! read through named accessors ([`UnifiedAddress::transparent_script`], +//! [`UnifiedAddress::orchard_receiver`], …). [`UnifiedAddress::contains`] answers +//! whether another address (a UA or a transparent address) is a receiver of this UA. + +use super::blake2b::blake2b_var_personal; +use bech32::primitives::decode::CheckedHrpstring; +use bech32::Bech32m; +use core::fmt; +use miniscript::bitcoin::consensus::Decodable; +use miniscript::bitcoin::VarInt; + +/// Errors produced while parsing or inspecting a ZIP-316 Unified Address. +/// +/// The variant name is surfaced to JS as `err.code` (e.g. +/// `"UnifiedAddressError.WrongHrp"`) via [`crate::error::WasmUtxoError`], so callers +/// can branch on the error kind instead of matching message text. +#[derive(Debug, strum::IntoStaticStr)] +pub enum UnifiedAddressError { + /// The string is not valid Bech32m. + BadBech32(String), + /// Unknown Zcash network name. + UnknownNetwork(String), + /// The address HRP does not match the requested network. + WrongHrp, + /// The F4Jumble payload length is outside ZIP-316 `VALID_LENGTH` (48..=4194368). + InvalidLength, + /// The un-jumbled payload is shorter than the 16-byte HRP padding. + PayloadTooShort, + /// The trailing padding does not equal the zero-extended HRP. + BadPadding, + /// A receiver typecode or length CompactSize could not be read (or is non-minimal). + BadReceiverEncoding(String), + /// A receiver length does not fit in `usize`. + ReceiverLengthOverflow, + /// A receiver's declared length runs past the end of the payload. + TruncatedReceiver, + /// Receivers are not in strictly ascending typecode order (ZIP-316). + ReceiverOrder, + /// A receiver has an unexpected byte length for its type. + BadReceiverLength, + /// The address has no shielded receiver; ZIP-316 forbids transparent-only UAs. + NoShieldedReceiver, + /// The candidate is not a valid transparent Zcash P2PKH/P2SH address. + BadTransparentAddress(String), +} + +impl fmt::Display for UnifiedAddressError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::BadBech32(e) => write!(f, "invalid Bech32m unified address: {e}"), + Self::UnknownNetwork(n) => write!( + f, + "unknown Zcash network {n:?}: expected \"zec\"/\"zcash\" or \"tzec\"/\"zcashTest\"" + ), + Self::WrongHrp => write!(f, "unified address HRP does not match the network"), + Self::InvalidLength => write!(f, "unified address payload length out of F4Jumble range"), + Self::PayloadTooShort => write!(f, "unified address payload shorter than padding"), + Self::BadPadding => write!(f, "unified address padding does not match HRP"), + Self::BadReceiverEncoding(e) => write!(f, "failed to read receiver field: {e}"), + Self::ReceiverLengthOverflow => write!(f, "receiver length overflow"), + Self::TruncatedReceiver => write!(f, "receiver data truncated"), + Self::ReceiverOrder => write!(f, "unified address receivers not in typecode order"), + Self::BadReceiverLength => write!(f, "receiver has an unexpected byte length"), + Self::NoShieldedReceiver => write!( + f, + "unified address has no shielded receiver (transparent-only UAs are invalid per ZIP-316)" + ), + Self::BadTransparentAddress(e) => write!(f, "invalid transparent Zcash address: {e}"), + } + } +} + +crate::impl_wasm_error_code!(UnifiedAddressError); + +/// ZIP-316 receiver typecodes. +const TYPECODE_P2PKH: u64 = 0x00; +const TYPECODE_P2SH: u64 = 0x01; +const TYPECODE_SAPLING: u64 = 0x02; +/// Orchard receiver typecode; Ironwood reuses it. +const TYPECODE_ORCHARD: u64 = 0x03; + +/// Expected raw length of a Sapling or Orchard/Ironwood receiver (11-byte +/// diversifier + 32-byte pk_d). +const SHIELDED_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 `VALID_LENGTH`). +const F4JUMBLE_MIN_LEN: usize = 48; +/// Maximum valid F4Jumbled message length (ZIP-316 `VALID_LENGTH`). +const F4JUMBLE_MAX_LEN: usize = 4_194_368; +/// 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<(), UnifiedAddressError> { + if !(F4JUMBLE_MIN_LEN..=F4JUMBLE_MAX_LEN).contains(&msg.len()) { + return Err(UnifiedAddressError::InvalidLength); + } + 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. +/// +/// Only mainnet (`zec`/`zcash`) and testnet (`tzec`/`zcashTest`) are supported — +/// consistently across parsing and transparent-address comparison. Regtest is not +/// supported (there is no corresponding transparent base58check codec here). +fn hrp_for_network(network: &str) -> Result<&'static str, UnifiedAddressError> { + match network { + "zcash" | "zec" => Ok("u"), + "zcashTest" | "tzec" => Ok("utest"), + _ => Err(UnifiedAddressError::UnknownNetwork(network.to_string())), + } +} + +/// 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, UnifiedAddressError> { + let checked = CheckedHrpstring::new::(address) + .map_err(|e| UnifiedAddressError::BadBech32(e.to_string()))?; + let hrp = checked.hrp(); + if hrp.as_str() != expected_hrp { + return Err(UnifiedAddressError::WrongHrp); + } + + 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(UnifiedAddressError::PayloadTooShort); + } + 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(UnifiedAddressError::BadPadding); + } + payload.truncate(split); + + // Parse the TLV receiver records. `VarInt::consensus_decode` rejects + // non-minimal CompactSize, so typecodes/lengths must be canonically encoded. + 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| UnifiedAddressError::BadReceiverEncoding(e.to_string()))?; + let len = VarInt::consensus_decode(&mut cursor) + .map(|v| v.0) + .map_err(|e| UnifiedAddressError::BadReceiverEncoding(e.to_string()))?; + let len = usize::try_from(len).map_err(|_| UnifiedAddressError::ReceiverLengthOverflow)?; + if cursor.len() < len { + return Err(UnifiedAddressError::TruncatedReceiver); + } + // Typecodes must be strictly ascending (ZIP-316 encoding order). + if let Some(prev) = last_typecode { + if typecode <= prev { + return Err(UnifiedAddressError::ReceiverOrder); + } + } + 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 +} + +/// 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), UnifiedAddressError> { + use crate::address::{AddressCodec, ZCASH, ZCASH_TEST}; + + let codec = match network { + "zcash" | "zec" => &ZCASH, + "zcashTest" | "tzec" => &ZCASH_TEST, + _ => return Err(UnifiedAddressError::UnknownNetwork(network.to_string())), + }; + let script = codec + .decode(address) + .map_err(|e| UnifiedAddressError::BadTransparentAddress(e.to_string()))?; + 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(UnifiedAddressError::BadTransparentAddress( + "address is neither P2PKH nor P2SH".to_string(), + )) + } +} + +/// Does `candidate` decode as a Bech32m string whose HRP is `expected_hrp`? +/// +/// Used to distinguish a unified-address candidate from a transparent one without +/// relying on a fragile string prefix. +fn looks_like_unified(candidate: &str, expected_hrp: &str) -> bool { + CheckedHrpstring::new::(candidate) + .map(|c| c.hrp().as_str() == expected_hrp) + .unwrap_or(false) +} + +/// A parsed ZIP-316 Unified Address. +/// +/// Decode once with [`UnifiedAddress::parse`], then read each component through its +/// named accessor. Absent receivers return `None`. +pub struct UnifiedAddress { + network: String, + receivers: Vec, +} + +impl UnifiedAddress { + /// Parse a Unified Address for the given network (`zec`/`zcash` or `tzec`/`zcashTest`). + /// + /// Rejects a spec-invalid UA that carries no shielded (Sapling or Orchard/Ironwood) + /// receiver — ZIP-316 forbids transparent-only (and empty) Unified Addresses. + pub fn parse(address: &str, network: &str) -> Result { + let expected_hrp = hrp_for_network(network)?; + let receivers = decode_receivers(address, expected_hrp)?; + let has_shielded = receivers + .iter() + .any(|r| r.typecode == TYPECODE_SAPLING || r.typecode == TYPECODE_ORCHARD); + if !has_shielded { + return Err(UnifiedAddressError::NoShieldedReceiver); + } + Ok(UnifiedAddress { + network: network.to_string(), + receivers, + }) + } + + fn receiver_data(&self, typecode: u64) -> Option<&[u8]> { + self.receivers + .iter() + .find(|r| r.typecode == typecode) + .map(|r| r.data.as_slice()) + } + + fn shielded_receiver(&self, typecode: u64) -> Result>, UnifiedAddressError> { + match self.receiver_data(typecode) { + None => Ok(None), + Some(data) if data.len() == SHIELDED_RECEIVER_LEN => Ok(Some(data.to_vec())), + Some(_) => Err(UnifiedAddressError::BadReceiverLength), + } + } + + fn transparent_hash(&self, typecode: u64) -> Result, UnifiedAddressError> { + match self.receiver_data(typecode) { + None => Ok(None), + Some(data) if data.len() == TRANSPARENT_HASH_LEN => Ok(Some(data)), + Some(_) => Err(UnifiedAddressError::BadReceiverLength), + } + } + + /// The Orchard/Ironwood receiver's raw 43 bytes (diversifier + `pk_d`), if present. + /// + /// Ironwood reuses the Orchard receiver, so this is the shielded receiver used to + /// construct an Ironwood output note. + pub fn orchard_receiver(&self) -> Result>, UnifiedAddressError> { + self.shielded_receiver(TYPECODE_ORCHARD) + } + + /// The Sapling receiver's raw 43 bytes (diversifier + `pk_d`), if present. + pub fn sapling_receiver(&self) -> Result>, UnifiedAddressError> { + self.shielded_receiver(TYPECODE_SAPLING) + } + + /// The transparent receiver as scriptPubKey bytes (P2PKH or P2SH), if present. + /// + /// Ready to use directly as a `TxOut.script_pubkey`. Prefers P2PKH over P2SH when + /// both are present (a UA holds at most one of each in practice). + pub fn transparent_script(&self) -> Result>, UnifiedAddressError> { + if let Some(hash) = self.transparent_hash(TYPECODE_P2PKH)? { + return Ok(Some(p2pkh_script(hash))); + } + if let Some(hash) = self.transparent_hash(TYPECODE_P2SH)? { + return Ok(Some(p2sh_script(hash))); + } + Ok(None) + } + + /// Determine whether `candidate` is a receiver of this unified address. + /// + /// `candidate` may be either another Unified Address (true iff every one of its + /// receivers is also present here) or a transparent Zcash address (true iff this + /// UA's transparent receiver matches it). `candidate` must be on the same network. + pub fn contains(&self, candidate: &str) -> Result { + let expected_hrp = hrp_for_network(&self.network)?; + let has = |typecode: u64, data: &[u8]| -> bool { + self.receivers + .iter() + .any(|r| r.typecode == typecode && r.data == data) + }; + + if looks_like_unified(candidate, expected_hrp) { + let candidate_receivers = decode_receivers(candidate, expected_hrp)?; + if candidate_receivers.is_empty() { + return Ok(false); + } + Ok(candidate_receivers.iter().all(|r| has(r.typecode, &r.data))) + } else { + let (typecode, hash) = transparent_address_to_receiver(candidate, &self.network)?; + Ok(has(typecode, &hash)) + } + } +} + +#[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); + } + + #[test] + fn f4jumble_rejects_out_of_range_length() { + assert!(f4jumble_inv(&mut [0u8; 47]).is_err()); + } + + /// 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_and_transparent_components() { + let f = ua_fixtures(); + let ua = UnifiedAddress::parse(&fx(&f, "zip316Mainnet", "unified"), "zec").unwrap(); + + let orchard = ua.orchard_receiver().unwrap().expect("orchard present"); + assert_eq!( + hex::encode(&orchard), + fx(&f, "zip316Mainnet", "orchardReceiverHex") + ); + assert_eq!(orchard.len(), SHIELDED_RECEIVER_LEN); + + let script = ua + .transparent_script() + .unwrap() + .expect("transparent present"); + let hash = hex::decode(fx(&f, "zip316Mainnet", "transparentPubkeyHashHex")).unwrap(); + assert_eq!(script, super::p2pkh_script(&hash)); + } + + #[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!(UnifiedAddress::parse(&ua, "tzec").is_err()); + } + + #[test] + fn unknown_network_is_rejected() { + let ua = fx(&ua_fixtures(), "zip316Mainnet", "unified"); + assert!(UnifiedAddress::parse(&ua, "bitcoin").is_err()); + } + + #[test] + fn corrupted_address_is_rejected() { + 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' }; + let corrupted: String = chars.into_iter().collect(); + assert!(UnifiedAddress::parse(&corrupted, "zec").is_err()); + } + + // --- Real testnet wallet vector (wallet-data/testnet-wallet-full.json) --- + + #[test] + fn resolves_wallet_components() { + let f = ua_fixtures(); + let ua = UnifiedAddress::parse(&fx(&f, "testnetWallet", "unified"), "tzec").unwrap(); + + assert_eq!( + hex::encode(ua.orchard_receiver().unwrap().unwrap()), + fx(&f, "testnetWallet", "ironwoodReceiverHex") + ); + let pkh = hex::decode(fx(&f, "testnetWallet", "transparentPubkeyHashHex")).unwrap(); + assert_eq!( + ua.transparent_script().unwrap().unwrap(), + super::p2pkh_script(&pkh) + ); + } + + #[test] + fn contains_transparent_and_self() { + let f = ua_fixtures(); + let ua_str = fx(&f, "testnetWallet", "unified"); + let ua = UnifiedAddress::parse(&ua_str, "tzec").unwrap(); + let addr = fx(&f, "testnetWallet", "transparentAddress"); + + assert!(ua.contains(&addr).unwrap()); + assert!(ua.contains(&ua_str).unwrap()); + // Sanity: the vector's transparent address round-trips to the wallet PKH. + assert_eq!( + tn_p2pkh_address(&fx(&f, "testnetWallet", "transparentPubkeyHashHex")), + addr + ); + } + + #[test] + fn foreign_transparent_address_is_not_a_component() { + let ua = + UnifiedAddress::parse(&fx(&ua_fixtures(), "testnetWallet", "unified"), "tzec").unwrap(); + // A valid testnet address whose hash is not in the UA. + let other = tn_p2pkh_address("00112233445566778899aabbccddeeff00112233"); + assert!(!ua.contains(&other).unwrap()); + } + + #[test] + fn contains_rejects_cross_network_candidate() { + let f = ua_fixtures(); + // testnet UA container, mainnet UA candidate → candidate is not a testnet UA + // and not a valid tzec transparent address → error. + let ua = UnifiedAddress::parse(&fx(&f, "testnetWallet", "unified"), "tzec").unwrap(); + let mainnet_ua = fx(&f, "zip316Mainnet", "unified"); + assert!(ua.contains(&mainnet_ua).is_err()); + } + + #[test] + fn contains_rejects_malformed_candidate() { + let ua = + UnifiedAddress::parse(&fx(&ua_fixtures(), "testnetWallet", "unified"), "tzec").unwrap(); + assert!(ua.contains("not-an-address").is_err()); + } + + #[test] + fn wrong_hrp_error_is_typed() { + // Parsing a mainnet UA as testnet yields WrongHrp — a typed variant, not a + // stringly error. + let ua = fx(&ua_fixtures(), "zip316Mainnet", "unified"); + assert!(matches!( + UnifiedAddress::parse(&ua, "tzec"), + Err(UnifiedAddressError::WrongHrp) + )); + } + + #[test] + fn errors_expose_wasm_error_codes() { + // The variant name is surfaced to JS as `err.code` via WasmUtxoError. + use crate::error::{WasmErrorCode, WasmUtxoError}; + let cases = [ + ( + UnifiedAddressError::WrongHrp, + "UnifiedAddressError.WrongHrp", + ), + ( + UnifiedAddressError::NoShieldedReceiver, + "UnifiedAddressError.NoShieldedReceiver", + ), + ( + UnifiedAddressError::UnknownNetwork("btc".into()), + "UnifiedAddressError.UnknownNetwork", + ), + ]; + for (err, expected_code) in cases { + assert_eq!(err.code(), expected_code); + // And it maps through WasmUtxoError with the same code. + assert_eq!(WasmUtxoError::from(err).code(), expected_code); + } + } +} diff --git a/packages/wasm-utxo/test/fixedScript/zcashUnifiedAddress.ts b/packages/wasm-utxo/test/fixedScript/zcashUnifiedAddress.ts new file mode 100644 index 00000000000..226ac83c46f --- /dev/null +++ b/packages/wasm-utxo/test/fixedScript/zcashUnifiedAddress.ts @@ -0,0 +1,95 @@ +import * as assert from "assert"; +import * as fs from "fs"; +import * as path from "path"; +import { fileURLToPath } from "url"; +import { ZcashUnifiedAddress } from "../../js/fixedScriptWallet/ZcashUnifiedAddress.js"; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const fixturesZcash = path.resolve(__dirname, "../fixtures/zcash"); + +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 | undefined): string | undefined { + return u === undefined ? undefined : Buffer.from(u).toString("hex"); +} + +describe("ZcashUnifiedAddress", function () { + describe("parse + component accessors", function () { + it("exposes the Orchard/Ironwood and transparent components (mainnet vector)", function () { + const ua = ZcashUnifiedAddress.parse(MAINNET.unified, MAINNET.network); + assert.strictEqual(hex(ua.orchardReceiver), MAINNET.orchardReceiverHex); + assert.strictEqual(ua.orchardReceiver?.length, 43); + assert.strictEqual( + hex(ua.transparentScript), + `76a914${MAINNET.transparentPubkeyHashHex}88ac`, + ); + }); + + it("resolves the wallet vector's components (testnet)", function () { + const ua = ZcashUnifiedAddress.parse(WALLET.unified, WALLET.network); + assert.strictEqual(hex(ua.orchardReceiver), WALLET.ironwoodReceiverHex); + assert.strictEqual(hex(ua.transparentScript), `76a914${WALLET.transparentPubkeyHashHex}88ac`); + }); + + it("rejects an address on the wrong network", function () { + assert.throws(() => ZcashUnifiedAddress.parse(MAINNET.unified, "tzec")); + }); + + it("throws a marked Error carrying a typed .code", function () { + // The wasm layer routes through WasmUtxoError, so JS receives a real Error + // with a typed .code — not a bare string. + assert.throws( + () => ZcashUnifiedAddress.parse(MAINNET.unified, "tzec"), + (err: unknown) => { + assert.ok(err instanceof Error, "should be a real Error"); + assert.strictEqual( + (err as Error & { code?: string }).code, + "UnifiedAddressError.WrongHrp", + ); + return true; + }, + ); + }); + }); + + describe("contains", function () { + it("recognizes the transparent address as a component", function () { + const ua = ZcashUnifiedAddress.parse(WALLET.unified, WALLET.network); + assert.strictEqual(ua.contains(WALLET.transparentAddress), true); + }); + + it("recognizes the unified address as a component of itself", function () { + const ua = ZcashUnifiedAddress.parse(WALLET.unified, WALLET.network); + assert.strictEqual(ua.contains(WALLET.unified), true); + }); + + it("throws on a cross-network candidate", function () { + const ua = ZcashUnifiedAddress.parse(WALLET.unified, WALLET.network); + // Mainnet UA candidate is neither a testnet UA nor a valid tzec transparent address. + assert.throws(() => ua.contains(MAINNET.unified)); + }); + + it("throws on a malformed candidate address", function () { + const ua = ZcashUnifiedAddress.parse(WALLET.unified, WALLET.network); + assert.throws(() => ua.contains("not-an-address")); + }); + }); +}); 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" + } +}