diff --git a/packages/wasm-utxo/js/fixedScriptWallet/ZcashBitGoPsbt.ts b/packages/wasm-utxo/js/fixedScriptWallet/ZcashBitGoPsbt.ts index b55e9f0af08..63b477b9bfa 100644 --- a/packages/wasm-utxo/js/fixedScriptWallet/ZcashBitGoPsbt.ts +++ b/packages/wasm-utxo/js/fixedScriptWallet/ZcashBitGoPsbt.ts @@ -330,4 +330,34 @@ export class ZcashBitGoPsbt extends BitGoPsbt { override extractTransaction(maxFeeRate?: number): ZcashTransaction { return ZcashTransaction.fromWasm(this.wasm.extract_zcash_transaction(maxFeeRate)); } + + /** + * Add a plain transparent output. Just `script`/`value` (no `unifiedAddress`) is the same as + * the generic {@link BitGoPsbt.addOutput}, unchanged. + * + * If `unifiedAddress` is given, it must be a Unified Address whose transparent receiver is + * exactly `script` — mismatches throw rather than being silently stored. It is then kept + * verbatim, keyed by this output's index, so {@link ZcashBitGoPsbt.transparentOutputUnifiedAddress} + * can later return the original UA string rather than just the bare scriptPubkey. + * + * `unifiedAddress` is only supported on a legacy v4 `ZcashBitGoPsbt` — the v6 (Ironwood) + * shielded side has its own UA path (`ZcashIronwoodBitGoPsbt.addShieldedOutput`'s + * `unifiedAddress` option). + * + * @param script - The output scriptPubkey + * @param value - The value in zatoshi + * @param unifiedAddress - Optional full Unified Address `script` was resolved from + * @returns The index of the newly added output + */ + addTransparentOutput(script: Uint8Array, value: bigint, unifiedAddress?: string): number { + return this.wasm.add_transparent_output(script, value, unifiedAddress); + } + + /** + * The Unified Address stored by {@link ZcashBitGoPsbt.addTransparentOutput} for output `index`, + * if one was supplied — `undefined` otherwise. + */ + transparentOutputUnifiedAddress(index: number): string | undefined { + return this.wasm.transparent_output_unified_address(index); + } } 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 c9d8b32b724..174b3317703 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 @@ -2728,11 +2728,29 @@ impl BitGoPsbt { .zip(psbt.outputs.iter()) .enumerate() .map(|(output_index, (tx_output, psbt_output))| { - ParsedOutput::parse(psbt_output, tx_output, wallet_keys, network, paygo_pubkeys) - .map_err(|error| ParseTransactionError::Output { - index: output_index, - error, - }) + let mut parsed = ParsedOutput::parse( + psbt_output, + tx_output, + wallet_keys, + network, + paygo_pubkeys, + ) + .map_err(|error| ParseTransactionError::Output { + index: output_index, + error, + })?; + // Prefer the caller's original Unified Address (if `add_transparent_output` was + // given one for this output): mirrors `shielded_outputs`'s treatment of + // `add_ironwood_output`'s UA — the caller-supplied UA, not a bare address + // reconstructed from the scriptPubKey alone, is what a client actually pasted in. + if let BitGoPsbt::Zcash(z, _) = self { + if let Some(ua) = + propkv::get_transparent_output_unified_address(&z.psbt, output_index) + { + parsed.address = Some(ua); + } + } + Ok(parsed) }) .collect() } 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 4866f3bcfa9..0fc771a75a4 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 @@ -278,6 +278,14 @@ pub enum ZecV6KeySubtype { /// storing the original string here lets output parsing return the exact UA the caller passed, /// receivers and all, after a serialize/deserialize round-trip. UnifiedAddress = 0x05, + /// The full ZIP-316 Unified Address string (UTF-8) a plain transparent output (identified by + /// its index in `unsigned_tx.output`) was addressed to, if the caller supplied one to + /// [`crate::fixed_script_wallet::bitgo_psbt::zcash_psbt`]'s `add_transparent_output`. Mirrors + /// `UnifiedAddress` above, but keyed by transparent output index rather than Orchard action + /// index, and stored only for the output's transparent receiver (already fully recoverable + /// from the scriptPubKey) — kept so output parsing can hand back the exact UA the caller + /// passed, not one reconstructed as a bare transparent address. + TransparentUnifiedAddress = 0x06, } fn set_zec_v6( @@ -433,6 +441,38 @@ pub fn clear_ironwood_unified_addresses(psbt: &mut miniscript::bitcoin::psbt::Ps }); } +/// Store the full Unified Address string one plain transparent output (identified by its +/// `output_index` in `unsigned_tx.output`) was addressed to, so it survives a +/// serialize/deserialize round-trip verbatim rather than being lost down to just the +/// scriptPubKey. Keyed by `output_index`. Overwrites any existing value for that index. +pub fn set_transparent_output_unified_address( + psbt: &mut miniscript::bitcoin::psbt::Psbt, + output_index: usize, + ua: &str, +) { + let key = ProprietaryKey { + prefix: BITGO_ZEC_V6.to_vec(), + subtype: ZecV6KeySubtype::TransparentUnifiedAddress as u8, + key: (output_index as u32).to_le_bytes().to_vec(), + }; + psbt.proprietary.insert(key, ua.as_bytes().to_vec()); +} + +/// Fetch the Unified Address string stored by [`set_transparent_output_unified_address`] for +/// `output_index`, if present and valid UTF-8. +pub fn get_transparent_output_unified_address( + psbt: &miniscript::bitcoin::psbt::Psbt, + output_index: usize, +) -> Option { + let key = ProprietaryKey { + prefix: BITGO_ZEC_V6.to_vec(), + subtype: ZecV6KeySubtype::TransparentUnifiedAddress as u8, + key: (output_index as u32).to_le_bytes().to_vec(), + }; + let bytes = psbt.proprietary.get(&key)?; + String::from_utf8(bytes.clone()).ok() +} + #[cfg(test)] mod tests { use super::*; 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 5a5001714c8..9098c016e87 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 @@ -849,6 +849,81 @@ impl ZcashBitGoPsbt { Ok(action_indices[0]) } + /// Add a plain transparent output. Just `script`/`value` (`unified_address: None`) is exactly + /// the legacy `add_output` behavior, unchanged, on either v4 or v6. + /// + /// If `unified_address` is given, this must be a legacy v4 PSBT — the UA is stored under the + /// same `BITGO_ZEC_V6`-namespaced proprietary key space as the Ironwood UA storage, but that + /// namespace name is historical, not a v6 requirement; a v6 PSBT's shielded-side UA already + /// has its own path ([`Self::add_ironwood_output`]), so this one is reserved for legacy + /// transparent-only PSBTs. `unified_address` must be a Unified Address whose transparent + /// receiver is exactly `script` — mismatches are rejected rather than silently stored, the + /// same contract `add_ironwood_output` applies to its Orchard receiver. It is then stored + /// verbatim, keyed by this output's index in `unsigned_tx.output` (see + /// [`super::propkv::set_transparent_output_unified_address`]), so + /// [`Self::transparent_output_unified_address`] can later return the original UA string + /// rather than just the bare scriptPubKey. + pub fn add_transparent_output( + &mut self, + script: miniscript::bitcoin::ScriptBuf, + value: u64, + unified_address: Option<&str>, + ) -> Result { + let index = self.psbt.unsigned_tx.output.len(); + let Some(ua) = unified_address else { + use miniscript::bitcoin::{Amount, TxOut}; + let tx_out = TxOut { + value: Amount::from_sat(value), + script_pubkey: script, + }; + return crate::psbt_ops::insert_output( + &mut self.psbt, + index, + tx_out, + miniscript::bitcoin::psbt::Output::default(), + ); + }; + + if self.is_ironwood_v6() { + return Err( + "unified_address on a transparent output requires a legacy v4 PSBT".to_string(), + ); + } + + let parsed = + crate::zcash::unified_address::UnifiedAddress::parse(ua, self.network.to_coin_name()) + .map_err(|e| format!("invalid unified_address: {e}"))?; + let transparent = parsed + .transparent_script() + .map_err(|e| format!("invalid unified_address: {e}"))? + .ok_or_else(|| "unified_address has no transparent receiver".to_string())?; + if transparent != script.as_bytes() { + return Err("unified_address's transparent receiver does not match script".to_string()); + } + + super::propkv::set_transparent_output_unified_address(&mut self.psbt, index, ua); + + use miniscript::bitcoin::{Amount, TxOut}; + let tx_out = TxOut { + value: Amount::from_sat(value), + script_pubkey: script, + }; + crate::psbt_ops::insert_output( + &mut self.psbt, + index, + tx_out, + miniscript::bitcoin::psbt::Output::default(), + )?; + Ok(index) + } + + /// The Unified Address stored by [`Self::add_transparent_output`] for output `index`, if the + /// caller supplied one — `None` if that output has no stored UA (including if it doesn't + /// exist, or wasn't added via `add_transparent_output` at all). + pub fn transparent_output_unified_address(&self, index: usize) -> Option { + super::propkv::get_transparent_output_unified_address(&self.psbt, index) + } + /// Deserialize the stored orchard PCZT. fn ironwood_pczt(&self) -> Result { let bytes = super::propkv::get_ironwood_pczt(&self.psbt) @@ -2863,6 +2938,260 @@ mod ironwood_v6_tests { ); } + /// A client passes a Unified Address as the recipient on a legacy v4 PSBT; + /// `add_transparent_output` resolves its transparent receiver to a scriptPubkey, verifies it + /// against the caller-supplied script, stores the UA, and adds the ordinary transparent + /// output — alongside a wallet change output. `transparent_output_unified_address` returns + /// the exact original UA — receivers and all — after a `serialize`/`deserialize` round trip, + /// not merely a bare address reconstructed from the scriptPubkey. + #[test] + fn add_transparent_output_unified_address_round_trips_on_v4() { + let fixtures: serde_json::Value = serde_json::from_str( + &crate::fixed_script_wallet::test_utils::fixtures::load_fixture( + "zcash/unified_address.json", + ) + .unwrap(), + ) + .unwrap(); + // The client only ever hands us the UA itself, not a bare transparent address. + let ua = fixtures["testnetWallet"]["unified"].as_str().unwrap(); + + let parsed = crate::zcash::unified_address::UnifiedAddress::parse(ua, "tzec").unwrap(); + let transparent_script = parsed + .transparent_script() + .unwrap() + .expect("fixture UA has a transparent receiver"); + + let wallet_keys = RootWalletKeys::new(get_test_wallet_keys("v4_ua_transparent_roundtrip")); + let mut psbt = BitGoPsbt::new_zcash( + Network::ZcashTestnet, + &wallet_keys, + NetworkUpgrade::Nu5.branch_id(), + None, + None, + None, + None, + ); + psbt.add_wallet_input( + Txid::from_byte_array([0x66u8; 32]), + 0, + 300_000_000, + &wallet_keys, + ScriptId { chain: 0, index: 0 }, + WalletInputOptions::default(), + ) + .unwrap(); + + let BitGoPsbt::Zcash(mut z, _) = psbt else { + panic!("expected Zcash PSBT"); + }; + assert!(!z.is_ironwood_v6()); + // Recipient output: the UA's transparent receiver, resolved and verified from the UA the + // client passed in — not from a separately-supplied plain transparent address. + let recipient_output_index = z + .add_transparent_output( + miniscript::bitcoin::ScriptBuf::from_bytes(transparent_script.clone()), + 100_000_000, + Some(ua), + ) + .unwrap(); + assert_eq!( + z.transparent_output_unified_address(recipient_output_index), + Some(ua.to_string()) + ); + + // Change output: an ordinary wallet output, unrelated to the UA — added via the outer + // `BitGoPsbt` the way any wallet change output normally is. + let mut psbt = BitGoPsbt::Zcash(z, Network::ZcashTestnet); + psbt.add_wallet_output(0, 1, 199_900_000, &wallet_keys) + .unwrap(); + + let round = + BitGoPsbt::deserialize(&psbt.serialize().unwrap(), Network::ZcashTestnet).unwrap(); + let BitGoPsbt::Zcash(round, _) = round else { + panic!("expected Zcash PSBT"); + }; + + // The recipient output's scriptPubkey survives the round trip... + assert_eq!( + round.psbt.unsigned_tx.output[recipient_output_index] + .script_pubkey + .as_bytes(), + transparent_script.as_slice() + ); + // ...and so does the exact UA string the client originally passed in. + assert_eq!( + round.transparent_output_unified_address(recipient_output_index), + Some(ua.to_string()) + ); + + // `parse_outputs_with_wallet_keys` resolves this output's `address` to the stored UA + // itself — mirroring how a shielded Ironwood output's `address` prefers its stored UA + // over a bare address reconstructed from the raw receiver — rather than the plain + // transparent address a script-only resolution would produce. + let round_bitgo_psbt = BitGoPsbt::Zcash(round, Network::ZcashTestnet); + let parsed_outputs = round_bitgo_psbt + .parse_outputs_with_wallet_keys(&wallet_keys, &[]) + .unwrap(); + assert_eq!( + parsed_outputs[recipient_output_index].address, + Some(ua.to_string()) + ); + } + + /// `add_transparent_output` rejects a `unified_address` whose transparent receiver doesn't + /// match `script` — a caller passing mismatched values is a bug, not something to silently + /// store, mirroring `add_ironwood_output`'s Orchard-side contract. + #[test] + fn add_transparent_output_rejects_mismatched_unified_address() { + let fixtures: serde_json::Value = serde_json::from_str( + &crate::fixed_script_wallet::test_utils::fixtures::load_fixture( + "zcash/unified_address.json", + ) + .unwrap(), + ) + .unwrap(); + let ua = fixtures["testnetWallet"]["unified"].as_str().unwrap(); + + let wallet_keys = RootWalletKeys::new(get_test_wallet_keys("v4_ua_transparent_mismatch")); + let mut z = ZcashBitGoPsbt::new( + Network::ZcashTestnet, + &wallet_keys, + NetworkUpgrade::Nu5.branch_id(), + None, + None, + None, + None, + ); + + let wrong_script = miniscript::bitcoin::ScriptBuf::from_bytes(vec![ + 0x76, 0xa9, 0x14, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0x88, 0xac, + ]); + let err = z + .add_transparent_output(wrong_script, 100_000_000, Some(ua)) + .unwrap_err(); + assert!(err.contains("does not match"), "unexpected error: {err}"); + // Nothing was inserted on the rejected call. + assert!(z.psbt.unsigned_tx.output.is_empty()); + } + + /// `add_transparent_output` rejects a `unified_address` that fails to parse as a ZIP-316 + /// Unified Address at all (bad Bech32m, wrong HRP, etc.) — a malformed string is a caller + /// bug, not something to fall back to the plain `script`/`value` behavior for. + #[test] + fn add_transparent_output_rejects_an_unparseable_unified_address() { + let wallet_keys = RootWalletKeys::new(get_test_wallet_keys("v4_ua_transparent_bad_ua")); + let mut z = ZcashBitGoPsbt::new( + Network::ZcashTestnet, + &wallet_keys, + NetworkUpgrade::Nu5.branch_id(), + None, + None, + None, + None, + ); + + let script = miniscript::bitcoin::ScriptBuf::from_bytes(vec![ + 0x76, 0xa9, 0x14, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0x88, 0xac, + ]); + let err = z + .add_transparent_output(script, 100_000_000, Some("not-a-valid-unified-address")) + .unwrap_err(); + assert!( + err.contains("invalid unified_address"), + "unexpected error: {err}" + ); + // Nothing was inserted on the rejected call. + assert!(z.psbt.unsigned_tx.output.is_empty()); + } + + /// `add_transparent_output` rejects a `unified_address` that has no transparent receiver at + /// all (Orchard-only, say) — there is nothing to verify `script` against, so this must error + /// rather than silently accept `script` unchecked. + #[test] + fn add_transparent_output_rejects_a_unified_address_with_no_transparent_receiver() { + let recipient = test_recipient(); + let orchard_only_ua = + crate::zcash::unified_address::encode_orchard_receiver(&recipient, "tzec").unwrap(); + + let wallet_keys = + RootWalletKeys::new(get_test_wallet_keys("v4_ua_transparent_no_transparent")); + let mut z = ZcashBitGoPsbt::new( + Network::ZcashTestnet, + &wallet_keys, + NetworkUpgrade::Nu5.branch_id(), + None, + None, + None, + None, + ); + + let script = miniscript::bitcoin::ScriptBuf::from_bytes(vec![ + 0x76, 0xa9, 0x14, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0x88, 0xac, + ]); + let err = z + .add_transparent_output(script, 100_000_000, Some(&orchard_only_ua)) + .unwrap_err(); + assert!( + err.contains("no transparent receiver"), + "unexpected error: {err}" + ); + // Nothing was inserted on the rejected call. + assert!(z.psbt.unsigned_tx.output.is_empty()); + } + + /// `add_transparent_output` rejects `unified_address` outright on a v6 (Ironwood) PSBT — that + /// format's shielded side already has its own UA path (`add_ironwood_output`), so this one is + /// reserved for legacy v4 PSBTs. A bare `script`/`value` call (no `unified_address`) is + /// exactly the legacy Zcash form and must keep working unchanged on either format. + #[test] + fn add_transparent_output_rejects_unified_address_on_v6_psbt() { + let fixtures: serde_json::Value = serde_json::from_str( + &crate::fixed_script_wallet::test_utils::fixtures::load_fixture( + "zcash/unified_address.json", + ) + .unwrap(), + ) + .unwrap(); + let ua = fixtures["testnetWallet"]["unified"].as_str().unwrap(); + let parsed = crate::zcash::unified_address::UnifiedAddress::parse(ua, "tzec").unwrap(); + let transparent_script = parsed.transparent_script().unwrap().unwrap(); + + let wallet_keys = RootWalletKeys::new(get_test_wallet_keys("v6_ua_transparent_rejected")); + let mut z = ZcashBitGoPsbt::new_v6_at_height( + Network::ZcashTestnet, + &wallet_keys, + NetworkUpgrade::Nu6_3.testnet_activation_height(), + None, + None, + ) + .unwrap(); + assert!(z.is_ironwood_v6()); + + let err = z + .add_transparent_output( + miniscript::bitcoin::ScriptBuf::from_bytes(transparent_script.clone()), + 100_000_000, + Some(ua), + ) + .unwrap_err(); + assert!(err.contains("v4"), "unexpected error: {err}"); + + // The legacy form (no unified_address) is unaffected. + let index = z + .add_transparent_output( + miniscript::bitcoin::ScriptBuf::from_bytes(transparent_script.clone()), + 100_000_000, + None, + ) + .unwrap(); + assert_eq!( + z.psbt.unsigned_tx.output[index].script_pubkey.as_bytes(), + transparent_script.as_slice() + ); + assert_eq!(z.transparent_output_unified_address(index), None); + } + /// `add_ironwood_output` rejects a `unified_address` whose Orchard receiver doesn't match /// `recipient` — a caller passing mismatched values is a bug, not something to silently store. #[test] diff --git a/packages/wasm-utxo/src/wasm/fixed_script_wallet/mod.rs b/packages/wasm-utxo/src/wasm/fixed_script_wallet/mod.rs index de42d6f6e60..0405bbd8a17 100644 --- a/packages/wasm-utxo/src/wasm/fixed_script_wallet/mod.rs +++ b/packages/wasm-utxo/src/wasm/fixed_script_wallet/mod.rs @@ -1075,6 +1075,37 @@ impl BitGoPsbt { Ok(self.psbt.add_output_with_address(address, value)?) } + /// Zcash-only: add a plain transparent output. Just `script`/`value` + /// (`unified_address: undefined`) is the legacy `add_output` behavior, unchanged. + /// + /// If `unified_address` is given, this PSBT must be a legacy v4 Zcash PSBT — a v6 (Ironwood) + /// PSBT's shielded side already has its own UA path (`add_ironwood_output`). The UA must be a + /// Unified Address whose transparent receiver is exactly `script`; mismatches are rejected + /// rather than silently stored. It is then kept verbatim, keyed by this output's index, so + /// `transparent_output_unified_address` can later return the original UA string rather than + /// just the bare scriptPubkey. + pub fn add_transparent_output( + &mut self, + script: &[u8], + value: u64, + unified_address: Option, + ) -> Result { + use miniscript::bitcoin::ScriptBuf; + let script = ScriptBuf::from_bytes(script.to_vec()); + self.zcash_mut()? + .add_transparent_output(script, value, unified_address.as_deref()) + .map_err(|e| WasmUtxoError::new(&e)) + } + + /// Zcash-only: the Unified Address stored by [`Self::add_transparent_output`] for output + /// `index`, if the caller supplied one — `undefined` otherwise (including for a non-Zcash + /// PSBT). + pub fn transparent_output_unified_address(&self, index: usize) -> Option { + self.zcash() + .ok() + .and_then(|z| z.transparent_output_unified_address(index)) + } + #[allow(clippy::too_many_arguments)] pub fn add_wallet_input_at_index( &mut self, diff --git a/packages/wasm-utxo/test/fixedScript/zcashTransparentUnifiedAddress.ts b/packages/wasm-utxo/test/fixedScript/zcashTransparentUnifiedAddress.ts new file mode 100644 index 00000000000..2c19e9e8b7d --- /dev/null +++ b/packages/wasm-utxo/test/fixedScript/zcashTransparentUnifiedAddress.ts @@ -0,0 +1,158 @@ +import assert from "node:assert"; +import * as fs from "node:fs"; +import * as path from "node:path"; +import { fileURLToPath } from "node:url"; +import { describe, it } from "mocha"; + +import { ZcashBitGoPsbt } from "../../js/fixedScriptWallet/ZcashBitGoPsbt.js"; +import { ZcashIronwoodBitGoPsbt } from "../../js/fixedScriptWallet/ZcashIronwoodBitGoPsbt.js"; +import { ZcashUnifiedAddress } from "../../js/fixedScriptWallet/ZcashUnifiedAddress.js"; +import { address as addressNs } from "../../js/index.js"; +import { getWalletKeysForSeed } from "../../js/testutils/index.js"; + +// A deterministic Ironwood receiver — same one used elsewhere in the Ironwood test suite — +// re-encoded here as a single-receiver (Orchard-only, no transparent receiver) UA. +const ORCHARD_RECEIVER = Buffer.from( + "4559029c0b5dbf941c5ad181a5fe8f45b34630f29d0c8dd8dc1cc3573386f416cb324133156d723df5e62d", + "hex", +); + +// A block height comfortably within NU5, well before NU6.3 (Ironwood) activation — any legacy +// (v4) branch id works here since this feature has nothing to do with sighash rules. +const LEGACY_TESTNET_HEIGHT = 2_000_000; +// NU6.3 (Ironwood) testnet activation height. +const NU6_3_TESTNET_HEIGHT = 4_134_000; + +const SCRIPT_ID = { chain: 0, index: 0 } as const; + +type UaVector = { + network: "zec" | "tzec"; + unified: string; + transparentAddress?: string; +}; + +const uaFixtures = JSON.parse( + fs.readFileSync( + path.resolve( + path.dirname(fileURLToPath(import.meta.url)), + "../fixtures/zcash/unified_address.json", + ), + "utf8", + ), +) as { testnetWallet: UaVector }; +const WALLET = uaFixtures.testnetWallet; + +// The UA's transparent receiver, resolved the same way a client would: through the public +// address API rather than by reaching into the UA's internals. +const TRANSPARENT_SCRIPT = addressNs.toOutputScriptWithCoin(WALLET.transparentAddress, "tzec"); + +describe("ZcashBitGoPsbt.addTransparentOutput (legacy v4 unified_address)", function () { + const walletKeys = getWalletKeysForSeed("v4-ua-transparent-ts"); + + function buildLegacyPsbt(): ZcashBitGoPsbt { + const psbt = ZcashBitGoPsbt.createEmpty("zcashTest", walletKeys, { + blockHeight: LEGACY_TESTNET_HEIGHT, + }); + psbt.addWalletInput({ txid: "22".repeat(32), vout: 0, value: 300_000_000n }, walletKeys, { + scriptId: SCRIPT_ID, + signPath: { signer: "user", cosigner: "bitgo" }, + }); + return psbt; + } + + it("adds the transparent output and stores the unifiedAddress it was resolved from", function () { + const psbt = buildLegacyPsbt(); + const index = psbt.addTransparentOutput(TRANSPARENT_SCRIPT, 100_000_000n, WALLET.unified); + psbt.addWalletOutput(walletKeys, { chain: 1, index: 0, value: 199_900_000n }); + + assert.strictEqual(psbt.transparentOutputUnifiedAddress(index), WALLET.unified); + + const outputs = psbt.parseOutputsWithWalletKeys(walletKeys); + assert.strictEqual(outputs.length, 2); + assert.deepStrictEqual(new Uint8Array(outputs[index].script), TRANSPARENT_SCRIPT); + // The parsed output's `address` prefers the stored unifiedAddress — mirroring how a + // shielded Ironwood output's `address` prefers its stored UA over a bare address + // reconstructed from the raw receiver — so it comes back as the exact UA the client + // originally passed in, not just the plain transparent address the script resolves to. + assert.strictEqual(outputs[index].address, WALLET.unified); + }); + + it("round-trips the unifiedAddress and the transparent output through serialize/fromBytes", function () { + const psbt = buildLegacyPsbt(); + const index = psbt.addTransparentOutput(TRANSPARENT_SCRIPT, 100_000_000n, WALLET.unified); + psbt.addWalletOutput(walletKeys, { chain: 1, index: 0, value: 199_900_000n }); + + const bytes = psbt.serialize(); + const round = ZcashBitGoPsbt.fromBytes(bytes, "zcashTest"); + + assert.strictEqual(round.transparentOutputUnifiedAddress(index), WALLET.unified); + const outputs = round.parseOutputsWithWalletKeys(walletKeys); + assert.deepStrictEqual(new Uint8Array(outputs[index].script), TRANSPARENT_SCRIPT); + assert.strictEqual(outputs[index].address, WALLET.unified); + }); + + it("works with just a script/value — no unifiedAddress, exactly like addOutput", function () { + const psbt = buildLegacyPsbt(); + const index = psbt.addTransparentOutput(TRANSPARENT_SCRIPT, 100_000_000n); + + assert.strictEqual(psbt.transparentOutputUnifiedAddress(index), undefined); + const outputs = psbt.parseOutputsWithWalletKeys(walletKeys); + assert.deepStrictEqual(new Uint8Array(outputs[index].script), TRANSPARENT_SCRIPT); + // No unifiedAddress was stored, so `address` falls back to the plain transparent address + // resolved from the scriptPubkey — exactly like any other legacy output. + assert.strictEqual(outputs[index].address, WALLET.transparentAddress); + }); + + describe("failure scenarios", function () { + it("rejects a unifiedAddress whose transparent receiver does not match script", function () { + const psbt = buildLegacyPsbt(); + // Any well-formed but different P2PKH script. + const otherScript = addressNs.toOutputScriptWithCoin( + "tmYXBYJj1K7vhejSec5osXK2QsGa5MTisUQ", + "tzec", + ); + assert.throws( + () => psbt.addTransparentOutput(otherScript, 100_000_000n, WALLET.unified), + /does not match/, + ); + // Nothing was inserted on the rejected call. + const outputs = psbt.parseOutputsWithWalletKeys(walletKeys); + assert.strictEqual(outputs.length, 0); + }); + + it("rejects a unifiedAddress that is not a valid unified address", function () { + const psbt = buildLegacyPsbt(); + assert.throws( + () => psbt.addTransparentOutput(TRANSPARENT_SCRIPT, 100_000_000n, "not-a-valid-address"), + /invalid unified_address/, + ); + // Nothing was inserted on the rejected call. + assert.strictEqual(psbt.parseOutputsWithWalletKeys(walletKeys).length, 0); + }); + + it("rejects a unifiedAddress with no transparent receiver (Orchard-only)", function () { + const psbt = buildLegacyPsbt(); + const orchardOnlyUa = ZcashUnifiedAddress.encodeOrchardReceiver(ORCHARD_RECEIVER, "tzec"); + assert.throws( + () => psbt.addTransparentOutput(TRANSPARENT_SCRIPT, 100_000_000n, orchardOnlyUa), + /no transparent receiver/, + ); + // Nothing was inserted on the rejected call. + assert.strictEqual(psbt.parseOutputsWithWalletKeys(walletKeys).length, 0); + }); + + it("rejects a unifiedAddress on a v6 (Ironwood) PSBT", function () { + const psbt = ZcashIronwoodBitGoPsbt.createEmpty("zcashTest", walletKeys, { + blockHeight: NU6_3_TESTNET_HEIGHT, + }); + assert.throws( + () => psbt.addTransparentOutput(TRANSPARENT_SCRIPT, 100_000_000n, WALLET.unified), + /v4/, + ); + + // The legacy form (no unifiedAddress) is unaffected, even on a v6 PSBT. + const index = psbt.addTransparentOutput(TRANSPARENT_SCRIPT, 100_000_000n); + assert.strictEqual(psbt.transparentOutputUnifiedAddress(index), undefined); + }); + }); +});