Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 4 additions & 3 deletions packages/wasm-utxo/js/fixedScriptWallet/ZcashBitGoPsbt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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,
Expand All @@ -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.
Expand Down
25 changes: 10 additions & 15 deletions packages/wasm-utxo/src/fixed_script_wallet/bitgo_psbt/zcash/v6.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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}"))?;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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);
});
});
});
Loading