diff --git a/packages/wasm-utxo/js/fixedScriptWallet/ZcashBitGoPsbt.ts b/packages/wasm-utxo/js/fixedScriptWallet/ZcashBitGoPsbt.ts index e8f604d5cb7..5d6b1250500 100644 --- a/packages/wasm-utxo/js/fixedScriptWallet/ZcashBitGoPsbt.ts +++ b/packages/wasm-utxo/js/fixedScriptWallet/ZcashBitGoPsbt.ts @@ -370,9 +370,10 @@ export class ZcashBitGoPsbt< * 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). + * `unifiedAddress` works on both a legacy v4 `ZcashBitGoPsbt` and a v6 (Ironwood) + * `ZcashIronwoodBitGoPsbt`. The shielded side has its own, separate UA path + * (`ZcashIronwoodBitGoPsbt.addShieldedOutput`'s `unifiedAddress` option), so a v6 PSBT can + * carry both a transparent payout's UA and shielded outputs' UAs side by side. * * @param script - The output scriptPubkey * @param value - The value in zatoshi diff --git a/packages/wasm-utxo/src/fixed_script_wallet/bitgo_psbt/zcash/tests/v6.rs b/packages/wasm-utxo/src/fixed_script_wallet/bitgo_psbt/zcash/tests/v6.rs index 4c13874056f..3b250027bf1 100644 --- a/packages/wasm-utxo/src/fixed_script_wallet/bitgo_psbt/zcash/tests/v6.rs +++ b/packages/wasm-utxo/src/fixed_script_wallet/bitgo_psbt/zcash/tests/v6.rs @@ -762,12 +762,15 @@ fn add_transparent_output_rejects_a_unified_address_with_no_transparent_receiver 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. +/// A client passes a Unified Address as the recipient on a v6 (Ironwood) PSBT — a deshield's +/// transparent payout, say. `add_transparent_output` verifies the UA's transparent receiver +/// against `script`, stores the UA verbatim (under the `TransparentUnifiedAddress` subtype, +/// disjoint from the shielded side's action-indexed `UnifiedAddress` keys), and adds the +/// ordinary transparent output. The exact original UA — receivers and all — survives a +/// serialize/deserialize round trip, and output parsing resolves the output's `address` to +/// the stored UA rather than the bare t-address the scriptPubkey alone would produce. #[test] -fn add_transparent_output_rejects_unified_address_on_v6_psbt() { +fn add_transparent_output_unified_address_round_trips_on_v6() { let fixtures: serde_json::Value = serde_json::from_str( &crate::fixed_script_wallet::test_utils::fixtures::load_fixture( "zcash/unified_address.json", @@ -779,7 +782,7 @@ fn add_transparent_output_rejects_unified_address_on_v6_psbt() { 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 wallet_keys = RootWalletKeys::new(get_test_wallet_keys("v6_ua_transparent_roundtrip")); let mut z = ZcashBitGoPsbt::new_v6_at_height( Network::ZcashTestnet, &wallet_keys, @@ -790,28 +793,55 @@ fn add_transparent_output_rejects_unified_address_on_v6_psbt() { .unwrap(); assert!(z.is_ironwood_v6()); - let err = z + let index = 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}"); + .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), + Some(ua.to_string()) + ); - // The legacy form (no unified_address) is unaffected. - let index = z + // The bare form (no unified_address) still stores nothing, right alongside a UA output. + let plain = z .add_transparent_output( miniscript::bitcoin::ScriptBuf::from_bytes(transparent_script.clone()), - 100_000_000, + 100_000, None, ) .unwrap(); + assert_eq!(z.transparent_output_unified_address(plain), None); + + // Survives the serialize/deserialize round trip, not just in-memory state. + let bytes = z.serialize().unwrap(); + let round = ZcashBitGoPsbt::deserialize_v6_pre_shield(&bytes, Network::ZcashTestnet).unwrap(); + assert!(round.is_ironwood_v6()); assert_eq!( - z.psbt.unsigned_tx.output[index].script_pubkey.as_bytes(), + round.psbt.unsigned_tx.output[index] + .script_pubkey + .as_bytes(), transparent_script.as_slice() ); - assert_eq!(z.transparent_output_unified_address(index), None); + assert_eq!( + round.transparent_output_unified_address(index), + Some(ua.to_string()) + ); + + // Output parsing resolves the UA-carrying output's `address` to the stored UA itself, + // mirroring the shielded side's treatment of its stored UAs, while the plain output + // falls back to the bare transparent address. + let bitgo = BitGoPsbt::Zcash(round, Network::ZcashTestnet); + let parsed_outputs = bitgo + .parse_outputs_with_wallet_keys(&wallet_keys, &[]) + .unwrap(); + assert_eq!(parsed_outputs[index].address, Some(ua.to_string())); } /// `combine_inputs` refuses a v6 PSBT outright. diff --git a/packages/wasm-utxo/src/fixed_script_wallet/bitgo_psbt/zcash/v6.rs b/packages/wasm-utxo/src/fixed_script_wallet/bitgo_psbt/zcash/v6.rs index acac9624fcc..cdc78c6212d 100644 --- a/packages/wasm-utxo/src/fixed_script_wallet/bitgo_psbt/zcash/v6.rs +++ b/packages/wasm-utxo/src/fixed_script_wallet/bitgo_psbt/zcash/v6.rs @@ -194,17 +194,18 @@ impl ZcashBitGoPsbt { /// 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 + /// If `unified_address` is given, it 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. + /// rather than just the bare scriptPubkey, and output parsing prefers it as `address`. + /// + /// This works on both v4 and v6 (Ironwood) PSBTs: the transparent UA lives under its own + /// `TransparentUnifiedAddress` subtype in the `BITGO_ZEC_V6` namespace, disjoint from the + /// shielded UAs (`UnifiedAddress`, keyed by *action index*), so a v6 PSBT can carry a + /// deshield payout's UA and shielded change UAs side by side without colliding. pub fn add_transparent_output( &mut self, script: miniscript::bitcoin::ScriptBuf, @@ -226,12 +227,6 @@ impl ZcashBitGoPsbt { ); }; - 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}"))?; diff --git a/packages/wasm-utxo/test/fixedScript/zcashTransparentUnifiedAddress.ts b/packages/wasm-utxo/test/fixedScript/zcashTransparentUnifiedAddress.ts index b1ec0be9dd5..85ab0a6a4d2 100644 --- a/packages/wasm-utxo/test/fixedScript/zcashTransparentUnifiedAddress.ts +++ b/packages/wasm-utxo/test/fixedScript/zcashTransparentUnifiedAddress.ts @@ -46,7 +46,7 @@ const WALLET = uaFixtures.testnetWallet; // 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 () { +describe("ZcashBitGoPsbt.addTransparentOutput (unified_address)", function () { const walletKeys = getWalletKeysForSeed("v4-ua-transparent-ts"); function buildLegacyPsbt(): ZcashBitGoPsbt { @@ -125,6 +125,26 @@ describe("ZcashBitGoPsbt.addTransparentOutput (legacy v4 unified_address)", func assert.deepStrictEqual(new Uint8Array(outputs[plainIndex].script), plainScript); }); + it("supports a unifiedAddress on a v6 (Ironwood) PSBT's transparent output", function () { + const psbt = ZcashIronwoodBitGoPsbt.createEmpty("zcashTest", walletKeys, { + blockHeight: NU6_3_TESTNET_HEIGHT, + }); + const index = psbt.addTransparentOutput(TRANSPARENT_SCRIPT, 100_000_000n, WALLET.unified); + assert.strictEqual(psbt.transparentOutputUnifiedAddress(index), WALLET.unified); + + // The bare form (no unifiedAddress) still stores nothing, right alongside a UA output. + const plain = psbt.addTransparentOutput(TRANSPARENT_SCRIPT, 100_000n); + assert.strictEqual(psbt.transparentOutputUnifiedAddress(plain), undefined); + + const bytes = psbt.serialize(); + const round = ZcashIronwoodBitGoPsbt.fromBytes(bytes, "zcashTest"); + assert.strictEqual(round.transparentOutputUnifiedAddress(index), WALLET.unified); + const outputs = round.parseOutputsWithWalletKeys(walletKeys); + assert.deepStrictEqual(new Uint8Array(outputs[index].script), TRANSPARENT_SCRIPT); + // The parsed output's `address` is the exact UA the client passed in. + assert.strictEqual(outputs[index].address, WALLET.unified); + }); + describe("failure scenarios", function () { it("rejects a unifiedAddress whose transparent receiver does not match script", function () { const psbt = buildLegacyPsbt(); @@ -162,19 +182,5 @@ describe("ZcashBitGoPsbt.addTransparentOutput (legacy v4 unified_address)", func // 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); - }); }); });