diff --git a/modules/abstract-utxo/src/impl/zec/index.ts b/modules/abstract-utxo/src/impl/zec/index.ts index 3e05c1b7b9..77e51324bb 100644 --- a/modules/abstract-utxo/src/impl/zec/index.ts +++ b/modules/abstract-utxo/src/impl/zec/index.ts @@ -1,3 +1,5 @@ export * from './zec'; export * from './tzec'; export * from './address'; +export * from './recipients'; +export * from './types'; diff --git a/modules/abstract-utxo/src/impl/zec/recipients.ts b/modules/abstract-utxo/src/impl/zec/recipients.ts new file mode 100644 index 0000000000..d56a4f88c9 --- /dev/null +++ b/modules/abstract-utxo/src/impl/zec/recipients.ts @@ -0,0 +1,153 @@ +import { fixedScriptWallet, zcashAddress } from '@bitgo/wasm-utxo'; + +import { getReplayProtectionPubkeys } from '../../transaction/fixedScript/replayProtection'; + +import { ZcashCoinName, UnifiedRecipientPreference } from './types'; + +/** + * How a recipient parsed from a Zcash PSBT is spent. + * + * The decode-side counterpart of utxo-core's `buildTransaction/zcash.ts` `ZcashDestination` on + * the build side: a shielded recipient is an Orchard/Ironwood output stored in the v6 (Ironwood) + * PSBT's orchard PCZT, and everything else is an ordinary transparent output. A transparent + * output resolved from a Unified Address carries that original UA (`zcashUnifiedTransparent`), a + * plain address does not. + */ +export type PsbtRecipientDestination = + | { + kind: 'zcashShielded'; + /** + * The Unified Address the output was addressed to — the original multi-receiver UA the + * client passed when the PSBT stores one verbatim, otherwise a re-encoded single-receiver + * Orchard UA. + */ + unifiedAddress: string; + } + | { + kind: 'zcashUnifiedTransparent'; + /** The original Unified Address the transparent receiver was resolved from. */ + unifiedAddress: string; + } + | { kind: 'transparent' }; + +/** A recipient resolved from a decoded Zcash PSBT's external outputs. */ +export interface PsbtRecipient { + /** Amount in satoshis. */ + amount: bigint; + /** + * The recipient address. For a shielded output this is the Unified Address the output was + * addressed to — the original multi-receiver UA when the PSBT stores one verbatim, otherwise a + * re-encoded single-receiver Orchard UA. For a transparent output it is the original Unified + * Address when one was stored, else the decoded transparent address. + */ + address: string; + /** + * Raw receiver bytes: the 43-byte Orchard/Ironwood receiver for a shielded output, the + * scriptPubKey for a transparent one. + */ + script: Uint8Array; + /** + * The original Unified Address the client supplied for this recipient, when the PSBT stores + * one: the v6 (Ironwood) PCZT for a shielded output, the transparent-output proprietary + * key-value map for a v4 transparent output. `undefined` when the recipient was built from a + * plain address. + */ + unifiedAddress?: string; + destination: PsbtRecipientDestination; +} + +/** + * Resolve the recipient list of a decoded Zcash PSBT (v4 Sapling-shaped or v6 Ironwood). + * + * Mirrors the recipient resolution of wallet-platform's utxo-core `buildTransaction` in the + * decode direction: every non-wallet output with a resolvable address is a recipient. A + * shielded output parses with `isShielded: true`, its `script` being the raw 43-byte receiver; + * when the build stored the client's original Unified Address (the v6 PCZT for shielded + * outputs, the transparent-output proprietary key-value map for v4), both the parsed address + * and `unifiedAddress` report it verbatim. Opaque outputs with no address (e.g. OP_RETURN) are + * skipped, as they carry no recipient. + */ +export function resolvePsbtRecipients( + psbt: fixedScriptWallet.ZcashBitGoPsbt, + walletKeys: fixedScriptWallet.RootWalletKeys +): PsbtRecipient[] { + const parsed = psbt.parseTransactionWithWalletKeys(walletKeys, { + replayProtection: { publicKeys: getReplayProtectionPubkeys('zec') }, + }); + + const recipients: PsbtRecipient[] = []; + parsed.outputs.forEach((output, i) => { + // Wallet-owned (change) outputs. + if (output.scriptId !== null) { + return; + } + // Opaque outputs (e.g. OP_RETURN) carry no recipient address. + if (output.address === null) { + return; + } + // The original client-passed Unified Address, stored verbatim in the PSBT's key-value + // pairs: the orchard PCZT for a shielded output (parsed `address` reports it in full), the + // transparent-output proprietary map for a v4 transparent output. + const unifiedAddress = output.isShielded ? output.address : psbt.transparentOutputUnifiedAddress(i) ?? undefined; + recipients.push({ + amount: output.value, + address: output.address, + script: output.script, + unifiedAddress, + destination: output.isShielded + ? { kind: 'zcashShielded', unifiedAddress: output.address } + : unifiedAddress + ? { kind: 'zcashUnifiedTransparent', unifiedAddress } + : { kind: 'transparent' }, + }); + }); + return recipients; +} + +/** + * Infer the Unified-Address recipient preference for a Zcash transaction when the caller did + * not pass one — the counterpart of wallet-platform's utxo-core `buildTransaction` + * `classifyRecipientShieldedness`. + * + * A recipient that resolves to a transparent output — an ordinary transparent address, or a + * Unified Address carrying a transparent receiver — is classified `'transparent'`; a Unified + * Address carrying only an Orchard/Ironwood receiver is classified `'shielded'`. A mix of + * shielded and transparent recipients is rejected. An address that is neither a transparent + * address nor a Unified Address propagates the Unified-Address parse error — it is not + * silently defaulted to `'transparent'`. + * + * @returns `'shielded'` when every recipient resolves shielded, `undefined` when every + * recipient resolves transparent (the build's default). The `'transparent'` arm of the + * return type exists so callers can pass the explicit preference through unchanged; this + * function itself never returns `'transparent'`. + */ +export function getUnifiedRecipientPreference( + name: ZcashCoinName, + recipients: { address: string | undefined }[] +): UnifiedRecipientPreference | undefined { + const shieldedness = recipients.map((recipient) => { + if (recipient.address === undefined) { + // Raw script inherently transparent. + return 'transparent' as const; + } + // Ordinary transparent address, or a Unified Address carrying a transparent receiver: + // resolves transparently either way (the build's default when no preference is given). + if (zcashAddress.hasTransparentReceiver(recipient.address, name)) { + return 'transparent' as const; + } + // A shielded (Orchard/Ironwood-only) Unified Address is the only remaining resolvable + // form. An address that is none of the above propagates the parse error instead of + // assuming a default. + const unified = fixedScriptWallet.ZcashUnifiedAddress.parse(recipient.address, name); + if (unified.hasOrchardReceiver) { + return 'shielded' as const; + } + throw new Error(`address ${recipient.address} carries no transparent or Orchard receiver`); + }); + const hasShielded = shieldedness.includes('shielded'); + const hasTransparent = shieldedness.includes('transparent'); + if (hasShielded && hasTransparent) { + throw new Error('Mixed shielded and transparent recipients are not supported'); + } + return hasShielded ? 'shielded' : undefined; +} diff --git a/modules/abstract-utxo/src/impl/zec/types.ts b/modules/abstract-utxo/src/impl/zec/types.ts new file mode 100644 index 0000000000..30ea4b3aba --- /dev/null +++ b/modules/abstract-utxo/src/impl/zec/types.ts @@ -0,0 +1,5 @@ +/** A Zcash coin name — the only UTXO coins with shielded (Orchard/Ironwood) support. */ +export type ZcashCoinName = 'zec' | 'tzec'; + +/** How a Zcash Unified Address recipient should be resolved: to its shielded (Orchard/Ironwood) receiver or its transparent receiver. */ +export type UnifiedRecipientPreference = 'shielded' | 'transparent'; diff --git a/modules/abstract-utxo/src/impl/zec/zec.ts b/modules/abstract-utxo/src/impl/zec/zec.ts index 8aed41fd92..f20e03d628 100644 --- a/modules/abstract-utxo/src/impl/zec/zec.ts +++ b/modules/abstract-utxo/src/impl/zec/zec.ts @@ -2,13 +2,11 @@ * @prettier */ import { BitGoBase } from '@bitgo/sdk-core'; -import { fixedScriptWallet } from '@bitgo/wasm-utxo'; +import { zcashAddress } from '@bitgo/wasm-utxo'; import { AbstractUtxoCoin } from '../../abstractUtxoCoin'; import { UtxoCoinName } from '../../names'; -import { isShieldedZcashAddress } from './address'; - export class Zec extends AbstractUtxoCoin { readonly name: UtxoCoinName = 'zec'; @@ -21,9 +19,8 @@ export class Zec extends AbstractUtxoCoin { } isValidAddress(address: string, param?: { anyFormat?: boolean; allowLightning?: boolean } | boolean): boolean { - if (super.isValidAddress(address, param)) { - return true; - } - return isShieldedZcashAddress(address, this.name as fixedScriptWallet.ZcashNetworkName); + return ( + zcashAddress.hasTransparentReceiver(address, this.name) || zcashAddress.hasOrchardReceiver(address, this.name) + ); } } diff --git a/modules/abstract-utxo/test/unit/impl/zec/unit/resolvePsbtRecipients.ts b/modules/abstract-utxo/test/unit/impl/zec/unit/resolvePsbtRecipients.ts new file mode 100644 index 0000000000..e710e549eb --- /dev/null +++ b/modules/abstract-utxo/test/unit/impl/zec/unit/resolvePsbtRecipients.ts @@ -0,0 +1,108 @@ +import assert from 'node:assert/strict'; + +import { address as wasmAddress, fixedScriptWallet } from '@bitgo/wasm-utxo'; + +import { resolvePsbtRecipients } from '../../../../../src/impl/zec/recipients'; +import { getDefaultWasmWalletKeys } from '../../../util'; + +// ZIP-316 unified-address test vector, copied from +// BitGoWASM/packages/wasm-utxo/test/fixtures/zcash/unified_address.json so both repos test +// against the same known-good data. +const testnetWallet = { + unified: + 'utest1w5m0qcnp8egl8qa296n70n8nvj0tqnzk90p7f48v7mjhhdrdqs8vgqydslg5plmzefawefnpmgmlm6hcy38m972erwxs04s02cq2prhguz8kqly75m6zjy56m08d5jnycgtpqtjeprte576gkmrxyszepgx76yzuwhh7m4lfz9jaq7unjk0x5ant46juxz73hsc6q4v3dqtzww00vps', + transparentAddress: 'tmM4DvLVJKXZt5ydn1tqYTHvahpKSwgjuRk', +}; +const IRONWOOD_HEIGHT = 4200000; // after the NU6.3 testnet activation (4134000) +const IRONWOOD_RECEIVER = Buffer.from( + 'd632c28aa0831d671be17709a42c9627e2eb687a1b2a55768ea470c9bae7499cd0bd3d0eb0484e307236b5', + 'hex' +); + +describe('resolvePsbtRecipients', function () { + const { walletKeys } = getDefaultWasmWalletKeys(); + + function buildV4Psbt(unifiedAddress?: string): fixedScriptWallet.ZcashBitGoPsbt { + const psbt = fixedScriptWallet.ZcashBitGoPsbt.createEmpty('tzec', walletKeys, { blockHeight: 3146400 }); + psbt.addWalletInput({ txid: '22'.repeat(32), vout: 0, value: 200000n }, walletKeys, { + scriptId: { chain: 0, index: 1 }, + }); + psbt.addWalletOutput(walletKeys, { chain: 1, index: 0, value: 100000n }); + const externalScript = wasmAddress.toOutputScriptWithCoin(testnetWallet.transparentAddress, 'tzec'); + psbt.addTransparentOutput(externalScript, 12345n, unifiedAddress); + return psbt; + } + + function buildV6Psbt(unifiedAddress?: string): fixedScriptWallet.ZcashIronwoodBitGoPsbt { + const psbt = fixedScriptWallet.ZcashIronwoodBitGoPsbt.createEmpty('tzec', walletKeys, { + blockHeight: IRONWOOD_HEIGHT, + }); + psbt.addWalletInput({ txid: '11'.repeat(32), vout: 0, value: 100000n }, walletKeys, { + scriptId: { chain: 0, index: 0 }, + }); + psbt.addWalletOutput(walletKeys, { chain: 1, index: 0, value: 90000n }); + psbt.addShieldedOutputs( + [{ recipient: new Uint8Array(IRONWOOD_RECEIVER), amount: 5000n, unifiedAddress }], + new Uint8Array(32) // all-zero anchor, as in the utxo-core shielded build tests + ); + return psbt; + } + + it('resolves external transparent outputs and excludes wallet change (v4)', function () { + const recipients = resolvePsbtRecipients(buildV4Psbt(), walletKeys); + assert.strictEqual(recipients.length, 1); + const recipient = recipients[0]; + assert.strictEqual(recipient.destination.kind, 'transparent'); + assert.strictEqual(recipient.address, testnetWallet.transparentAddress); + assert.strictEqual(recipient.amount, 12345n); + assert.strictEqual(recipient.unifiedAddress, undefined); + assert.deepStrictEqual( + Buffer.from(recipient.script), + Buffer.from(wasmAddress.toOutputScriptWithCoin(testnetWallet.transparentAddress, 'tzec')) + ); + }); + + it('reports the original UA verbatim for a transparent output built from a Unified Address (v4)', function () { + const recipients = resolvePsbtRecipients(buildV4Psbt(testnetWallet.unified), walletKeys); + assert.strictEqual(recipients.length, 1); + const recipient = recipients[0]; + assert.deepStrictEqual(recipient.destination, { + kind: 'zcashUnifiedTransparent', + unifiedAddress: testnetWallet.unified, + }); + assert.strictEqual(recipient.unifiedAddress, testnetWallet.unified); + // For a transparent output built from a Unified Address, the parsed address is the UA + // itself (verbatim), not just the transparent receiver. + assert.strictEqual(recipient.address, testnetWallet.unified); + }); + + it('resolves a shielded v6 output to its (re-encoded) Orchard Unified Address recipient', function () { + const recipients = resolvePsbtRecipients(buildV6Psbt(), walletKeys); + assert.strictEqual(recipients.length, 1); + const recipient = recipients[0]; + const expectedAddress = fixedScriptWallet.ZcashUnifiedAddress.encodeOrchardReceiver( + new Uint8Array(IRONWOOD_RECEIVER), + 'tzec' + ); + assert.deepStrictEqual(recipient.destination, { + kind: 'zcashShielded', + unifiedAddress: expectedAddress, + }); + assert.strictEqual(recipient.address, expectedAddress); + assert.strictEqual(recipient.amount, 5000n); + assert.deepStrictEqual(Buffer.from(recipient.script), IRONWOOD_RECEIVER); + }); + + it('reports the original UA verbatim for a shielded output built from a Unified Address (v6)', function () { + const orchardOnlyUa = fixedScriptWallet.ZcashUnifiedAddress.encodeOrchardReceiver( + new Uint8Array(IRONWOOD_RECEIVER), + 'tzec' + ); + const recipients = resolvePsbtRecipients(buildV6Psbt(orchardOnlyUa), walletKeys); + assert.strictEqual(recipients.length, 1); + const recipient = recipients[0]; + assert.deepStrictEqual(recipient.destination, { kind: 'zcashShielded', unifiedAddress: orchardOnlyUa }); + assert.strictEqual(recipient.unifiedAddress, orchardOnlyUa); + assert.strictEqual(recipient.address, orchardOnlyUa); + }); +}); diff --git a/modules/abstract-utxo/test/unit/impl/zec/unit/unifiedRecipientPreference.ts b/modules/abstract-utxo/test/unit/impl/zec/unit/unifiedRecipientPreference.ts new file mode 100644 index 0000000000..af9d12c1db --- /dev/null +++ b/modules/abstract-utxo/test/unit/impl/zec/unit/unifiedRecipientPreference.ts @@ -0,0 +1,73 @@ +import assert from 'node:assert/strict'; + +import { fixedScriptWallet } from '@bitgo/wasm-utxo'; + +import { getUnifiedRecipientPreference } from '../../../../../src/impl/zec/recipients'; + +// ZIP-316 unified-address test vectors, copied from +// BitGoWASM/packages/wasm-utxo/test/fixtures/zcash/unified_address.json so +// both repos test against the same known-good data. +const zip316Mainnet = { + unified: + 'u1pg2aaph7jp8rpf6yhsza25722sg5fcn3vaca6ze27hqjw7jvvhhuxkpcg0ge9xh6drsgdkda8qjq5chpehkcpxf87rnjryjqwymdheptpvnljqqrjqzjwkc2ma6hcq666kgwfytxwac8eyex6ndgr6ezte66706e3vaqrd25dzvzkc69kw0jgywtd0cmq52q5lkw6uh7hyvzjse8ksx', +}; +const testnetWallet = { + unified: + 'utest1w5m0qcnp8egl8qa296n70n8nvj0tqnzk90p7f48v7mjhhdrdqs8vgqydslg5plmzefawefnpmgmlm6hcy38m972erwxs04s02cq2prhguz8kqly75m6zjy56m08d5jnycgtpqtjeprte576gkmrxyszepgx76yzuwhh7m4lfz9jaq7unjk0x5ant46juxz73hsc6q4v3dqtzww00vps', + transparentAddress: 'tmM4DvLVJKXZt5ydn1tqYTHvahpKSwgjuRk', +}; + +describe('getUnifiedRecipientPreference', function () { + function orchardOnlyUa(network: 'zec' | 'tzec'): string { + return fixedScriptWallet.ZcashUnifiedAddress.encodeOrchardReceiver(new Uint8Array(43).fill(0x42), network); + } + + it('returns undefined for no recipients', function () { + assert.strictEqual(getUnifiedRecipientPreference('tzec', []), undefined); + }); + + it('returns undefined for an ordinary transparent address', function () { + assert.strictEqual( + getUnifiedRecipientPreference('tzec', [{ address: testnetWallet.transparentAddress }]), + undefined + ); + }); + + it('returns undefined for a raw-script recipient with no address', function () { + assert.strictEqual(getUnifiedRecipientPreference('tzec', [{ address: undefined }]), undefined); + }); + + it("returns 'shielded' for an Orchard-only Unified Address", function () { + assert.strictEqual(getUnifiedRecipientPreference('tzec', [{ address: orchardOnlyUa('tzec') }]), 'shielded'); + }); + + it("infers 'shielded' on mainnet", function () { + assert.strictEqual(getUnifiedRecipientPreference('zec', [{ address: orchardOnlyUa('zec') }]), 'shielded'); + }); + + it('classifies a Unified Address carrying transparent + Orchard receivers as transparent', function () { + // Both official vectors carry a transparent receiver alongside the Orchard one, so the + // transparent-first classification applies even though they also classify as shielded. + assert.strictEqual(getUnifiedRecipientPreference('tzec', [{ address: testnetWallet.unified }]), undefined); + assert.strictEqual(getUnifiedRecipientPreference('zec', [{ address: zip316Mainnet.unified }]), undefined); + }); + + it('fails hard for an unrecognizable address instead of defaulting to transparent', function () { + assert.throws(() => getUnifiedRecipientPreference('tzec', [{ address: 'not-an-address' }])); + }); + + it('fails hard for a unified address on the wrong network', function () { + assert.throws(() => getUnifiedRecipientPreference('tzec', [{ address: zip316Mainnet.unified }])); + }); + + it('rejects a mix of shielded and transparent recipients', function () { + assert.throws( + () => + getUnifiedRecipientPreference('tzec', [ + { address: orchardOnlyUa('tzec') }, + { address: testnetWallet.transparentAddress }, + ]), + /Mixed shielded and transparent recipients/ + ); + }); +});