diff --git a/packages/wasm-utxo/js/address.ts b/packages/wasm-utxo/js/address.ts index 6e06a66d01e..d5a00f32e97 100644 --- a/packages/wasm-utxo/js/address.ts +++ b/packages/wasm-utxo/js/address.ts @@ -8,19 +8,11 @@ import type { CoinName } from "./coinName.js"; export type AddressFormat = "default" | "cashaddr"; /** - * @param canBeShieldedOutput - When set and `address` is a ZIP-316 unified address carrying an - * Orchard/Ironwood receiver, returns that raw 43-byte receiver instead of a transparent - * scriptPubKey (there is no scriptPubKey for a shielded output). `address` is only even - * attempted as a unified address when this is set. If `address` looks like a unified address for - * this coin's network but is malformed, or has no Orchard/Ironwood receiver (e.g. Sapling-only), - * this throws rather than falling back to the transparent path. + * Convert `address` to its scriptPubKey bytes for `coin`, decoding it as an ordinary transparent + * address. */ -export function toOutputScriptWithCoin( - address: string, - coin: CoinName, - canBeShieldedOutput?: boolean, -): Uint8Array { - return AddressNamespace.to_output_script_with_coin(address, coin, canBeShieldedOutput); +export function toOutputScriptWithCoin(address: string, coin: CoinName): Uint8Array { + return AddressNamespace.to_output_script_with_coin(address, coin); } export function fromOutputScriptWithCoin( diff --git a/packages/wasm-utxo/js/fixedScriptWallet/Dimensions.ts b/packages/wasm-utxo/js/fixedScriptWallet/Dimensions.ts index 28e69095a54..f5139a35ab8 100644 --- a/packages/wasm-utxo/js/fixedScriptWallet/Dimensions.ts +++ b/packages/wasm-utxo/js/fixedScriptWallet/Dimensions.ts @@ -17,17 +17,6 @@ export type FromInputOptions = { utxolibCompat?: boolean; }; -/** - * Options for output dimension calculation - */ -export type FromOutputOptions = { - /** - * Set when `address` may be a ZIP-316 Unified Address whose Orchard receiver should be sized - * as a shielded output. Forwarded to `toOutputScriptWithCoin`'s `canBeShieldedOutput`. - */ - isShielded?: boolean; -}; - /** * Dimensions class for estimating transaction virtual size. * @@ -37,7 +26,7 @@ export type FromOutputOptions = { * This is a thin wrapper over the WASM implementation. */ export class Dimensions { - private constructor(private _wasm: WasmDimensions) {} + protected constructor(private _wasm: WasmDimensions) {} /** * Create empty dimensions (zero weight) @@ -83,12 +72,10 @@ export class Dimensions { */ static fromOutput(script: Uint8Array): Dimensions; /** - * Create dimensions for a single output from an address. - * - * Pass `{ isShielded: true }` when `address` may be a ZIP-316 Unified Address whose Orchard - * receiver should be sized as a shielded output + * Create dimensions for a single output from an address, decoded as an ordinary transparent + * address for `network`. */ - static fromOutput(address: string, network: CoinName, options?: FromOutputOptions): Dimensions; + static fromOutput(address: string, network: CoinName): Dimensions; /** * Create dimensions for a single output from script length only */ @@ -100,13 +87,12 @@ export class Dimensions { static fromOutput( params: Uint8Array | string | { length: number } | { scriptType: OutputScriptType }, network?: CoinName, - options?: FromOutputOptions, ): Dimensions { if (typeof params === "string") { if (network === undefined) { throw new Error("network is required when passing an address string"); } - const script = toOutputScriptWithCoin(params, network, options?.isShielded); + const script = toOutputScriptWithCoin(params, network); return new Dimensions(WasmDimensions.from_output_script_length(script.length)); } if (typeof params === "object" && "scriptType" in params) { diff --git a/packages/wasm-utxo/js/fixedScriptWallet/ZcashDimensions.ts b/packages/wasm-utxo/js/fixedScriptWallet/ZcashDimensions.ts new file mode 100644 index 00000000000..75410b07c77 --- /dev/null +++ b/packages/wasm-utxo/js/fixedScriptWallet/ZcashDimensions.ts @@ -0,0 +1,69 @@ +import { WasmDimensions } from "../wasm/wasm_utxo.js"; +import type { CoinName } from "../coinName.js"; +import type { OutputScriptType } from "./scriptType.js"; +import { toShieldedReceiverWithCoin, toTransparentReceiverWithCoin } from "../zcashAddress.js"; +import { Dimensions } from "./Dimensions.js"; + +/** + * Options for {@link ZcashDimensions.fromOutput}. + */ +export type ZcashFromOutputOptions = { + /** + * Set when `address` may be a ZIP-316 Unified Address whose Orchard receiver should be sized + * as a shielded output. Forwarded to `zcashAddress.toShieldedReceiverWithCoin`; when unset, + * `zcashAddress.toTransparentReceiverWithCoin` is used instead. + */ + isShielded?: boolean; +}; + +/** + * Zcash-specific dimensions: resolves ZIP-316 Unified Addresses (transparent or Orchard/Ironwood + * shielded receiver) instead of the plain transparent-only decoding {@link Dimensions.fromOutput} + * does for every other coin. + */ +export class ZcashDimensions extends Dimensions { + /** + * Create dimensions for a single output from script bytes + */ + static fromOutput(script: Uint8Array): Dimensions; + /** + * Create dimensions for a single output from a Zcash address for `network`. + * + * Pass `{ isShielded: true }` when `address` may be a ZIP-316 Unified Address whose Orchard + * receiver should be sized as a shielded output; otherwise its transparent receiver is + * resolved (a UA with no transparent receiver throws rather than falling back to Orchard). + */ + static fromOutput( + address: string, + network: CoinName, + options?: ZcashFromOutputOptions, + ): Dimensions; + /** + * Create dimensions for a single output from script length only + */ + static fromOutput(params: { length: number }): Dimensions; + /** + * Create dimensions for a single output from script type + */ + static fromOutput(params: { scriptType: OutputScriptType }): Dimensions; + static fromOutput( + params: Uint8Array | string | { length: number } | { scriptType: OutputScriptType }, + network?: CoinName, + options?: ZcashFromOutputOptions, + ): Dimensions { + if (typeof params === "string") { + if (network === undefined) { + throw new Error("network is required when passing an address string"); + } + const receiver = options?.isShielded + ? toShieldedReceiverWithCoin(params, network) + : toTransparentReceiverWithCoin(params, network); + return new ZcashDimensions(WasmDimensions.from_output_script_length(receiver.length)); + } + if (typeof params === "object" && "scriptType" in params) { + return Dimensions.fromOutput(params); + } + // Both Uint8Array and { length: number } have .length + return Dimensions.fromOutput(params); + } +} diff --git a/packages/wasm-utxo/js/fixedScriptWallet/ZcashUnifiedAddress.ts b/packages/wasm-utxo/js/fixedScriptWallet/ZcashUnifiedAddress.ts index c53c8769089..d8df00034ae 100644 --- a/packages/wasm-utxo/js/fixedScriptWallet/ZcashUnifiedAddress.ts +++ b/packages/wasm-utxo/js/fixedScriptWallet/ZcashUnifiedAddress.ts @@ -16,6 +16,8 @@ import type { ZcashNetworkName } from "./ZcashBitGoPsbt.js"; * const ua = ZcashUnifiedAddress.parse(uaString, "zec"); * const ironwood = ua.orchardReceiver; // 43 bytes, or undefined * const script = ua.transparentScript; // scriptPubKey bytes, or undefined + * ua.hasOrchardReceiver; // true iff orchardReceiver is present + * ua.hasTransparentReceiver; // true iff transparentScript is present * ua.contains(transparentAddress); // is it one of this UA's receivers? * ``` */ @@ -56,6 +58,26 @@ export class ZcashUnifiedAddress { return this._wasm.transparentScript; } + /** + * Whether this Unified Address carries an Orchard/Ironwood receiver. + * + * Equivalent to `orchardReceiver !== undefined`; prefer this when only presence + * matters, since it avoids copying the receiver bytes. + */ + get hasOrchardReceiver(): boolean { + return this._wasm.hasOrchardReceiver; + } + + /** + * Whether this Unified Address carries a transparent (P2PKH/P2SH) receiver. + * + * Equivalent to `transparentScript !== undefined`; prefer this when only presence + * matters, since it avoids copying the script bytes. + */ + get hasTransparentReceiver(): boolean { + return this._wasm.hasTransparentReceiver; + } + /** * Whether `candidate` is a receiver of this unified address. * diff --git a/packages/wasm-utxo/js/index.ts b/packages/wasm-utxo/js/index.ts index bd56d90b95e..092253b82a6 100644 --- a/packages/wasm-utxo/js/index.ts +++ b/packages/wasm-utxo/js/index.ts @@ -8,6 +8,7 @@ void wasm; // Most exports are namespaced to avoid polluting the top-level namespace // and to make imports more explicit (e.g., `import { address } from '@bitgo/wasm-utxo'`) export * as address from "./address.js"; +export * as zcashAddress from "./zcashAddress.js"; export * as ast from "./ast/index.js"; export * as bip322 from "./bip322/index.js"; export * as inscriptions from "./inscriptions.js"; @@ -21,6 +22,7 @@ export * as ecpair from "./ecpair.js"; export { ECPair } from "./ecpair.js"; export { BIP32 } from "./bip32.js"; export { Dimensions } from "./fixedScriptWallet/Dimensions.js"; +export { ZcashDimensions } from "./fixedScriptWallet/ZcashDimensions.js"; export type WasmUtxoVersionInfo = { version: string; gitHash: string }; export function getWasmUtxoVersion(): WasmUtxoVersionInfo { diff --git a/packages/wasm-utxo/js/zcashAddress.ts b/packages/wasm-utxo/js/zcashAddress.ts new file mode 100644 index 00000000000..ef7511dba9c --- /dev/null +++ b/packages/wasm-utxo/js/zcashAddress.ts @@ -0,0 +1,62 @@ +import { + zcashHasOrchardReceiver, + zcashHasTransparentReceiver, + zcashToShieldedReceiverWithCoin, + zcashToTransparentReceiverWithCoin, +} from "./wasm/wasm_utxo.js"; +import type { CoinName } from "./coinName.js"; + +/** + * Convert `address` to its scriptPubKey bytes for `coin`'s network. When `address` is a ZIP-316 + * unified address, resolves its transparent receiver -- same as any other transparent address -- + * rather than rejecting the UA string outright. A UA with no transparent receiver (e.g. + * Orchard-only) throws rather than falling back to a shielded one. + * + * `js/address.ts`'s `toOutputScriptWithCoin` dispatches here for zcash/tzec; call this directly + * only if you already know `coin` is a Zcash coin. + */ +export function toTransparentReceiverWithCoin(address: string, coin: CoinName): Uint8Array { + return zcashToTransparentReceiverWithCoin(address, coin); +} + +/** + * Resolve the Orchard/Ironwood receiver of a ZIP-316 unified address for `coin`'s network, as its + * raw 43 bytes (diversifier + `pk_d`) -- there is no scriptPubKey for a shielded output, so this + * returns raw receiver bytes rather than a scriptPubKey. + * + * `address` must be a unified address; an ordinary transparent address is rejected, since it can + * never carry a shielded receiver. A UA that has no Orchard/Ironwood receiver (e.g. Sapling-only) + * throws rather than falling back to the transparent receiver. If `address` merely looks like a + * unified address for this coin's network (right Bech32m HRP) but is malformed, this also throws. + * Use {@link toOutputScriptWithCoin} from `js/address.ts` for the transparent case. + */ +export function toShieldedReceiverWithCoin(address: string, coin: CoinName): Uint8Array { + return zcashToShieldedReceiverWithCoin(address, coin); +} + +/** + * Whether `address` is a ZIP-316 unified address for `coin`'s network carrying an + * Orchard/Ironwood receiver. + * + * A plain membership check, not a decoder: never throws. Returns `false` for a malformed or + * wrong-network unified address, an ordinary (non-UA) address, or a UA with no Orchard/Ironwood + * receiver (e.g. Sapling-only). Use {@link toShieldedReceiverWithCoin} when the raw receiver bytes + * are needed. + */ +export function hasOrchardReceiver(address: string, coin: CoinName): boolean { + return zcashHasOrchardReceiver(address, coin); +} + +/** + * Whether `address` has a usable transparent receiver for `coin`'s network: either `address` is + * a ZIP-316 unified address carrying a transparent receiver, or `address` is itself an ordinary + * transparent address that decodes for `coin`. + * + * A plain membership check, not a decoder: never throws. Returns `false` for a malformed or + * wrong-network unified address, a UA with no transparent receiver (e.g. Orchard-only), or an + * address that is neither a unified address nor a valid transparent address for `coin`. Use + * {@link toOutputScriptWithCoin} from `js/address.ts` when the scriptPubKey bytes are needed. + */ +export function hasTransparentReceiver(address: string, coin: CoinName): boolean { + return zcashHasTransparentReceiver(address, coin); +} diff --git a/packages/wasm-utxo/src/address/networks.rs b/packages/wasm-utxo/src/address/networks.rs index c7cb5229c28..5b1a9401ecb 100644 --- a/packages/wasm-utxo/src/address/networks.rs +++ b/packages/wasm-utxo/src/address/networks.rs @@ -354,43 +354,6 @@ pub fn to_output_script_with_coin(address: &str, coin: &str) -> Result Result> { - if can_be_shielded_output - && crate::zcash::unified_address::looks_like_unified_for_network(address, coin) - { - let ua = crate::zcash::unified_address::UnifiedAddress::parse(address, coin) - .map_err(|e| AddressError::InvalidAddress(e.to_string()))?; - return match ua - .orchard_receiver() - .map_err(|e| AddressError::InvalidAddress(e.to_string()))? - { - Some(receiver) => Ok(receiver), - None => Err(AddressError::InvalidAddress(format!( - "unified address has no Orchard/Ironwood receiver: {address}" - ))), - }; - } - to_output_script_with_coin(address, coin).map(|script| script.to_bytes().to_vec()) -} - /// Convert an output script to an address string using a BitGo coin name. /// The coin name is first converted to a Network using `Network::from_coin_name()`. pub fn from_output_script_with_coin(script: &Script, coin: &str) -> Result { @@ -483,81 +446,6 @@ mod tests { assert!(result.is_err()); } - mod shielded_output { - use super::*; - - 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") - } - - 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() - } - - #[test] - fn returns_the_orchard_receiver_for_a_unified_address_when_set() { - let f = ua_fixtures(); - let ua = fx(&f, "zip316Mainnet", "unified"); - let expected = hex::decode(fx(&f, "zip316Mainnet", "orchardReceiverHex")).unwrap(); - - let receiver = - to_output_script_or_shielded_receiver_with_coin(&ua, "zec", true).unwrap(); - assert_eq!(receiver, expected); - assert_eq!(receiver.len(), 43); - } - - #[test] - fn never_even_attempts_ua_parsing_when_unset() { - // A unified address is never itself a valid transparent address, so without the flag - // this must fail exactly like it did before this feature existed — not succeed by - // accidentally matching some transparent codec. - let ua = fx(&ua_fixtures(), "zip316Mainnet", "unified"); - assert!(to_output_script_or_shielded_receiver_with_coin(&ua, "zec", false).is_err()); - } - - #[test] - fn falls_through_to_the_transparent_path_for_an_ordinary_address_even_when_set() { - let f = ua_fixtures(); - let addr = fx(&f, "testnetWallet", "transparentAddress"); - let expected_hash = hex::decode(fx(&f, "testnetWallet", "transparentPubkeyHashHex")) - .unwrap() - .try_into() - .unwrap(); - let expected = - ScriptBuf::new_p2pkh(&PubkeyHash::from_byte_array(expected_hash)).to_bytes(); - - let script = - to_output_script_or_shielded_receiver_with_coin(&addr, "tzec", true).unwrap(); - assert_eq!(script, expected); - } - - #[test] - fn errors_on_a_wrong_network_unified_address_rather_than_succeeding() { - // The mainnet UA's HRP ("u") doesn't match testnet's ("utest"), so - // `looks_like_unified_for_network` itself returns false — this never reaches - // `UnifiedAddress::parse`'s own (separately tested, in unified_address.rs) - // `WrongHrp` check; it falls through to the transparent path instead, which fails for - // an unrelated reason (a UA string never decodes as a transparent address). Assert on - // that specific failure rather than a bare `is_err()`, so this pins down which path - // actually rejected it — a bare `is_err()` would still pass even if the network check - // were silently removed entirely. - let ua = fx(&ua_fixtures(), "zip316Mainnet", "unified"); - let err = - to_output_script_or_shielded_receiver_with_coin(&ua, "tzec", true).unwrap_err(); - assert!( - err.to_string().contains("Could not decode address"), - "expected the transparent-decode fallback to fail; got: {err}" - ); - } - } - #[test] fn test_base58_bitcoin_cash() { // Bitcoin Cash should prefer base58 format for encoding diff --git a/packages/wasm-utxo/src/wasm/address.rs b/packages/wasm-utxo/src/wasm/address.rs index 31d7abaa7e5..da1e885dfe8 100644 --- a/packages/wasm-utxo/src/wasm/address.rs +++ b/packages/wasm-utxo/src/wasm/address.rs @@ -1,6 +1,5 @@ use crate::address::networks::{ - from_output_script_with_coin_and_format, to_output_script_or_shielded_receiver_with_coin, - AddressFormat, + from_output_script_with_coin_and_format, to_output_script_with_coin, AddressFormat, }; use miniscript::bitcoin::Script; use wasm_bindgen::prelude::*; @@ -11,22 +10,15 @@ pub struct AddressNamespace; #[wasm_bindgen] impl AddressNamespace { - /// `can_be_shielded_output`: when set and `address` is a ZIP-316 unified address carrying an - /// Orchard/Ironwood receiver, returns that raw 43-byte receiver instead of a transparent - /// scriptPubKey. See [`to_output_script_or_shielded_receiver_with_coin`] for the exact - /// fallback/error rules. + /// Convert `address` to its scriptPubKey bytes for `coin`. #[wasm_bindgen] pub fn to_output_script_with_coin( address: &str, coin: &str, - can_be_shielded_output: Option, ) -> std::result::Result, JsValue> { - to_output_script_or_shielded_receiver_with_coin( - address, - coin, - can_be_shielded_output.unwrap_or(false), - ) - .map_err(|e| JsValue::from_str(&e.to_string())) + to_output_script_with_coin(address, coin) + .map(|script| script.to_bytes().to_vec()) + .map_err(|e| JsValue::from_str(&e.to_string())) } #[wasm_bindgen] diff --git a/packages/wasm-utxo/src/wasm/zcash.rs b/packages/wasm-utxo/src/wasm/zcash.rs index 1d83c741e0c..bc4342d1e92 100644 --- a/packages/wasm-utxo/src/wasm/zcash.rs +++ b/packages/wasm-utxo/src/wasm/zcash.rs @@ -114,6 +114,57 @@ pub fn ironwood_build_witness( }) } +/// Resolve the Orchard/Ironwood receiver of a ZIP-316 unified address for `coin`'s network, as +/// its raw 43 bytes (diversifier + `pk_d`) — there is no scriptPubKey for a shielded output, so +/// this can't return script bytes uniformly and returns raw receiver bytes instead. +/// +/// A UA that has no Orchard/Ironwood receiver (e.g. Sapling-only) throws rather than falling back +/// to the transparent receiver. If `address` merely looks like a unified address for this coin's +/// network (right Bech32m HRP) but is malformed, this also throws. `address` must be a unified +/// address — an ordinary transparent address is rejected, since it can never carry a shielded +/// receiver; use {@link toOutputScriptWithCoin} in `js/address.ts` for the transparent case. +/// Resolve the transparent receiver of an `address` for `coin`'s network, as its scriptPubKey +/// bytes. When `address` is a ZIP-316 unified address, resolves its transparent receiver rather +/// than rejecting the UA string outright — a UA with no transparent receiver (e.g. Orchard-only) +/// throws rather than falling back to a shielded one. An ordinary (non-UA) address falls through +/// to the same transparent codec path as any other coin. +/// +/// This is the zcash-aware counterpart of `toOutputScriptWithCoin` in `js/address.ts`, which +/// dispatches here for zcash/tzec so that non-zcash coins never pay for a unified-address check. +#[wasm_bindgen(js_name = zcashToTransparentReceiverWithCoin)] +pub fn zcash_to_transparent_receiver_with_coin( + address: &str, + coin: &str, +) -> std::result::Result, JsValue> { + crate::zcash::address::to_output_script_or_shielded_receiver_with_coin(address, coin, false) + .map_err(|e| JsValue::from_str(&e.to_string())) +} + +#[wasm_bindgen(js_name = zcashToShieldedReceiverWithCoin)] +pub fn zcash_to_shielded_receiver_with_coin( + address: &str, + coin: &str, +) -> std::result::Result, JsValue> { + crate::zcash::address::to_output_script_or_shielded_receiver_with_coin(address, coin, true) + .map_err(|e| JsValue::from_str(&e.to_string())) +} + +/// Whether `address` is a ZIP-316 unified address for `coin`'s network carrying an +/// Orchard/Ironwood receiver. `false` (never throws) for a malformed/wrong-network unified +/// address, an ordinary address, or a UA with no Orchard/Ironwood receiver. +#[wasm_bindgen(js_name = zcashHasOrchardReceiver)] +pub fn zcash_has_orchard_receiver(address: &str, coin: &str) -> bool { + crate::zcash::address::has_orchard_receiver(address, coin) +} + +/// Whether `address` has a usable transparent receiver for `coin`'s network: either it's a +/// unified address carrying a transparent receiver, or it's itself a valid transparent address +/// for `coin`. `false` (never throws) otherwise. +#[wasm_bindgen(js_name = zcashHasTransparentReceiver)] +pub fn zcash_has_transparent_receiver(address: &str, coin: &str) -> bool { + crate::zcash::address::has_transparent_receiver(address, coin) +} + /// A parsed ZIP-316 Unified Address. /// /// Decode once with [`ZcashUnifiedAddress::parse`], then read each component through @@ -165,6 +216,24 @@ impl ZcashUnifiedAddress { self.transparent.clone() } + /// Whether this Unified Address carries an Orchard/Ironwood receiver. + /// + /// Equivalent to `orchardReceiver !== undefined`, without cloning the receiver bytes + /// when the caller only needs presence. + #[wasm_bindgen(getter, js_name = hasOrchardReceiver)] + pub fn has_orchard_receiver(&self) -> bool { + self.orchard.is_some() + } + + /// Whether this Unified Address carries a transparent (P2PKH/P2SH) receiver. + /// + /// Equivalent to `transparentScript !== undefined`, without cloning the script bytes + /// when the caller only needs presence. + #[wasm_bindgen(getter, js_name = hasTransparentReceiver)] + pub fn has_transparent_receiver(&self) -> bool { + self.transparent.is_some() + } + /// Whether `candidate` (another Unified Address, or a transparent Zcash address /// on the same network) is a receiver of this Unified Address. #[wasm_bindgen] diff --git a/packages/wasm-utxo/src/zcash/address.rs b/packages/wasm-utxo/src/zcash/address.rs new file mode 100644 index 00000000000..481fd8cb0b3 --- /dev/null +++ b/packages/wasm-utxo/src/zcash/address.rs @@ -0,0 +1,308 @@ +//! Zcash-specific address resolution: ZIP-316 unified addresses layered on top of the general +//! network/coin address codecs in [`crate::address::networks`]. + +use crate::address::networks::to_output_script_with_coin; +use crate::address::AddressError; + +type Result = std::result::Result; + +/// Like [`to_output_script_with_coin`], but when `address` is a ZIP-316 unified address for +/// `coin`'s network, resolves it through the UA path instead of erroring outright (a UA string +/// is never itself a valid transparent address). +/// +/// `resolve_shielded` picks which of the UA's receivers is authoritative: +/// - `true`: only the Orchard/Ironwood receiver is resolved, returned as its raw 43 bytes +/// (diversifier + `pk_d`) — there is no scriptPubKey for a shielded output, so this can't +/// return a `ScriptBuf` uniformly and returns raw bytes instead. A UA with no Orchard/Ironwood +/// receiver (e.g. Sapling-only) errors rather than silently falling back to transparent. +/// - `false`: only the transparent receiver is resolved, as its scriptPubKey bytes. A UA with no +/// transparent receiver errors rather than silently falling back to Orchard/Ironwood. +/// +/// A UA that can't yield the receiver the caller asked for is a caller bug, not an alternate +/// valid address, so this always errors rather than trying the other receiver kind. +/// +/// If `address` merely *looks* like a UA for this network (right Bech32m HRP) but is malformed, +/// this also errors rather than falling back to the transparent path. +pub fn to_output_script_or_shielded_receiver_with_coin( + address: &str, + coin: &str, + resolve_shielded: bool, +) -> Result> { + if crate::zcash::unified_address::looks_like_unified_for_network(address, coin) { + let ua = crate::zcash::unified_address::UnifiedAddress::parse(address, coin) + .map_err(|e| AddressError::InvalidAddress(e.to_string()))?; + return if resolve_shielded { + match ua + .orchard_receiver() + .map_err(|e| AddressError::InvalidAddress(e.to_string()))? + { + Some(receiver) => Ok(receiver), + None => Err(AddressError::InvalidAddress(format!( + "unified address has no Orchard/Ironwood receiver: {address}" + ))), + } + } else { + match ua + .transparent_script() + .map_err(|e| AddressError::InvalidAddress(e.to_string()))? + { + Some(script) => Ok(script), + None => Err(AddressError::InvalidAddress(format!( + "unified address has no transparent receiver: {address}" + ))), + } + }; + } + to_output_script_with_coin(address, coin).map(|script| script.to_bytes().to_vec()) +} + +/// Whether `address` is a ZIP-316 unified address for `coin`'s network carrying an +/// Orchard/Ironwood receiver. +/// +/// A plain membership check, not a decoder: returns `false` (rather than erroring) for a +/// malformed unified address, one on the wrong network, an ordinary (non-UA) address, or a UA +/// with no Orchard/Ironwood receiver (e.g. Sapling-only). Use +/// [`to_output_script_or_shielded_receiver_with_coin`] when the actual receiver bytes are +/// needed, or when a malformed UA should surface as an error instead of `false`. +pub fn has_orchard_receiver(address: &str, coin: &str) -> bool { + if !crate::zcash::unified_address::looks_like_unified_for_network(address, coin) { + return false; + } + crate::zcash::unified_address::UnifiedAddress::parse(address, coin) + .ok() + .and_then(|ua| ua.orchard_receiver().ok()) + .flatten() + .is_some() +} + +/// Whether `address` has a usable transparent receiver for `coin`'s network: either `address` +/// is a ZIP-316 unified address carrying a transparent receiver, or `address` is itself an +/// ordinary transparent address that decodes for `coin`. +/// +/// A plain membership check, not a decoder: returns `false` (rather than erroring) for a +/// malformed unified address, one on the wrong network, a UA with no transparent receiver +/// (e.g. Orchard-only), or an address that is neither a unified address nor a valid transparent +/// address for `coin`. Use [`to_output_script_or_shielded_receiver_with_coin`] / +/// [`to_output_script_with_coin`] when the actual scriptPubKey is needed, or when a malformed +/// address should surface as an error instead of `false`. +pub fn has_transparent_receiver(address: &str, coin: &str) -> bool { + if crate::zcash::unified_address::looks_like_unified_for_network(address, coin) { + return crate::zcash::unified_address::UnifiedAddress::parse(address, coin) + .ok() + .and_then(|ua| ua.transparent_script().ok()) + .flatten() + .is_some(); + } + to_output_script_with_coin(address, coin).is_ok() +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::bitcoin::hashes::Hash; + use crate::bitcoin::{PubkeyHash, ScriptBuf}; + + mod shielded_output { + use super::*; + + 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") + } + + 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() + } + + #[test] + fn resolve_shielded_true_returns_the_orchard_receiver_for_a_unified_address() { + let f = ua_fixtures(); + let ua = fx(&f, "zip316Mainnet", "unified"); + let expected = hex::decode(fx(&f, "zip316Mainnet", "orchardReceiverHex")).unwrap(); + + let receiver = + to_output_script_or_shielded_receiver_with_coin(&ua, "zec", true).unwrap(); + assert_eq!(receiver, expected); + assert_eq!(receiver.len(), 43); + } + + #[test] + fn resolve_shielded_false_returns_the_transparent_receiver_for_a_unified_address() { + // A unified address is now always attempted as one, regardless of `resolve_shielded` + // -- the flag only picks which of its receivers is authoritative. `false` resolves + // the UA's transparent receiver rather than erroring outright. + let f = ua_fixtures(); + let ua = fx(&f, "zip316Mainnet", "unified"); + let expected_hash = + hex::decode(fx(&f, "zip316Mainnet", "transparentPubkeyHashHex")).unwrap(); + let expected = ScriptBuf::new_p2pkh(&PubkeyHash::from_byte_array( + expected_hash.try_into().unwrap(), + )) + .to_bytes(); + + let script = + to_output_script_or_shielded_receiver_with_coin(&ua, "zec", false).unwrap(); + assert_eq!(script, expected); + } + + #[test] + fn resolve_shielded_false_errors_on_a_unified_address_with_no_transparent_receiver() { + // An orchard-only UA has no transparent receiver, so `resolve_shielded: false` must + // error rather than silently returning the Orchard receiver instead. + let f = ua_fixtures(); + let receiver: [u8; 43] = hex::decode(fx(&f, "zip316Mainnet", "orchardReceiverHex")) + .unwrap() + .try_into() + .unwrap(); + let orchard_only = + crate::zcash::unified_address::encode_orchard_receiver(&receiver, "zec").unwrap(); + + let err = to_output_script_or_shielded_receiver_with_coin(&orchard_only, "zec", false) + .unwrap_err(); + assert!( + err.to_string().contains("no transparent receiver"), + "expected a transparent-receiver-missing error; got: {err}" + ); + } + + #[test] + fn falls_through_to_the_transparent_path_for_an_ordinary_address_regardless_of_resolve_shielded( + ) { + let f = ua_fixtures(); + let addr = fx(&f, "testnetWallet", "transparentAddress"); + let expected_hash = hex::decode(fx(&f, "testnetWallet", "transparentPubkeyHashHex")) + .unwrap() + .try_into() + .unwrap(); + let expected = + ScriptBuf::new_p2pkh(&PubkeyHash::from_byte_array(expected_hash)).to_bytes(); + + for resolve_shielded in [true, false] { + let script = to_output_script_or_shielded_receiver_with_coin( + &addr, + "tzec", + resolve_shielded, + ) + .unwrap(); + assert_eq!(script, expected); + } + } + + #[test] + fn errors_on_a_wrong_network_unified_address_rather_than_succeeding() { + // The mainnet UA's HRP ("u") doesn't match testnet's ("utest"), so + // `looks_like_unified_for_network` itself returns false — this never reaches + // `UnifiedAddress::parse`'s own (separately tested, in unified_address.rs) + // `WrongHrp` check; it falls through to the transparent path instead, which fails for + // an unrelated reason (a UA string never decodes as a transparent address). Assert on + // that specific failure rather than a bare `is_err()`, so this pins down which path + // actually rejected it — a bare `is_err()` would still pass even if the network check + // were silently removed entirely. + let ua = fx(&ua_fixtures(), "zip316Mainnet", "unified"); + let err = + to_output_script_or_shielded_receiver_with_coin(&ua, "tzec", true).unwrap_err(); + assert!( + err.to_string().contains("Could not decode address"), + "expected the transparent-decode fallback to fail; got: {err}" + ); + } + } + + mod receiver_presence { + use super::*; + + 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") + } + + 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() + } + + #[test] + fn has_orchard_receiver_is_true_for_a_ua_with_an_orchard_receiver() { + let ua = fx(&ua_fixtures(), "zip316Mainnet", "unified"); + assert!(has_orchard_receiver(&ua, "zec")); + } + + #[test] + fn has_orchard_receiver_is_false_for_an_ordinary_transparent_address() { + let f = ua_fixtures(); + let addr = fx(&f, "testnetWallet", "transparentAddress"); + assert!(!has_orchard_receiver(&addr, "tzec")); + } + + #[test] + fn has_orchard_receiver_is_false_for_a_wrong_network_unified_address() { + let ua = fx(&ua_fixtures(), "zip316Mainnet", "unified"); + assert!(!has_orchard_receiver(&ua, "tzec")); + } + + #[test] + fn has_orchard_receiver_is_false_for_garbage_input() { + assert!(!has_orchard_receiver("not an address", "zec")); + assert!(!has_orchard_receiver("", "zec")); + } + + #[test] + fn has_transparent_receiver_is_true_for_a_ua_with_a_transparent_receiver() { + let ua = fx(&ua_fixtures(), "zip316Mainnet", "unified"); + assert!(has_transparent_receiver(&ua, "zec")); + } + + #[test] + fn has_transparent_receiver_is_false_for_an_orchard_only_ua() { + let f = ua_fixtures(); + let receiver: [u8; 43] = hex::decode(fx(&f, "zip316Mainnet", "orchardReceiverHex")) + .unwrap() + .try_into() + .unwrap(); + let orchard_only = + crate::zcash::unified_address::encode_orchard_receiver(&receiver, "zec").unwrap(); + assert!(!has_transparent_receiver(&orchard_only, "zec")); + } + + #[test] + fn has_transparent_receiver_is_true_for_an_ordinary_transparent_address() { + let f = ua_fixtures(); + let addr = fx(&f, "testnetWallet", "transparentAddress"); + assert!(has_transparent_receiver(&addr, "tzec")); + } + + #[test] + fn has_transparent_receiver_is_false_for_a_malformed_address() { + assert!(!has_transparent_receiver("not an address", "tzec")); + assert!(!has_transparent_receiver("", "tzec")); + } + + #[test] + fn has_transparent_receiver_is_false_for_a_wrong_network_unified_address() { + // Mainnet UA's HRP doesn't match testnet's, so this never reaches the UA parser at + // all -- it falls through to the transparent path, which also fails (a UA string + // never decodes as a transparent address). + let ua = fx(&ua_fixtures(), "zip316Mainnet", "unified"); + assert!(!has_transparent_receiver(&ua, "tzec")); + } + + #[test] + fn has_transparent_receiver_is_true_for_a_non_zcash_coin_address() { + assert!(has_transparent_receiver( + "1A1zP1eP5QGefi2DMPTfTL5SLmv7DivfNa", + "btc" + )); + } + } +} diff --git a/packages/wasm-utxo/src/zcash/mod.rs b/packages/wasm-utxo/src/zcash/mod.rs index 1c5572c9077..806dc3209e7 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 address; pub mod blake2b; /// orchard PCZT ↔ v6 IronwoodBundle bridge (Constructor / Signer / Extractor roles). pub mod ironwood_build; diff --git a/packages/wasm-utxo/src/zcash/unified_address.rs b/packages/wasm-utxo/src/zcash/unified_address.rs index 976feffcccb..b6eccbebf3d 100644 --- a/packages/wasm-utxo/src/zcash/unified_address.rs +++ b/packages/wasm-utxo/src/zcash/unified_address.rs @@ -362,7 +362,7 @@ fn looks_like_unified(candidate: &str, expected_hrp: &str) -> bool { /// HRP)? `false` for an unknown network name, same as any other non-match. /// /// For a caller that needs to route between "parse as a unified address" and "parse as a -/// transparent address" (e.g. [`crate::address::networks::to_output_script_or_shielded_receiver_with_coin`]): +/// transparent address" (e.g. [`crate::zcash::address::to_output_script_or_shielded_receiver_with_coin`]): /// this only sniffs the HRP, so it can't itself distinguish a well-formed UA from a malformed /// one — callers that get `true` should still handle [`UnifiedAddress::parse`] failing. pub fn looks_like_unified_for_network(candidate: &str, network: &str) -> bool { diff --git a/packages/wasm-utxo/test/address/receiverPresence.ts b/packages/wasm-utxo/test/address/receiverPresence.ts new file mode 100644 index 00000000000..50631c2046a --- /dev/null +++ b/packages/wasm-utxo/test/address/receiverPresence.ts @@ -0,0 +1,90 @@ +import * as assert from "node:assert"; +import * as fs from "node:fs"; +import * as path from "node:path"; +import { fileURLToPath } from "node:url"; + +import { zcashAddress } from "../../js/index.js"; +import { ZcashUnifiedAddress } from "../../js/fixedScriptWallet/ZcashUnifiedAddress.js"; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const fixturesZcash = path.resolve(__dirname, "../fixtures/zcash"); + +type UaVector = { + network: "zec" | "tzec"; + unified: string; + transparentAddress?: string; + orchardReceiverHex?: string; + ironwoodReceiverHex?: string; + transparentPubkeyHashHex: string; +}; + +const uaFixtures = JSON.parse( + fs.readFileSync(path.join(fixturesZcash, "unified_address.json"), "utf8"), +) as { + zip316Mainnet: UaVector; + testnetWallet: UaVector; +}; +const MAINNET = uaFixtures.zip316Mainnet; +const WALLET = uaFixtures.testnetWallet; + +const ZEC = "zec"; +const TZEC = "tzec"; + +// An Orchard-only unified address (no transparent receiver), derived from the mainnet fixture's +// Orchard receiver. +const ORCHARD_ONLY_UA = ZcashUnifiedAddress.encodeOrchardReceiver( + Buffer.from(MAINNET.orchardReceiverHex, "hex"), + ZEC, +); + +describe("zcashAddress.hasOrchardReceiver", function () { + it("is true for a unified address with an Orchard/Ironwood receiver", function () { + assert.strictEqual(zcashAddress.hasOrchardReceiver(MAINNET.unified, ZEC), true); + assert.strictEqual(zcashAddress.hasOrchardReceiver(WALLET.unified, TZEC), true); + }); + + it("is true for an Orchard-only unified address", function () { + assert.strictEqual(zcashAddress.hasOrchardReceiver(ORCHARD_ONLY_UA, ZEC), true); + }); + + it("is false for an ordinary (non-UA) transparent address", function () { + assert.strictEqual(zcashAddress.hasOrchardReceiver(WALLET.transparentAddress, TZEC), false); + }); + + it("is false for a wrong-network unified address", function () { + assert.strictEqual(zcashAddress.hasOrchardReceiver(MAINNET.unified, TZEC), false); + }); + + it("is false for garbage input, and never throws", function () { + assert.strictEqual(zcashAddress.hasOrchardReceiver("not an address", ZEC), false); + assert.strictEqual(zcashAddress.hasOrchardReceiver("", ZEC), false); + }); +}); + +describe("zcashAddress.hasTransparentReceiver", function () { + it("is true for a unified address with a transparent receiver", function () { + assert.strictEqual(zcashAddress.hasTransparentReceiver(MAINNET.unified, ZEC), true); + assert.strictEqual(zcashAddress.hasTransparentReceiver(WALLET.unified, TZEC), true); + }); + + it("is false for an Orchard-only unified address", function () { + assert.strictEqual(zcashAddress.hasTransparentReceiver(ORCHARD_ONLY_UA, ZEC), false); + }); + + it("is true for an ordinary transparent address that decodes for the coin", function () { + assert.strictEqual(zcashAddress.hasTransparentReceiver(WALLET.transparentAddress, TZEC), true); + assert.strictEqual( + zcashAddress.hasTransparentReceiver("1A1zP1eP5QGefi2DMPTfTL5SLmv7DivfNa", "btc"), + true, + ); + }); + + it("is false for a wrong-network unified address", function () { + assert.strictEqual(zcashAddress.hasTransparentReceiver(MAINNET.unified, TZEC), false); + }); + + it("is false for garbage input, and never throws", function () { + assert.strictEqual(zcashAddress.hasTransparentReceiver("not an address", ZEC), false); + assert.strictEqual(zcashAddress.hasTransparentReceiver("", ZEC), false); + }); +}); diff --git a/packages/wasm-utxo/test/address/toOutputScriptWithCoinShielded.ts b/packages/wasm-utxo/test/address/toOutputScriptWithCoinShielded.ts index 7f09bb9930b..c62fffd3c97 100644 --- a/packages/wasm-utxo/test/address/toOutputScriptWithCoinShielded.ts +++ b/packages/wasm-utxo/test/address/toOutputScriptWithCoinShielded.ts @@ -3,7 +3,7 @@ import * as fs from "node:fs"; import * as path from "node:path"; import { fileURLToPath } from "node:url"; -import { address as addressNs } from "../../js/index.js"; +import { address as addressNs, zcashAddress } from "../../js/index.js"; const __dirname = path.dirname(fileURLToPath(import.meta.url)); const fixturesZcash = path.resolve(__dirname, "../fixtures/zcash"); @@ -31,44 +31,72 @@ const WALLET = uaFixtures.testnetWallet; const ZEC = "zec"; const TZEC = "tzec"; -describe("toOutputScriptWithCoin canBeShieldedOutput", function () { - it("returns the raw Orchard/Ironwood receiver for a unified address, when set (mainnet)", function () { - const script = addressNs.toOutputScriptWithCoin(MAINNET.unified, ZEC, true); +describe("zcashAddress.toShieldedReceiverWithCoin", function () { + it("returns the raw Orchard/Ironwood receiver for a unified address (mainnet)", function () { + const script = zcashAddress.toShieldedReceiverWithCoin(MAINNET.unified, ZEC); assert.strictEqual(Buffer.from(script).toString("hex"), MAINNET.orchardReceiverHex); assert.strictEqual(script.length, 43); }); - it("returns the raw Orchard/Ironwood receiver for a unified address, when set (testnet)", function () { - const script = addressNs.toOutputScriptWithCoin(WALLET.unified, TZEC, true); + it("returns the raw Orchard/Ironwood receiver for a unified address (testnet)", function () { + const script = zcashAddress.toShieldedReceiverWithCoin(WALLET.unified, TZEC); assert.strictEqual(Buffer.from(script).toString("hex"), WALLET.ironwoodReceiverHex); }); - it("still resolves a unified address's transparent script when canBeShieldedOutput is unset", function () { - // Without the flag, a UA is never even attempted as one — it falls through to the same - // transparent-only path as before this feature existed. A UA string is never itself a valid - // transparent address, so this must fail exactly like it always did. - assert.throws(() => addressNs.toOutputScriptWithCoin(MAINNET.unified, ZEC)); - assert.throws(() => addressNs.toOutputScriptWithCoin(MAINNET.unified, ZEC, false)); + it("throws for a wrong-network unified address rather than silently succeeding", function () { + // MAINNET.unified has the "u" HRP; asking for "tzec" (expects "utest") means the HRP sniff + // itself returns false, so this never reaches the UA parser's own (separately tested) + // network check — it falls through to the transparent path, which fails for an unrelated + // reason (a UA string never decodes as a transparent address). Assert on that specific + // failure rather than a bare `throws()`, so this pins down which path actually rejected it — + // a bare `throws()` would still pass even if the network check were silently removed. + assert.throws( + () => zcashAddress.toShieldedReceiverWithCoin(MAINNET.unified, TZEC), + /Could not decode address/, + ); + }); +}); + +describe("zcashAddress.toTransparentReceiverWithCoin", function () { + it("returns the unified address's transparent receiver", function () { + // A unified address is always attempted as one; the transparent receiver is always the + // authoritative one for this function -- never the shielded receiver. See + // `zcashAddress.toShieldedReceiverWithCoin` for resolving the shielded receiver instead. + const expected = `76a914${MAINNET.transparentPubkeyHashHex}88ac`; + assert.strictEqual( + Buffer.from(zcashAddress.toTransparentReceiverWithCoin(MAINNET.unified, ZEC)).toString("hex"), + expected, + ); }); - it("falls through to the transparent path for an ordinary (non-UA) address, even when set", function () { - const script = addressNs.toOutputScriptWithCoin(WALLET.transparentAddress, TZEC, true); + it("falls through to the transparent path for an ordinary (non-UA) address", function () { + const script = zcashAddress.toTransparentReceiverWithCoin( + WALLET.transparentAddress ?? "", + TZEC, + ); assert.strictEqual( Buffer.from(script).toString("hex"), `76a914${WALLET.transparentPubkeyHashHex}88ac`, ); }); +}); - it("throws for a wrong-network unified address rather than silently succeeding", function () { - // MAINNET.unified has the "u" HRP; asking for "tzec" (expects "utest") means the HRP sniff - // itself returns false, so this never reaches the UA parser's own (separately tested) - // network check — it falls through to the transparent path, which fails for an unrelated - // reason (a UA string never decodes as a transparent address). Assert on that specific - // failure rather than a bare `throws()`, so this pins down which path actually rejected it — - // a bare `throws()` would still pass even if the network check were silently removed. +describe("address.toOutputScriptWithCoin", function () { + it("does not resolve a unified address -- it is not a valid transparent address", function () { + // The general-purpose function never attempts unified-address resolution; a UA string is + // just as invalid to it as any other malformed address. See + // `zcashAddress.toTransparentReceiverWithCoin` for resolving a UA's transparent receiver. assert.throws( - () => addressNs.toOutputScriptWithCoin(MAINNET.unified, TZEC, true), + () => addressNs.toOutputScriptWithCoin(MAINNET.unified, ZEC), /Could not decode address/, ); }); + + it("decodes an ordinary (non-UA) transparent address", function () { + const script = addressNs.toOutputScriptWithCoin(WALLET.transparentAddress ?? "", TZEC); + assert.strictEqual( + Buffer.from(script).toString("hex"), + `76a914${WALLET.transparentPubkeyHashHex}88ac`, + ); + }); }); diff --git a/packages/wasm-utxo/test/dimensions.ts b/packages/wasm-utxo/test/dimensions.ts index ef36b450e74..63403b3a5b5 100644 --- a/packages/wasm-utxo/test/dimensions.ts +++ b/packages/wasm-utxo/test/dimensions.ts @@ -1,5 +1,12 @@ import assert from "node:assert"; -import { Dimensions, Descriptor, ECPair, fixedScriptWallet, Psbt } from "../js/index.js"; +import { + Dimensions, + ZcashDimensions, + Descriptor, + ECPair, + fixedScriptWallet, + Psbt, +} from "../js/index.js"; import { formatNode } from "../js/ast/index.js"; import { Transaction } from "../js/transaction.js"; import { @@ -248,8 +255,9 @@ describe("Dimensions", function () { // its recipient is a ZIP-316 Unified Address, not a transparent address — but rather than // modeling the Orchard action's real on-chain byte layout, it's run through the same // length-based formula as a transparent output, sized from the UA's decoded 43-byte - // receiver. `isShielded: true` forwards to `toOutputScriptWithCoin`'s `canBeShieldedOutput`, - // which is what lets a UA decode at all instead of failing as an invalid transparent address. + // receiver. `ZcashDimensions.fromOutput`'s `isShielded: true` routes through + // `zcashAddress.toShieldedReceiverWithCoin` instead of `toTransparentReceiverWithCoin`, which + // is what selects the UA's Orchard receiver instead of its transparent receiver. describe("Zcash Orchard/Ironwood shielded output", function () { const ORCHARD_RECEIVER_SIZE = 43; // A valid raw Orchard/Ironwood receiver (43 bytes), encoded as a single-receiver UA. @@ -260,7 +268,9 @@ describe("Dimensions", function () { const UNIFIED_ADDRESS = ZcashUnifiedAddress.encodeOrchardReceiver(RECEIVER, "zcashTest"); it("sizes a shielded output the same as a 43-byte scriptPubKey", function () { - const shieldedOutput = Dimensions.fromOutput(UNIFIED_ADDRESS, "tzec", { isShielded: true }); + const shieldedOutput = ZcashDimensions.fromOutput(UNIFIED_ADDRESS, "tzec", { + isShielded: true, + }); // Output weight = 4 * (8 + 1 + 43) = 208 assert.strictEqual(shieldedOutput.getOutputWeight(), 208); @@ -270,14 +280,20 @@ describe("Dimensions", function () { ); }); - it("throws for a UA without isShielded (not a valid transparent address)", function () { - assert.throws(() => Dimensions.fromOutput(UNIFIED_ADDRESS, "tzec")); + it("throws for an orchard-only UA when isShielded is unset (no transparent receiver)", function () { + // Without `isShielded`, `zcashAddress.toTransparentReceiverWithCoin` is used, so this UA + // -- which carries only an Orchard receiver -- is resolved for its (absent) transparent + // receiver and throws, rather than silently falling back to the Orchard receiver. + assert.throws( + () => ZcashDimensions.fromOutput(UNIFIED_ADDRESS, "tzec"), + /no transparent receiver/, + ); }); it("still decodes an ordinary transparent zcash address with isShielded: true", function () { // A zcash testnet p2sh address -> ordinary 23-byte scriptPubKey const transparentAddress = "t288NZMrzYi6oednBEnw8UZvGoqX4Z6NXys"; - const dim = Dimensions.fromOutput(transparentAddress, "tzec", { isShielded: true }); + const dim = ZcashDimensions.fromOutput(transparentAddress, "tzec", { isShielded: true }); assert.strictEqual( dim.getOutputWeight(), @@ -286,12 +302,16 @@ describe("Dimensions", function () { }); it("does not count as segwit", function () { - const shieldedOutput = Dimensions.fromOutput(UNIFIED_ADDRESS, "tzec", { isShielded: true }); + const shieldedOutput = ZcashDimensions.fromOutput(UNIFIED_ADDRESS, "tzec", { + isShielded: true, + }); assert.strictEqual(shieldedOutput.hasSegwit, false); }); it("combines with a transparent input the same way any other output would", function () { - const shieldedOutput = Dimensions.fromOutput(UNIFIED_ADDRESS, "tzec", { isShielded: true }); + const shieldedOutput = ZcashDimensions.fromOutput(UNIFIED_ADDRESS, "tzec", { + isShielded: true, + }); const transparentInput = Dimensions.fromInput({ chain: 0 }); const combined = transparentInput.plus(shieldedOutput); @@ -315,7 +335,9 @@ describe("Dimensions", function () { // default relay fee rates used elsewhere for legacy fee estimation. const FEE_RATE_ZAT_PER_KVB = 150_000; - const shieldedOutput = Dimensions.fromOutput(UNIFIED_ADDRESS, "tzec", { isShielded: true }); + const shieldedOutput = ZcashDimensions.fromOutput(UNIFIED_ADDRESS, "tzec", { + isShielded: true, + }); const legacyFee = (shieldedOutput.getOutputVSize() * FEE_RATE_ZAT_PER_KVB) / 1000; assert.ok( diff --git a/packages/wasm-utxo/test/fixedScript/zcashTransparentUnifiedAddress.ts b/packages/wasm-utxo/test/fixedScript/zcashTransparentUnifiedAddress.ts index 2c19e9e8b7d..b1ec0be9dd5 100644 --- a/packages/wasm-utxo/test/fixedScript/zcashTransparentUnifiedAddress.ts +++ b/packages/wasm-utxo/test/fixedScript/zcashTransparentUnifiedAddress.ts @@ -103,6 +103,28 @@ describe("ZcashBitGoPsbt.addTransparentOutput (legacy v4 unified_address)", func assert.strictEqual(outputs[index].address, WALLET.transparentAddress); }); + it("round-trips two recipients: a UA-resolved transparent output and a plain t-address output", function () { + const psbt = buildLegacyPsbt(); + + const uaIndex = psbt.addTransparentOutput(TRANSPARENT_SCRIPT, 50_000_000n, WALLET.unified); + + const PLAIN_TADDR = "tmYXBYJj1K7vhejSec5osXK2QsGa5MTisUQ"; + const plainScript = addressNs.toOutputScriptWithCoin(PLAIN_TADDR, "tzec"); + const plainIndex = psbt.addTransparentOutput(plainScript, 40_000_000n); + + const bytes = psbt.serialize(); + const round = ZcashBitGoPsbt.fromBytes(bytes, "zcashTest"); + + const outputs = round.parseOutputsWithWalletKeys(walletKeys); + assert.strictEqual(outputs.length, 2); + // UA recipient: address comes back as the original unified address, not the bare t-address. + assert.strictEqual(outputs[uaIndex].address, WALLET.unified); + assert.deepStrictEqual(new Uint8Array(outputs[uaIndex].script), TRANSPARENT_SCRIPT); + // Plain t-address recipient: address comes back as the plain transparent address. + assert.strictEqual(outputs[plainIndex].address, PLAIN_TADDR); + assert.deepStrictEqual(new Uint8Array(outputs[plainIndex].script), plainScript); + }); + describe("failure scenarios", function () { it("rejects a unifiedAddress whose transparent receiver does not match script", function () { const psbt = buildLegacyPsbt(); diff --git a/packages/wasm-utxo/test/fixedScript/zcashUnifiedAddress.ts b/packages/wasm-utxo/test/fixedScript/zcashUnifiedAddress.ts index 8d85a3d0fa5..e90cc4e7518 100644 --- a/packages/wasm-utxo/test/fixedScript/zcashUnifiedAddress.ts +++ b/packages/wasm-utxo/test/fixedScript/zcashUnifiedAddress.ts @@ -41,12 +41,26 @@ describe("ZcashUnifiedAddress", function () { hex(ua.transparentScript), `76a914${MAINNET.transparentPubkeyHashHex}88ac`, ); + assert.strictEqual(ua.hasOrchardReceiver, true); + assert.strictEqual(ua.hasTransparentReceiver, true); }); 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`); + assert.strictEqual(ua.hasOrchardReceiver, true); + assert.strictEqual(ua.hasTransparentReceiver, true); + }); + + it("hasOrchardReceiver is true and hasTransparentReceiver is false for an Orchard-only UA", function () { + const receiver = Buffer.from(MAINNET.orchardReceiverHex, "hex"); + const orchardOnlyUa = ZcashUnifiedAddress.encodeOrchardReceiver(receiver, MAINNET.network); + const ua = ZcashUnifiedAddress.parse(orchardOnlyUa, MAINNET.network); + + assert.strictEqual(ua.hasOrchardReceiver, true); + assert.strictEqual(ua.hasTransparentReceiver, false); + assert.strictEqual(ua.transparentScript, undefined); }); it("rejects an address on the wrong network", function () {