diff --git a/packages/wasm-utxo/js/address.ts b/packages/wasm-utxo/js/address.ts index 8864f7479a3..6e06a66d01e 100644 --- a/packages/wasm-utxo/js/address.ts +++ b/packages/wasm-utxo/js/address.ts @@ -7,8 +7,20 @@ import type { CoinName } from "./coinName.js"; */ export type AddressFormat = "default" | "cashaddr"; -export function toOutputScriptWithCoin(address: string, coin: CoinName): Uint8Array { - return AddressNamespace.to_output_script_with_coin(address, coin); +/** + * @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. + */ +export function toOutputScriptWithCoin( + address: string, + coin: CoinName, + canBeShieldedOutput?: boolean, +): Uint8Array { + return AddressNamespace.to_output_script_with_coin(address, coin, canBeShieldedOutput); } export function fromOutputScriptWithCoin( diff --git a/packages/wasm-utxo/js/fixedScriptWallet/BitGoPsbt.ts b/packages/wasm-utxo/js/fixedScriptWallet/BitGoPsbt.ts index 4f432f3a6bc..3b11a8ce8c0 100644 --- a/packages/wasm-utxo/js/fixedScriptWallet/BitGoPsbt.ts +++ b/packages/wasm-utxo/js/fixedScriptWallet/BitGoPsbt.ts @@ -53,6 +53,16 @@ export type ParsedOutput = { paygo: boolean; /** Full BIP32 derivation path from the wallet xpub (e.g. "0/1"). Null for external outputs. */ derivationPath: string | null; + /** + * True for a shielded (Orchard/Ironwood) output. Such an output has no `unsigned_tx` entry of + * its own — it lives in the PSBT's proprietary-map PCZT, read from its plaintext (not + * encrypted/decrypted) recipient field. `address` is a single-receiver ZIP-316 unified address + * (`u1...`/`utest1...`) encoding that receiver — a real, usable Zcash address, though not + * necessarily byte-identical to whatever multi-receiver UA the sender originally pasted in (a + * UA with a transparent/Sapling receiver too would round-trip to a different string carrying + * only the Orchard one). `script` holds the same receiver as raw 43 bytes. + */ + isShielded: boolean; }; export type ParsedTransaction = { diff --git a/packages/wasm-utxo/js/fixedScriptWallet/ZcashUnifiedAddress.ts b/packages/wasm-utxo/js/fixedScriptWallet/ZcashUnifiedAddress.ts index 453c2d5aa39..c53c8769089 100644 --- a/packages/wasm-utxo/js/fixedScriptWallet/ZcashUnifiedAddress.ts +++ b/packages/wasm-utxo/js/fixedScriptWallet/ZcashUnifiedAddress.ts @@ -68,6 +68,22 @@ export class ZcashUnifiedAddress { return this._wasm.contains(candidate); } + /** + * Encode a raw 43-byte Orchard/Ironwood receiver as a single-receiver Unified Address. + * + * Deliberately narrower than a general UA encoder: this always produces exactly one receiver, + * so it can't reproduce a multi-receiver UA (transparent + Sapling + Orchard) a sender might + * have originally pasted in — it only gives back *a* valid, usable address for the + * Orchard/Ironwood receiver itself. + * + * @param receiver - 43-byte raw Orchard/Ironwood receiver + * @param network - Zcash network name ("zcash", "zcashTest", "zec", "tzec") + * @throws If `receiver` is not 43 bytes, or `network` is unrecognized + */ + static encodeOrchardReceiver(receiver: Uint8Array, network: ZcashNetworkName): string { + return WasmZcashUnifiedAddress.encodeOrchardReceiver(receiver, network); + } + /** @internal */ get wasm(): WasmZcashUnifiedAddress { return this._wasm; diff --git a/packages/wasm-utxo/src/address/networks.rs b/packages/wasm-utxo/src/address/networks.rs index 5b1a9401ecb..c7cb5229c28 100644 --- a/packages/wasm-utxo/src/address/networks.rs +++ b/packages/wasm-utxo/src/address/networks.rs @@ -354,6 +354,43 @@ 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 { @@ -446,6 +483,81 @@ 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/fixed_script_wallet/bitgo_psbt/mod.rs b/packages/wasm-utxo/src/fixed_script_wallet/bitgo_psbt/mod.rs index 211c586a115..1a1d9b50fbb 100644 --- a/packages/wasm-utxo/src/fixed_script_wallet/bitgo_psbt/mod.rs +++ b/packages/wasm-utxo/src/fixed_script_wallet/bitgo_psbt/mod.rs @@ -158,6 +158,8 @@ pub enum ParseTransactionError { SpendAmountOverflow { index: usize }, /// Fee calculation error (outputs exceed inputs) FeeCalculation, + /// Failed to read the shielded (Ironwood) output's value + ShieldedOutput(String), } impl std::fmt::Display for ParseTransactionError { @@ -181,6 +183,9 @@ impl std::fmt::Display for ParseTransactionError { ParseTransactionError::FeeCalculation => { write!(f, "Fee calculation error: outputs exceed inputs") } + ParseTransactionError::ShieldedOutput(error) => { + write!(f, "Shielded output: {}", error) + } } } } @@ -1839,10 +1844,18 @@ impl BitGoPsbt { } BitGoPsbt::Zcash(zcash_psbt, _) => { use miniscript::bitcoin::hashes::{sha256d, Hash}; - // Compute txid from full Zcash transaction bytes - let txid_bytes = zcash_psbt - .compute_txid() - .expect("Failed to compute Zcash txid"); + // A v6 (Ironwood) PSBT's txid is the ZIP-244 digest over the transparent skeleton + // plus shielded action data — the v4/Sapling path below builds an invalid + // transaction for it (wrong wire format entirely), so it must not be used here. + let txid_bytes = if zcash_psbt.is_ironwood_v6() { + zcash_psbt + .unsigned_v6_txid() + .expect("Failed to compute v6 (Ironwood) txid") + } else { + zcash_psbt + .compute_txid() + .expect("Failed to compute Zcash txid") + }; let hash = sha256d::Hash::from_byte_array(txid_bytes); Txid::from_raw_hash(hash) } @@ -2724,6 +2737,46 @@ impl BitGoPsbt { .collect() } + /// The synthesized `ParsedOutput` for this PSBT's shielded (Ironwood) output, and its value — + /// `None` if this isn't a v6 (Ironwood) PSBT, or it is but no shielded output has been added + /// yet. Shared by `parse_transaction_with_wallet_keys` (which also folds the value into + /// `miner_fee`/`spend_amount`) and `parse_outputs_with_wallet_keys` (which only needs the + /// output entry). + /// + /// The shielded output lives in a proprietary-map PCZT rather than `unsigned_tx.output`, so + /// plain transparent-output parsing never sees it; this is how callers surface it explicitly. + fn shielded_output(&self) -> Result, ParseTransactionError> { + let BitGoPsbt::Zcash(z, _) = self else { + return Ok(None); + }; + let Some((amount, recipient)) = z + .ironwood_shielded_output_info() + .map_err(ParseTransactionError::ShieldedOutput)? + else { + return Ok(None); + }; + let address = crate::zcash::unified_address::encode_orchard_receiver( + &recipient, + self.network().to_coin_name(), + ) + .map_err(|e| ParseTransactionError::ShieldedOutput(e.to_string()))?; + Ok(Some(( + ParsedOutput { + address: Some(address), + // No scriptPubKey exists for a shielded output; the raw receiver is still + // available here (not a scriptPubKey, but the same "raw output-destination + // bytes" role this field plays for transparent outputs). + script: recipient.to_vec(), + value: amount, + script_id: None, + paygo: false, + derivation_path: None, + is_shielded: true, + }, + amount, + ))) + } + /// Calculate total input value from parsed inputs /// /// # Returns @@ -3382,7 +3435,11 @@ impl BitGoPsbt { wallet_keys: &crate::fixed_script_wallet::RootWalletKeys, paygo_pubkeys: &[secp256k1::PublicKey], ) -> Result, ParseTransactionError> { - self.parse_outputs(wallet_keys, paygo_pubkeys) + let mut outputs = self.parse_outputs(wallet_keys, paygo_pubkeys)?; + if let Some((output, _amount)) = self.shielded_output()? { + outputs.push(output); + } + Ok(outputs) } /// Parse transaction with wallet keys to identify wallet inputs/outputs and calculate metrics @@ -3405,13 +3462,31 @@ impl BitGoPsbt { // Parse inputs and outputs let parsed_inputs = self.parse_inputs(wallet_keys, replay_protection)?; - let parsed_outputs = self.parse_outputs(wallet_keys, paygo_pubkeys)?; + let mut parsed_outputs = self.parse_outputs(wallet_keys, paygo_pubkeys)?; // Calculate totals let total_input_value = Self::sum_input_values(&parsed_inputs)?; - let (total_output_value, spend_amount) = + let (mut total_output_value, mut spend_amount) = Self::sum_output_values(&psbt.unsigned_tx.output, &parsed_outputs)?; + // Fold in the shielded output, if any: it's invisible to the transparent-only parsing + // above, so without this it silently vanishes into `miner_fee` and `spend_amount` + // undercounts the send. + if let Some((output, amount)) = self.shielded_output()? { + let output_index = parsed_outputs.len(); + parsed_outputs.push(output); + total_output_value = total_output_value.checked_add(amount).ok_or( + ParseTransactionError::OutputValueOverflow { + index: output_index, + }, + )?; + spend_amount = spend_amount.checked_add(amount).ok_or( + ParseTransactionError::SpendAmountOverflow { + index: output_index, + }, + )?; + } + // Calculate miner fee let miner_fee = total_input_value .checked_sub(total_output_value) diff --git a/packages/wasm-utxo/src/fixed_script_wallet/bitgo_psbt/propkv.rs b/packages/wasm-utxo/src/fixed_script_wallet/bitgo_psbt/propkv.rs index f91a104a67a..755db075e6e 100644 --- a/packages/wasm-utxo/src/fixed_script_wallet/bitgo_psbt/propkv.rs +++ b/packages/wasm-utxo/src/fixed_script_wallet/bitgo_psbt/propkv.rs @@ -267,6 +267,10 @@ pub enum ZecV6KeySubtype { IronwoodPczt = 0x01, VersionGroupId = 0x02, ExpiryHeight = 0x03, + /// Marker set once [`take_ironwood_pczt`] has actually dropped a PCZT (i.e. extraction + /// happened, not just "no shielded output was ever added"). Persists even though the PCZT + /// itself is gone, so a later read can tell the two "no PCZT" states apart. + IronwoodExtracted = 0x04, } fn set_zec_v6( @@ -334,7 +338,24 @@ pub fn take_ironwood_pczt(psbt: &mut miniscript::bitcoin::psbt::Psbt) -> bool { subtype: ZecV6KeySubtype::IronwoodPczt as u8, key: vec![], }; - psbt.proprietary.remove(&key).is_some() + let took = psbt.proprietary.remove(&key).is_some(); + if took { + set_ironwood_extracted(psbt); + } + took +} + +/// Mark the PSBT as having had its Ironwood PCZT extracted (see [`take_ironwood_pczt`]). +/// Persists across `serialize`/`deserialize`, unlike the PCZT itself, so a later "was a shielded +/// output added and then extracted, or never added at all?" check can tell the two apart even +/// though both look identical from PCZT-presence alone. +fn set_ironwood_extracted(psbt: &mut miniscript::bitcoin::psbt::Psbt) { + set_zec_v6(psbt, ZecV6KeySubtype::IronwoodExtracted, vec![]); +} + +/// Whether the PSBT's Ironwood PCZT has been extracted (dropped by [`take_ironwood_pczt`]). +pub fn is_ironwood_extracted(psbt: &miniscript::bitcoin::psbt::Psbt) -> bool { + get_zec_v6(psbt, ZecV6KeySubtype::IronwoodExtracted).is_some() } /// Store the Zcash v6 (Ironwood) header params — `version_group_id` and `expiry_height` — under the diff --git a/packages/wasm-utxo/src/fixed_script_wallet/bitgo_psbt/psbt_wallet_output.rs b/packages/wasm-utxo/src/fixed_script_wallet/bitgo_psbt/psbt_wallet_output.rs index f9db3c62edf..8ae9dc16979 100644 --- a/packages/wasm-utxo/src/fixed_script_wallet/bitgo_psbt/psbt_wallet_output.rs +++ b/packages/wasm-utxo/src/fixed_script_wallet/bitgo_psbt/psbt_wallet_output.rs @@ -15,6 +15,10 @@ pub struct ParsedOutput { /// Full BIP32 derivation path from the wallet xpub (e.g. `[chain, index]`). /// `None` for outputs that do not belong to this wallet. pub derivation_path: Option, + /// Whether this output is a shielded (Orchard/Ironwood) output rather than a transparent one. + /// Always `false` for outputs parsed from `tx_output`/`psbt_output` — set by the caller when + /// synthesizing a `ParsedOutput` for the shielded side of a v6 (Ironwood) transaction. + pub is_shielded: bool, } impl ParsedOutput { @@ -59,6 +63,7 @@ impl ParsedOutput { script_id, paygo, derivation_path, + is_shielded: false, }) } diff --git a/packages/wasm-utxo/src/fixed_script_wallet/bitgo_psbt/zcash_psbt.rs b/packages/wasm-utxo/src/fixed_script_wallet/bitgo_psbt/zcash_psbt.rs index fe8fb616059..b9ea8f499ec 100644 --- a/packages/wasm-utxo/src/fixed_script_wallet/bitgo_psbt/zcash_psbt.rs +++ b/packages/wasm-utxo/src/fixed_script_wallet/bitgo_psbt/zcash_psbt.rs @@ -726,6 +726,75 @@ impl ZcashBitGoPsbt { .map_err(|e| e.to_string()) } + /// Distinguishes "no shielded output has ever been added" (`Ok(false)`) from "one was added + /// and then extracted via [`Self::combine_ironwood_proof`]/`mark_ironwood_extracted`" + /// (`Err`) — both look identical as bare PCZT-presence (`None` either way), but only the + /// first is safe to treat as "there is no shielded output here". Call this before trusting an + /// absent PCZT to mean the latter. + fn require_no_shielded_output_ever_added(&self) -> Result { + if super::propkv::get_ironwood_pczt(&self.psbt).is_some() { + return Ok(false); + } + if super::propkv::is_ironwood_extracted(&self.psbt) { + return Err( + "this PSBT's Ironwood PCZT has already been extracted (via combine_ironwood_proof); \ + its shielded output data is gone, not merely absent" + .to_string(), + ); + } + Ok(true) + } + + /// The value (zatoshi) and raw 43-byte recipient of the shielded Ironwood output, if one has + /// been added via [`Self::add_ironwood_output`] — `None` if no shielded output is present yet. + /// + /// The shielded side lives in a proprietary-map PCZT rather than `unsigned_tx.output`, so + /// transparent-only output parsing (`ParsedOutput`/`sum_output_values`) never sees it; this is + /// how callers surface it explicitly for spend/fee accounting. + /// + /// The recipient is readable here — unlike the on-wire `IronwoodAction` + /// ([`crate::zcash::ironwood_build::action_to_ironwood`], which only carries the *encrypted* + /// note — because the PCZT keeps the Constructor's plaintext `recipient`/`value` fields + /// in-memory for the Prover/Signer roles; nothing needs decrypting. + /// + /// Only [`Self::add_ironwood_output`]'s single-output shape (dummy spend, one real output) is + /// supported elsewhere in this file, so this always reads action 0 — and only because + /// `construct_shield_pczt` builds with `BundleType::UNPADDED`, which is guaranteed to produce + /// exactly one action. That guarantee is asserted below rather than assumed silently: if the + /// bundle type ever changes to one that pads/shuffles, a multi-action bundle's action 0 need + /// not be the real output (padding actions carry value 0 and a random recipient), so this + /// must error instead of quietly reading the wrong one. + /// + /// Errors (rather than returning `None`) if the PCZT is absent because it was already + /// extracted — see [`Self::require_no_shielded_output_ever_added`]. + pub fn ironwood_shielded_output_info( + &self, + ) -> Result, String> { + if self.require_no_shielded_output_ever_added()? { + return Ok(None); + } + // `orchard::pczt::Bundle` only exposes a mutable actions accessor; we only read from it. + let mut pczt = self.ironwood_pczt()?; + let actions = pczt.actions_mut(); + if actions.len() != 1 { + return Err(format!( + "expected exactly one Ironwood action (single-output shape), found {}", + actions.len() + )); + } + let action = &actions[0]; + let output = action.output(); + let value = output + .value() + .map(|v| v.inner()) + .ok_or_else(|| "shielded output is missing its plaintext value".to_string())?; + let recipient = output + .recipient() + .map(|address| address.to_raw_address_bytes()) + .ok_or_else(|| "shielded output is missing its plaintext recipient".to_string())?; + Ok(Some((value, recipient))) + } + /// The spent-output value (zatoshi, as i64) and scriptPubKey of every transparent input, in /// input order — the amounts/scripts ZIP-244 commits to. fn transparent_input_amounts_and_scripts( @@ -779,6 +848,24 @@ impl ZcashBitGoPsbt { Ok(crate::zcash::v6::compute_v6_txid(&tx)) } + /// The unsigned ZIP-244 txid for this v6 (Ironwood) PSBT, whatever state it's in: with a + /// shielded output present, this is [`Self::v6_txid`]; before one has been added, it is the + /// ZIP-244 txid of the transparent-only skeleton (a bundle-less v6 tx is a valid ZIP-244 + /// input — `ironwood_bundle: None`). + /// + /// Exists so callers computing a general-purpose "unsigned txid" (like + /// `PsbtAccess::unsigned_tx_id`) don't need to know whether a shielded output has been added + /// yet — unlike [`Self::v6_txid`], this never errors just because one hasn't been. + pub fn unsigned_v6_txid(&self) -> Result<[u8; 32], String> { + let bundle = if self.require_no_shielded_output_ever_added()? { + None + } else { + Some(self.ironwood_action_data()?) + }; + let tx = self.to_v6_transaction(self.psbt.unsigned_tx.clone(), bundle)?; + Ok(crate::zcash::v6::compute_v6_txid(&tx)) + } + /// ZIP-244 per-input transparent sighash for transparent input `index` (SIGHASH_ALL) — the /// message the key controlling that input must sign. pub fn v6_transparent_sighash(&self, index: usize) -> Result<[u8; 32], String> { @@ -1407,6 +1494,70 @@ mod ironwood_v6_tests { ); } + /// `unsigned_v6_txid` and `ironwood_shielded_output_info` both key off PCZT presence to decide + /// whether a shielded output exists. Once extracted, the PCZT is gone but a shielded output + /// *did* exist — treating that the same as "never added" would silently compute a wrong + /// (transparent-only) txid, and silently drop the shielded amount back into the caller's fee + /// calculation. Both must error instead. + #[test] + fn unsigned_v6_txid_and_shielded_output_info_error_after_extraction() { + let mut z = build_shield_psbt("v6_extracted_accounting"); + + // Before extraction: both see the shielded output. + assert!(z.unsigned_v6_txid().is_ok()); + assert!( + z.ironwood_shielded_output_info().unwrap().is_some(), + "shielded output present before extraction" + ); + + assert!(z.mark_ironwood_extracted(), "a PCZT was present"); + + // After extraction: neither silently falls back to "no shielded output". + let txid_err = z.unsigned_v6_txid().unwrap_err(); + assert!( + txid_err.contains("already been extracted"), + "unexpected error: {txid_err}" + ); + let info_err = z.ironwood_shielded_output_info().unwrap_err(); + assert!( + info_err.contains("already been extracted"), + "unexpected error: {info_err}" + ); + } + + /// The counterpart to the extraction case above: a v6 PSBT that never had a shielded output + /// added at all must still work — `unsigned_v6_txid` computes the transparent-only txid, and + /// `ironwood_shielded_output_info` reports `None`, neither erroring. + #[test] + fn unsigned_v6_txid_and_shielded_output_info_handle_no_shielded_output_ever_added() { + let wallet_keys = RootWalletKeys::new(get_test_wallet_keys("v6_never_shielded")); + let mut psbt = BitGoPsbt::new_zcash_v6_at_height( + Network::ZcashTestnet, + &wallet_keys, + NetworkUpgrade::Nu6_3.testnet_activation_height(), + None, + None, + ) + .unwrap(); + psbt.add_wallet_input( + Txid::from_byte_array([0x44u8; 32]), + 0, + 200_000_000, + &wallet_keys, + ScriptId { chain: 0, index: 0 }, + WalletInputOptions::default(), + ) + .unwrap(); + psbt.add_wallet_output(0, 1, 199_900_000, &wallet_keys) + .unwrap(); + let BitGoPsbt::Zcash(z, _) = psbt else { + panic!("expected Zcash PSBT"); + }; + + assert!(z.unsigned_v6_txid().is_ok()); + assert!(z.ironwood_shielded_output_info().unwrap().is_none()); + } + /// A signature from a key outside the input's redeem script is rejected at ingest, rather than /// being silently dropped at finalization. #[test] diff --git a/packages/wasm-utxo/src/wasm/address.rs b/packages/wasm-utxo/src/wasm/address.rs index 3bc0c133676..31d7abaa7e5 100644 --- a/packages/wasm-utxo/src/wasm/address.rs +++ b/packages/wasm-utxo/src/wasm/address.rs @@ -1,5 +1,6 @@ use crate::address::networks::{ - from_output_script_with_coin_and_format, to_output_script_with_coin, AddressFormat, + from_output_script_with_coin_and_format, to_output_script_or_shielded_receiver_with_coin, + AddressFormat, }; use miniscript::bitcoin::Script; use wasm_bindgen::prelude::*; @@ -10,14 +11,22 @@ 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. #[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_with_coin(address, coin) - .map(|script| script.to_bytes()) - .map_err(|e| JsValue::from_str(&e.to_string())) + 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())) } #[wasm_bindgen] diff --git a/packages/wasm-utxo/src/wasm/try_into_js_value.rs b/packages/wasm-utxo/src/wasm/try_into_js_value.rs index 63b638df3d0..1c30022489c 100644 --- a/packages/wasm-utxo/src/wasm/try_into_js_value.rs +++ b/packages/wasm-utxo/src/wasm/try_into_js_value.rs @@ -391,7 +391,8 @@ impl TryIntoJsValue for crate::fixed_script_wallet::bitgo_psbt::ParsedOutput { "value" => self.value, "scriptId" => self.script_id, "paygo" => self.paygo, - "derivationPath" => self.derivation_path.clone() + "derivationPath" => self.derivation_path.clone(), + "isShielded" => self.is_shielded ) } } diff --git a/packages/wasm-utxo/src/wasm/zcash.rs b/packages/wasm-utxo/src/wasm/zcash.rs index 8d8156c9bcf..ccdc34904dc 100644 --- a/packages/wasm-utxo/src/wasm/zcash.rs +++ b/packages/wasm-utxo/src/wasm/zcash.rs @@ -99,6 +99,29 @@ impl ZcashUnifiedAddress { pub fn contains(&self, candidate: &str) -> Result { Ok(self.inner.contains(candidate)?) } + + /// Encode a raw 43-byte Orchard/Ironwood receiver as a single-receiver Unified Address for + /// `network` ("zcash"/"zec" or "zcashTest"/"tzec"). + /// + /// Deliberately narrower than a general UA encoder: this always produces exactly one + /// receiver, so it can't reproduce a multi-receiver UA (transparent + Sapling + Orchard) a + /// sender might have originally pasted in — only give back *a* valid, usable address for the + /// Orchard/Ironwood receiver itself. + #[wasm_bindgen(js_name = encodeOrchardReceiver)] + pub fn encode_orchard_receiver( + receiver: &[u8], + network: &str, + ) -> Result { + let receiver: [u8; 43] = receiver.try_into().map_err(|_| { + WasmUtxoError::new(&format!( + "orchard receiver must be 43 bytes, got {}", + receiver.len() + )) + })?; + Ok(crate::zcash::unified_address::encode_orchard_receiver( + &receiver, network, + )?) + } } /// A parsed Zcash v6 (Ironwood / NU6.3) transaction — for inspection and txid. diff --git a/packages/wasm-utxo/src/zcash/unified_address.rs b/packages/wasm-utxo/src/zcash/unified_address.rs index 61daf7263f3..976feffcccb 100644 --- a/packages/wasm-utxo/src/zcash/unified_address.rs +++ b/packages/wasm-utxo/src/zcash/unified_address.rs @@ -24,7 +24,7 @@ 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::consensus::{Decodable, Encodable}; use miniscript::bitcoin::VarInt; /// Errors produced while parsing or inspecting a ZIP-316 Unified Address. @@ -168,6 +168,21 @@ fn f4jumble_inv(msg: &mut [u8]) -> Result<(), UnifiedAddressError> { Ok(()) } +/// Apply F4Jumble in place (the 4-round unkeyed Feistel network forwards) — the exact inverse of +/// [`f4jumble_inv`]: a Feistel network inverts by running the same per-round updates in reverse +/// round order, so this applies `g(0), h(0), g(1), h(1)`. +fn f4jumble(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); + g_round(msg, left_len, 0); + h_round(msg, left_len, 0); + g_round(msg, left_len, 1); + h_round(msg, left_len, 1); + Ok(()) +} + /// The expected Bech32m HRP for a Zcash network. /// /// Only mainnet (`zec`/`zcash`) and testnet (`tzec`/`zcashTest`) are supported — @@ -249,6 +264,42 @@ fn decode_receivers( Ok(receivers) } +/// Encode a single Orchard/Ironwood receiver as a ZIP-316 Unified Address for `network`. +/// +/// Deliberately narrower than a general UA encoder: BitGo never needs to *build* a UA carrying +/// more than one receiver (a transparent + Sapling + Orchard combo, say) — only to hand back a +/// human-readable address for a shielded output whose raw receiver it already has. A single- +/// receiver UA is a valid ZIP-316 address in its own right (receivers ⩾ 1, and typecode ordering +/// is trivially satisfied with only one), so this covers that case exactly without the added +/// receiver-merging logic a general encoder would need. +pub fn encode_orchard_receiver( + receiver: &[u8; SHIELDED_RECEIVER_LEN], + network: &str, +) -> Result { + let expected_hrp = hrp_for_network(network)?; + + // TLV receiver record: CompactSize(typecode) ‖ CompactSize(length) ‖ data. + let mut payload = Vec::with_capacity(2 + SHIELDED_RECEIVER_LEN + PADDING_LEN); + VarInt(TYPECODE_ORCHARD) + .consensus_encode(&mut payload) + .expect("Vec writes are infallible"); + VarInt(SHIELDED_RECEIVER_LEN as u64) + .consensus_encode(&mut payload) + .expect("Vec writes are infallible"); + payload.extend_from_slice(receiver); + + // Trailing 16-byte padding: the HRP, zero-extended. + let mut padding = [0u8; PADDING_LEN]; + padding[..expected_hrp.len()].copy_from_slice(expected_hrp.as_bytes()); + payload.extend_from_slice(&padding); + + f4jumble(&mut payload)?; + + let hrp = bech32::Hrp::parse(expected_hrp).expect("hrp_for_network returns a valid HRP"); + bech32::encode::(hrp, &payload) + .map_err(|e| UnifiedAddressError::BadBech32(e.to_string())) +} + /// 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 @@ -307,6 +358,20 @@ fn looks_like_unified(candidate: &str, expected_hrp: &str) -> bool { .unwrap_or(false) } +/// Does `candidate` look like a unified address for `network` (Bech32m with this network's +/// 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`]): +/// 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 { + match hrp_for_network(network) { + Ok(expected_hrp) => looks_like_unified(candidate, expected_hrp), + Err(_) => false, + } +} + /// A parsed ZIP-316 Unified Address. /// /// Decode once with [`UnifiedAddress::parse`], then read each component through its @@ -437,11 +502,96 @@ mod tests { assert_eq!(buf, F4JUMBLE_NORMAL); } + #[test] + fn f4jumble_forward_matches_vector() { + let mut buf = F4JUMBLE_NORMAL.to_vec(); + f4jumble(&mut buf).unwrap(); + assert_eq!(buf, F4JUMBLE_JUMBLED); + } + + #[test] + fn f4jumble_and_inverse_round_trip() { + let mut buf: Vec = (0..200u16).map(|i| (i % 256) as u8).collect(); + let original = buf.clone(); + f4jumble(&mut buf).unwrap(); + assert_ne!(buf, original, "jumbling actually changes the bytes"); + f4jumble_inv(&mut buf).unwrap(); + assert_eq!(buf, original); + } + #[test] fn f4jumble_rejects_out_of_range_length() { assert!(f4jumble_inv(&mut [0u8; 47]).is_err()); } + mod encode_orchard_receiver_tests { + 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") + } + + #[test] + fn round_trips_through_parse_for_both_networks() { + for (group, network) in [("zip316Mainnet", "zec"), ("testnetWallet", "tzec")] { + let f = ua_fixtures(); + let receiver_hex = f[group]["orchardReceiverHex"] + .as_str() + .or_else(|| f[group]["ironwoodReceiverHex"].as_str()) + .unwrap_or_else(|| panic!("missing orchard/ironwood receiver for {group}")); + let receiver: [u8; SHIELDED_RECEIVER_LEN] = + hex::decode(receiver_hex).unwrap().try_into().unwrap(); + + let encoded = encode_orchard_receiver(&receiver, network).unwrap(); + let parsed = UnifiedAddress::parse(&encoded, network).unwrap(); + assert_eq!( + parsed.orchard_receiver().unwrap().expect("orchard present"), + receiver.to_vec(), + "{group}: round-tripped receiver must match the original" + ); + } + } + + #[test] + fn produces_the_correct_hrp_for_each_network() { + let receiver = [0u8; SHIELDED_RECEIVER_LEN]; + assert!(encode_orchard_receiver(&receiver, "zec") + .unwrap() + .starts_with("u1")); + assert!(encode_orchard_receiver(&receiver, "tzec") + .unwrap() + .starts_with("utest1")); + // Both coin-name spellings for a network must produce byte-identical addresses. + assert_eq!( + encode_orchard_receiver(&receiver, "zcash").unwrap(), + encode_orchard_receiver(&receiver, "zec").unwrap(), + ); + assert_eq!( + encode_orchard_receiver(&receiver, "zcashTest").unwrap(), + encode_orchard_receiver(&receiver, "tzec").unwrap(), + ); + } + + #[test] + fn is_deterministic() { + let receiver = [0x42u8; SHIELDED_RECEIVER_LEN]; + assert_eq!( + encode_orchard_receiver(&receiver, "zec").unwrap(), + encode_orchard_receiver(&receiver, "zec").unwrap(), + ); + } + + #[test] + fn rejects_an_unknown_network() { + let receiver = [0u8; SHIELDED_RECEIVER_LEN]; + assert!(encode_orchard_receiver(&receiver, "bitcoin").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( diff --git a/packages/wasm-utxo/test/address/toOutputScriptWithCoinShielded.ts b/packages/wasm-utxo/test/address/toOutputScriptWithCoinShielded.ts new file mode 100644 index 00000000000..7f09bb9930b --- /dev/null +++ b/packages/wasm-utxo/test/address/toOutputScriptWithCoinShielded.ts @@ -0,0 +1,74 @@ +import * as assert from "node:assert"; +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"; + +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; + +// This coin-name mapping mirrors `Network::from_coin_name`: "zec"/"tzec" both resolve, but the +// rest of this test suite (and BitGoJS) spells them "zec"/"tzec" too. +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); + 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); + 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("falls through to the transparent path for an ordinary (non-UA) address, even when set", function () { + const script = addressNs.toOutputScriptWithCoin(WALLET.transparentAddress, TZEC, true); + 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. + assert.throws( + () => addressNs.toOutputScriptWithCoin(MAINNET.unified, TZEC, true), + /Could not decode address/, + ); + }); +}); diff --git a/packages/wasm-utxo/test/fixedScript/zcashIronwoodPsbt.ts b/packages/wasm-utxo/test/fixedScript/zcashIronwoodPsbt.ts index d46b1f3f686..ac6c94a5286 100644 --- a/packages/wasm-utxo/test/fixedScript/zcashIronwoodPsbt.ts +++ b/packages/wasm-utxo/test/fixedScript/zcashIronwoodPsbt.ts @@ -9,6 +9,7 @@ import { IRONWOOD_VERSION_GROUP_ID, ZcashBitGoPsbt, } from "../../js/fixedScriptWallet/ZcashBitGoPsbt.js"; +import { ZcashUnifiedAddress } from "../../js/fixedScriptWallet/ZcashUnifiedAddress.js"; import { getWalletKeysForSeed } from "../../js/testutils/index.js"; // NU6.3 (Ironwood) testnet activation height. @@ -67,6 +68,32 @@ describe("ZcashIronwoodBitGoPsbt v6 (Ironwood)", function () { assert.deepStrictEqual(round.transparentSighash(0), sighash); }); + it("unsignedTxId (the generic PsbtAccess accessor) agrees with getId() for a v6 PSBT", function () { + // Regression test: unsignedTxId used to fall through to the v4/Sapling txid path for every + // Zcash PSBT, which builds an invalid transaction for v6 (wrong wire format) and panicked + // (wasm `unreachable` trap) instead of returning a usable value. + const psbt = buildShieldPsbt(); + assert.strictEqual(psbt.unsignedTxId(), psbt.getId()); + }); + + it("unsignedTxId works before a shielded output has been added", function () { + const psbt = ZcashIronwoodBitGoPsbt.createEmpty("zcashTest", walletKeys, { + blockHeight: NU6_3_TESTNET_HEIGHT, + }); + psbt.addWalletInput({ txid: "11".repeat(32), vout: 0, value: 200_000_000n }, walletKeys, { + scriptId: SCRIPT_ID, + signPath: { signer: "user", cosigner: "bitgo" }, + }); + psbt.addWalletOutput(walletKeys, { chain: 1, index: 0, value: 199_900_000n }); + assert.match(psbt.unsignedTxId(), /^[0-9a-f]{64}$/); + }); + + // The extraction-accounting regression (unsignedTxId/parseTransactionWithWalletKeys must throw, + // not silently go transparent-only, once the PCZT has been dropped by combineProof) is covered + // at the Rust level — `unsigned_v6_txid_and_shielded_output_info_error_after_extraction` in + // zcash_psbt.rs — since reaching a real extracted PSBT from this package's current JS surface + // needs a full ECDSA signing flow this test file has no utility for. + it("rejects a well-formed signature that does not verify against the v6 sighash", function () { const psbt = buildShieldPsbt(); // A pubkey the redeem script actually contains, so the failure comes from verification against @@ -113,6 +140,94 @@ describe("ZcashIronwoodBitGoPsbt v6 (Ironwood)", function () { assert.throws(() => psbt.transparentSighash(5), /out of range/); }); + describe("parseTransactionWithWalletKeys", function () { + // The shielded output has no `unsigned_tx` entry of its own (it lives in the PSBT's + // proprietary-map PCZT), so it is invisible to plain transparent-output parsing unless + // surfaced explicitly via `isShielded`. + it("surfaces the shielded output as isShielded and folds its value into fee/spend accounting", function () { + const psbt = buildShieldPsbt(); + const parsed = psbt.parseTransactionWithWalletKeys(walletKeys, { + replayProtection: { publicKeys: [] }, + }); + + assert.strictEqual(parsed.outputs.length, 2); + const shielded = parsed.outputs.filter((o) => o.isShielded); + assert.strictEqual(shielded.length, 1); + assert.strictEqual(shielded[0].value, 100_000_000n, "the shielded note's value"); + // `address` is a real, usable single-receiver unified address encoding the raw receiver + // (read from the PCZT's plaintext recipient field, not from decrypting anything); `script` + // carries the same 43 raw bytes. + assert.deepStrictEqual(Buffer.from(shielded[0].script), RECIPIENT); + const ua = ZcashUnifiedAddress.parse(shielded[0].address ?? "", "zcashTest"); + assert.deepStrictEqual(Buffer.from(ua.orchardReceiver ?? []), RECIPIENT); + assert.strictEqual(shielded[0].derivationPath, null); + + const change = parsed.outputs.find((o) => !o.isShielded); + assert.strictEqual(change?.derivationPath, "0/0/1/0"); + + // 200_000_000 in - (99_900_000 transparent change + 100_000_000 shielded) = 100_000 fee. + assert.strictEqual(parsed.minerFee, 100_000n); + // The shielded note counts as an external spend, same as any other non-wallet output. + assert.strictEqual(parsed.spendAmount, 100_000_000n); + }); + + it("omits the shielded entry for a v6 PSBT with no shielded output yet", function () { + const psbt = ZcashIronwoodBitGoPsbt.createEmpty("zcashTest", walletKeys, { + blockHeight: NU6_3_TESTNET_HEIGHT, + }); + psbt.addWalletInput({ txid: "11".repeat(32), vout: 0, value: 200_000_000n }, walletKeys, { + scriptId: SCRIPT_ID, + signPath: { signer: "user", cosigner: "bitgo" }, + }); + psbt.addWalletOutput(walletKeys, { chain: 1, index: 0, value: 199_900_000n }); + + const parsed = psbt.parseTransactionWithWalletKeys(walletKeys, { + replayProtection: { publicKeys: [] }, + }); + assert.strictEqual(parsed.outputs.length, 1); + assert.strictEqual(parsed.outputs[0].isShielded, false); + assert.strictEqual(parsed.minerFee, 100_000n); + assert.strictEqual(parsed.spendAmount, 0n); + }); + }); + + describe("parseOutputsWithWalletKeys", function () { + // This method skips input validation but shares the same output-parsing path, so the shielded + // output must be surfaced here too — otherwise a caller using it (e.g. to identify outputs + // belonging to a different wallet than the inputs) would silently miss the shielded note. + it("surfaces the shielded output alongside the transparent change output", function () { + const psbt = buildShieldPsbt(); + const outputs = psbt.parseOutputsWithWalletKeys(walletKeys); + + assert.strictEqual(outputs.length, 2); + const shielded = outputs.filter((o) => o.isShielded); + assert.strictEqual(shielded.length, 1); + assert.strictEqual(shielded[0].value, 100_000_000n); + assert.deepStrictEqual(Buffer.from(shielded[0].script), RECIPIENT); + const ua = ZcashUnifiedAddress.parse(shielded[0].address ?? "", "zcashTest"); + assert.deepStrictEqual(Buffer.from(ua.orchardReceiver ?? []), RECIPIENT); + assert.strictEqual(shielded[0].derivationPath, null); + + const change = outputs.find((o) => !o.isShielded); + assert.strictEqual(change?.derivationPath, "0/0/1/0"); + }); + + it("omits the shielded entry for a v6 PSBT with no shielded output yet", function () { + const psbt = ZcashIronwoodBitGoPsbt.createEmpty("zcashTest", walletKeys, { + blockHeight: NU6_3_TESTNET_HEIGHT, + }); + psbt.addWalletInput({ txid: "11".repeat(32), vout: 0, value: 200_000_000n }, walletKeys, { + scriptId: SCRIPT_ID, + signPath: { signer: "user", cosigner: "bitgo" }, + }); + psbt.addWalletOutput(walletKeys, { chain: 1, index: 0, value: 199_900_000n }); + + const outputs = psbt.parseOutputsWithWalletKeys(walletKeys); + assert.strictEqual(outputs.length, 1); + assert.strictEqual(outputs[0].isShielded, false); + }); + }); + describe("addShieldedOutput byte-length validation", function () { // Each field is validated at the wasm boundary, before the orchard builder is touched. const anchor = new Uint8Array(32); diff --git a/packages/wasm-utxo/test/fixedScript/zcashUnifiedAddress.ts b/packages/wasm-utxo/test/fixedScript/zcashUnifiedAddress.ts index 226ac83c46f..8d85a3d0fa5 100644 --- a/packages/wasm-utxo/test/fixedScript/zcashUnifiedAddress.ts +++ b/packages/wasm-utxo/test/fixedScript/zcashUnifiedAddress.ts @@ -92,4 +92,37 @@ describe("ZcashUnifiedAddress", function () { assert.throws(() => ua.contains("not-an-address")); }); }); + + describe("encodeOrchardReceiver", function () { + it("round-trips a raw receiver through parse (mainnet)", function () { + const receiver = Buffer.from(MAINNET.orchardReceiverHex, "hex"); + const encoded = ZcashUnifiedAddress.encodeOrchardReceiver(receiver, MAINNET.network); + const ua = ZcashUnifiedAddress.parse(encoded, MAINNET.network); + assert.strictEqual(hex(ua.orchardReceiver), MAINNET.orchardReceiverHex); + }); + + it("round-trips a raw receiver through parse (testnet)", function () { + const receiver = Buffer.from(WALLET.ironwoodReceiverHex, "hex"); + const encoded = ZcashUnifiedAddress.encodeOrchardReceiver(receiver, WALLET.network); + const ua = ZcashUnifiedAddress.parse(encoded, WALLET.network); + assert.strictEqual(hex(ua.orchardReceiver), WALLET.ironwoodReceiverHex); + }); + + it("is deterministic", function () { + const receiver = Buffer.from(MAINNET.orchardReceiverHex, "hex"); + assert.strictEqual( + ZcashUnifiedAddress.encodeOrchardReceiver(receiver, MAINNET.network), + ZcashUnifiedAddress.encodeOrchardReceiver(receiver, MAINNET.network), + ); + }); + + it("rejects a receiver of the wrong length", function () { + assert.throws(() => ZcashUnifiedAddress.encodeOrchardReceiver(new Uint8Array(10), "zec")); + }); + + it("rejects an unknown network", function () { + const receiver = Buffer.from(MAINNET.orchardReceiverHex, "hex"); + assert.throws(() => ZcashUnifiedAddress.encodeOrchardReceiver(receiver, "bitcoin" as never)); + }); + }); });