From d3a9a4fde10014a6ecda96eee1057cc6a274b35c Mon Sep 17 00:00:00 2001 From: Veetrag Jain Date: Sat, 5 Sep 2026 01:19:44 +0530 Subject: [PATCH] fix(abstract-utxo): add zec psbt decode and UA resolution support Ticket: CSHLD-1639 --- modules/abstract-utxo/src/abstractUtxoCoin.ts | 16 + modules/abstract-utxo/src/impl/zec/index.ts | 1 + .../abstract-utxo/src/impl/zec/recipients.ts | 125 +++++++ modules/abstract-utxo/src/impl/zec/zec.ts | 126 ++++++- .../transaction/fixedScript/parseOutput.ts | 7 +- .../fixedScript/parseTransaction.ts | 14 +- .../src/transaction/recipient.ts | 21 +- .../unit/fixtures/tzec/unified_address.json | 8 + .../unit/fixtures/zec/unified_address.json | 7 + .../unit/impl/zec/shieldedPrebuildAndSign.ts | 91 +++++ .../test/unit/impl/zec/unifiedAddress.ts | 317 ++++++++++++++++++ .../test/unit/transaction/recipient.ts | 74 ++++ .../sdk-core/src/bitgo/wallet/BuildParams.ts | 2 + modules/sdk-core/src/bitgo/wallet/iWallet.ts | 6 + 14 files changed, 803 insertions(+), 12 deletions(-) create mode 100644 modules/abstract-utxo/src/impl/zec/recipients.ts create mode 100644 modules/abstract-utxo/test/unit/fixtures/tzec/unified_address.json create mode 100644 modules/abstract-utxo/test/unit/fixtures/zec/unified_address.json create mode 100644 modules/abstract-utxo/test/unit/impl/zec/shieldedPrebuildAndSign.ts create mode 100644 modules/abstract-utxo/test/unit/impl/zec/unifiedAddress.ts diff --git a/modules/abstract-utxo/src/abstractUtxoCoin.ts b/modules/abstract-utxo/src/abstractUtxoCoin.ts index e4fb3c2333..abc7dbf590 100644 --- a/modules/abstract-utxo/src/abstractUtxoCoin.ts +++ b/modules/abstract-utxo/src/abstractUtxoCoin.ts @@ -254,6 +254,12 @@ export interface TransactionParams extends BaseTransactionParams { /** Parameters for bridging intents (e.g. BTC -> sBTC peg-in), present when `type === 'bridging'`. */ bridgingParams?: BridgingParams; qr?: boolean; + /** + * Zcash-only: how to resolve a Unified Address recipient. `'shielded'` resolves it to its + * Orchard/Ironwood receiver (a shielded output); any other value (or omission) resolves it to + * its transparent receiver. Ignored for non-Zcash coins and for non-Unified-Address recipients. + */ + unifiedRecipientPreference?: string; } export interface ParseTransactionOptions extends BaseParseTransactionOptions { @@ -544,6 +550,16 @@ export abstract class AbstractUtxoCoin extends BaseCoin implements Musig2Partici } } + /** + * Resolve a transaction-address (not a raw scriptPubKey) to its output script. Base + * implementation defers to wasm-utxo's coin-agnostic address decoding. Overridable by coins + * whose address space needs additional context to resolve — e.g. Zcash Unified Addresses, + * which resolve differently depending on `unifiedRecipientPreference`. + */ + resolveOutputScript(address: string, unifiedRecipientPreference?: string): Uint8Array { + return wasmAddress.toOutputScriptWithCoin(address, this.name); + } + /** * Run custom coin logic after a transaction prebuild has been received from BitGo * @param prebuild diff --git a/modules/abstract-utxo/src/impl/zec/index.ts b/modules/abstract-utxo/src/impl/zec/index.ts index 707e753101..dc8f21a887 100644 --- a/modules/abstract-utxo/src/impl/zec/index.ts +++ b/modules/abstract-utxo/src/impl/zec/index.ts @@ -1,2 +1,3 @@ export * from './zec'; +export * from './recipients'; export * from './tzec'; 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..887dca20ea --- /dev/null +++ b/modules/abstract-utxo/src/impl/zec/recipients.ts @@ -0,0 +1,125 @@ +/** + * @prettier + */ +import { fixedScriptWallet } from '@bitgo/wasm-utxo'; +import { Triple } from '@bitgo/sdk-core'; + +import { getReplayProtectionPubkeys } from '../../transaction/fixedScript/replayProtection'; + +/** + * 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 (or the single-receiver UA re-encoding is byte-identical for a shielded + * output). + */ + unifiedAddress?: string; + destination: PsbtRecipientDestination; +} + +export type ResolvePsbtRecipientsOptions = { + /** + * Custom change wallet xpubs, when the transaction spends to a custom change wallet. Outputs + * matching these keys are classified as change, not recipients — matching how + * `explainPsbtWasm` treats them. + */ + customChangeXpubs?: Triple; +}; + +/** + * 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, non-custom-change 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, + opts: ResolvePsbtRecipientsOptions = {} +): PsbtRecipient[] { + const parsed = psbt.parseTransactionWithWalletKeys(walletKeys, { + replayProtection: { publicKeys: getReplayProtectionPubkeys('zec') }, + }); + const customChangeOutputs = opts.customChangeXpubs + ? psbt.parseOutputsWithWalletKeys(opts.customChangeXpubs) + : undefined; + + const recipients: PsbtRecipient[] = []; + parsed.outputs.forEach((output, i) => { + // Wallet-owned (change) outputs. + if (output.scriptId !== null) { + return; + } + // Outputs owned by the custom change wallet, if one was supplied. + if (customChangeOutputs?.[i]?.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; +} diff --git a/modules/abstract-utxo/src/impl/zec/zec.ts b/modules/abstract-utxo/src/impl/zec/zec.ts index 0ee1df3081..f091d3e30e 100644 --- a/modules/abstract-utxo/src/impl/zec/zec.ts +++ b/modules/abstract-utxo/src/impl/zec/zec.ts @@ -1,11 +1,36 @@ /** * @prettier */ -import { BitGoBase } from '@bitgo/sdk-core'; +import { + address as wasmAddress, + fixedScriptWallet, + hasPsbtMagic, + isWasmUtxoError, + zcashAddress as wasmZcashAddress, +} from '@bitgo/wasm-utxo'; +import { BitGoBase, ExtraPrebuildParamsOptions, Wallet } from '@bitgo/sdk-core'; import { AbstractUtxoCoin } from '../../abstractUtxoCoin'; +import { stringToBufferTryFormats } from '../../transaction/decode'; import { UtxoCoinName } from '../../names'; +import { resolvePsbtRecipients, ResolvePsbtRecipientsOptions, PsbtRecipient } from './recipients'; + +/** + * Parse `address` as a ZIP-316 Unified Address for `network`, or return `undefined` if it isn't + * one (malformed, wrong network, or not bech32m-shaped at all). + */ +function tryParseUnifiedAddress( + address: string, + network: 'zec' | 'tzec' +): fixedScriptWallet.ZcashUnifiedAddress | undefined { + try { + return fixedScriptWallet.ZcashUnifiedAddress.parse(address, network); + } catch (e) { + return undefined; + } +} + export class Zec extends AbstractUtxoCoin { readonly name: UtxoCoinName = 'zec'; @@ -16,4 +41,103 @@ export class Zec extends AbstractUtxoCoin { static createInstance(bitgo: BitGoBase): Zec { return new Zec(bitgo); } + + /** + * Forward `unifiedRecipientPreference` alongside the standard extra build params. Zcash builds + * that carry this preference always go through the wasm-utxo (Ironwood/v6-capable) build path + * on Wallet Platform rather than the legacy utxolib path, since utxolib has no notion of + * Unified Addresses or shielded outputs. + */ + override async getExtraPrebuildParams(buildParams: ExtraPrebuildParamsOptions & { wallet: Wallet }) { + const extraParams = await super.getExtraPrebuildParams(buildParams); + const unifiedRecipientPreference = buildParams.unifiedRecipientPreference as string | undefined; + if (unifiedRecipientPreference === undefined) { + return extraParams; + } + return { ...extraParams, unifiedRecipientPreference }; + } + + /** + * In addition to ordinary transparent addresses, Zcash accepts ZIP-316 Unified Addresses that + * carry a transparent receiver, an Orchard/Ironwood receiver, or both. `unifiedRecipientPreference` + * (which of those receivers a build should spend to) is not this method's concern — it only + * answers whether `address` is a spendable address at all. + */ + override isValidAddress( + address: string, + param?: { anyFormat?: boolean; allowLightning?: boolean } | boolean + ): boolean { + const unifiedAddress = tryParseUnifiedAddress(address, this.name as 'zec' | 'tzec'); + if (unifiedAddress !== undefined) { + return unifiedAddress.transparentScript !== undefined || unifiedAddress.orchardReceiver !== undefined; + } + return super.isValidAddress(address, param); + } + + /** + * Resolve `address` to an output script. For a Unified Address, `unifiedRecipientPreference === + * 'shielded'` resolves to the raw 43-byte Orchard/Ironwood receiver (a shielded output, no + * scriptPubKey) instead of the default transparent scriptPubKey. Non-Unified addresses and any + * other `unifiedRecipientPreference` value are unaffected and resolve exactly as the base + * implementation would. + */ + override resolveOutputScript(address: string, unifiedRecipientPreference?: string): Uint8Array { + if (unifiedRecipientPreference === 'shielded') { + return wasmZcashAddress.toShieldedReceiverWithCoin(address, this.name); + } + return wasmAddress.toOutputScriptWithCoin(address, this.name); + } + + /** + * Zcash v6 (Ironwood) PSBTs carry their shielded side as an orchard PCZT and cannot be + * deserialized by the generic `ZcashBitGoPsbt` — attempt that first (the common, non-shielding + * case) and fall back to `ZcashIronwoodBitGoPsbt.fromBytes` for v6-shaped bytes. + */ + override decodeTransaction(input: Buffer | string): fixedScriptWallet.BitGoPsbt { + const buffer = typeof input === 'string' ? stringToBufferTryFormats(input, ['hex', 'base64']) : input; + if (!hasPsbtMagic(buffer)) { + return super.decodeTransaction(input); + } + try { + return fixedScriptWallet.ZcashBitGoPsbt.fromBytes(buffer, this.name as 'zec' | 'tzec'); + } catch (e) { + // `ZcashBitGoPsbt.fromBytes` signals v6 (Ironwood) bytes with a plain Error (not a + // WasmUtxoError) telling the caller to use `ZcashIronwoodBitGoPsbt.fromBytes` instead — + // see its doc comment. Fall back for that message as well as wasm-layer errors. + if (isWasmUtxoError(e) || (e instanceof Error && e.message.includes('v6 (Ironwood)'))) { + return fixedScriptWallet.ZcashIronwoodBitGoPsbt.fromBytes(buffer, this.name as 'zec' | 'tzec'); + } + throw e; + } + } + + override decodeTransactionFromPrebuild(prebuild: { + txHex?: string; + txBase64?: string; + txHexPsbt?: string; + }): fixedScriptWallet.BitGoPsbt { + const string = prebuild.txHexPsbt ?? prebuild.txHex ?? prebuild.txBase64; + if (!string) { + throw new Error('missing required txHex or txBase64 property'); + } + return this.decodeTransaction(string); + } + + /** + * Decode a Zcash PSBT (v4 Sapling-shaped or v6 Ironwood) and resolve its recipient list. + * The decode-side counterpart of the wallet-platform build path's recipient resolution: + * shielded outputs resolve to their single-receiver Orchard Unified Address, transparent + * outputs to their transparent address. Change and custom-change outputs are excluded. + */ + resolveRecipientsFromPsbt( + input: Buffer | string, + walletKeys: fixedScriptWallet.RootWalletKeys, + opts: ResolvePsbtRecipientsOptions = {} + ): PsbtRecipient[] { + const psbt = this.decodeTransaction(input); + if (!(psbt instanceof fixedScriptWallet.ZcashBitGoPsbt)) { + throw new Error('expected a Zcash PSBT'); + } + return resolvePsbtRecipients(psbt, walletKeys, opts); + } } diff --git a/modules/abstract-utxo/src/transaction/fixedScript/parseOutput.ts b/modules/abstract-utxo/src/transaction/fixedScript/parseOutput.ts index c48fc2600a..4351e6d521 100644 --- a/modules/abstract-utxo/src/transaction/fixedScript/parseOutput.ts +++ b/modules/abstract-utxo/src/transaction/fixedScript/parseOutput.ts @@ -202,6 +202,7 @@ export interface ParseOutputOptions { txParams: { recipients: ITransactionRecipient[]; changeAddress?: string; + unifiedRecipientPreference?: string; }; customChange?: CustomChangeOptions; reqId?: IRequestTracer; @@ -279,9 +280,11 @@ export async function parseOutput({ * recipient list is > 1000 This is not always a valid assumption and could lead greater apparent spend (but never lower) */ if (txParams.recipients !== undefined && txParams.recipients.length > RECIPIENT_THRESHOLD) { + const resolveScript = (address: string): Uint8Array => + coin.resolveOutputScript(address, txParams.unifiedRecipientPreference); const isCurrentAddressInRecipients = txParams.recipients.some((recipient) => - fromExtendedAddressFormatToScript(recipient.address, coin.name).equals( - fromExtendedAddressFormatToScript(currentAddress, coin.name) + fromExtendedAddressFormatToScript(recipient.address, coin.name, resolveScript).equals( + fromExtendedAddressFormatToScript(currentAddress, coin.name, resolveScript) ) ); diff --git a/modules/abstract-utxo/src/transaction/fixedScript/parseTransaction.ts b/modules/abstract-utxo/src/transaction/fixedScript/parseTransaction.ts index e28e5525a5..8e7e9aa759 100644 --- a/modules/abstract-utxo/src/transaction/fixedScript/parseTransaction.ts +++ b/modules/abstract-utxo/src/transaction/fixedScript/parseTransaction.ts @@ -84,8 +84,11 @@ function toExpectedOutputs( recipients?: ITransactionRecipient[]; allowExternalChangeAddress?: boolean; changeAddress?: string; + unifiedRecipientPreference?: string; } ): ExpectedOutput[] { + const resolveScript = (address: string): Uint8Array => + coin.resolveOutputScript(address, txParams.unifiedRecipientPreference); // verify that each recipient from txParams has their own output const expectedOutputs: ExpectedOutput[] = (txParams.recipients ?? []).flatMap((output) => { if (output.address === undefined) { @@ -95,21 +98,21 @@ function toExpectedOutputs( } return [ { - script: toOutputScript(output, coin.name), + script: toOutputScript(output, coin.name, resolveScript), value: output.amount === 'max' ? 'max' : BigInt(output.amount), }, ]; } return [ { - script: fromExtendedAddressFormatToScript(output.address, coin.name), + script: fromExtendedAddressFormatToScript(output.address, coin.name, resolveScript), value: output.amount === 'max' ? 'max' : BigInt(output.amount), }, ]; }); if (txParams.allowExternalChangeAddress && txParams.changeAddress) { expectedOutputs.push({ - script: toOutputScript(txParams.changeAddress, coin.name), + script: toOutputScript(txParams.changeAddress, coin.name, resolveScript), // When an external change address is explicitly specified, count all outputs going towards that // address in the expected outputs (regardless of the output amount) value: 'max', @@ -232,6 +235,7 @@ export async function parseTransaction( txParams: { recipients: txParams.recipients ?? [], changeAddress: txParams.changeAddress, + unifiedRecipientPreference: txParams.unifiedRecipientPreference, }, customChange, reqId, @@ -247,7 +251,9 @@ export async function parseTransaction( function toComparableOutputsWithExternal(outputs: Output[]): ComparableOutputWithExternal[] { return outputs.map((output) => ({ - script: fromExtendedAddressFormatToScript(output.address, coin.name), + script: fromExtendedAddressFormatToScript(output.address, coin.name, (address) => + coin.resolveOutputScript(address, txParams.unifiedRecipientPreference) + ), value: output.amount === 'max' ? 'max' : (BigInt(output.amount) as bigint | 'max'), external: output.external, })); diff --git a/modules/abstract-utxo/src/transaction/recipient.ts b/modules/abstract-utxo/src/transaction/recipient.ts index 8dde2fbd43..20440e601b 100644 --- a/modules/abstract-utxo/src/transaction/recipient.ts +++ b/modules/abstract-utxo/src/transaction/recipient.ts @@ -24,23 +24,34 @@ export function fromExtendedAddressFormat(extendedAddress: string): { address: s return { address: extendedAddress }; } -export function fromExtendedAddressFormatToScript(extendedAddress: string, coinName: UtxoCoinName): Buffer { +export function fromExtendedAddressFormatToScript( + extendedAddress: string, + coinName: UtxoCoinName, + resolveScript?: (address: string, coinName: UtxoCoinName) => Uint8Array +): Buffer { const result = fromExtendedAddressFormat(extendedAddress); if ('script' in result) { return Buffer.from(result.script, 'hex'); } - return Buffer.from(address.toOutputScriptWithCoin(result.address, coinName)); + const script = resolveScript + ? resolveScript(result.address, coinName) + : address.toOutputScriptWithCoin(result.address, coinName); + return Buffer.from(script); } -export function toOutputScript(v: string | { address: string } | { script: string }, coinName: UtxoCoinName): Buffer { +export function toOutputScript( + v: string | { address: string } | { script: string }, + coinName: UtxoCoinName, + resolveScript?: (address: string, coinName: UtxoCoinName) => Uint8Array +): Buffer { if (typeof v === 'string') { - return fromExtendedAddressFormatToScript(v, coinName); + return fromExtendedAddressFormatToScript(v, coinName, resolveScript); } if ('script' in v) { return Buffer.from(v.script, 'hex'); } if ('address' in v) { - return fromExtendedAddressFormatToScript(v.address, coinName); + return fromExtendedAddressFormatToScript(v.address, coinName, resolveScript); } throw new Error('invalid input'); } diff --git a/modules/abstract-utxo/test/unit/fixtures/tzec/unified_address.json b/modules/abstract-utxo/test/unit/fixtures/tzec/unified_address.json new file mode 100644 index 0000000000..53d700fae9 --- /dev/null +++ b/modules/abstract-utxo/test/unit/fixtures/tzec/unified_address.json @@ -0,0 +1,8 @@ +{ + "_note": "ZIP-316 unified-address test vector for tzec (testnet), derived from wallet-data/testnet-wallet-full.json in the Ironwood reference sandbox. See @bitgo/wasm-utxo test/fixtures/zcash/unified_address.json (testnetWallet).", + "network": "tzec", + "unified": "utest1w5m0qcnp8egl8qa296n70n8nvj0tqnzk90p7f48v7mjhhdrdqs8vgqydslg5plmzefawefnpmgmlm6hcy38m972erwxs04s02cq2prhguz8kqly75m6zjy56m08d5jnycgtpqtjeprte576gkmrxyszepgx76yzuwhh7m4lfz9jaq7unjk0x5ant46juxz73hsc6q4v3dqtzww00vps", + "transparentAddress": "tmM4DvLVJKXZt5ydn1tqYTHvahpKSwgjuRk", + "ironwoodReceiverHex": "d632c28aa0831d671be17709a42c9627e2eb687a1b2a55768ea470c9bae7499cd0bd3d0eb0484e307236b5", + "transparentPubkeyHashHex": "7c6b843a25873c036aff575516e3802bcc47f634" +} diff --git a/modules/abstract-utxo/test/unit/fixtures/zec/unified_address.json b/modules/abstract-utxo/test/unit/fixtures/zec/unified_address.json new file mode 100644 index 0000000000..8e7a4851d2 --- /dev/null +++ b/modules/abstract-utxo/test/unit/fixtures/zec/unified_address.json @@ -0,0 +1,7 @@ +{ + "_note": "ZIP-316 unified-address test vector for zec (mainnet), from the official zcash-test-vectors (unified_address.py). See @bitgo/wasm-utxo test/fixtures/zcash/unified_address.json (zip316Mainnet).", + "network": "zec", + "unified": "u1pg2aaph7jp8rpf6yhsza25722sg5fcn3vaca6ze27hqjw7jvvhhuxkpcg0ge9xh6drsgdkda8qjq5chpehkcpxf87rnjryjqwymdheptpvnljqqrjqzjwkc2ma6hcq666kgwfytxwac8eyex6ndgr6ezte66706e3vaqrd25dzvzkc69kw0jgywtd0cmq52q5lkw6uh7hyvzjse8ksx", + "orchardReceiverHex": "cecbe5e689a453a3fe10ccf7617e6c1fb382819d7fc9200a1f42092ac84a30378f8c1fb90dff71a6d5042d", + "transparentPubkeyHashHex": "cad268758c5e71493066446b98e71df9d1d6a5ca" +} diff --git a/modules/abstract-utxo/test/unit/impl/zec/shieldedPrebuildAndSign.ts b/modules/abstract-utxo/test/unit/impl/zec/shieldedPrebuildAndSign.ts new file mode 100644 index 0000000000..d9cd945615 --- /dev/null +++ b/modules/abstract-utxo/test/unit/impl/zec/shieldedPrebuildAndSign.ts @@ -0,0 +1,91 @@ +import * as assert from 'assert'; + +import nock = require('nock'); +import { common } from '@bitgo/sdk-core'; +import { getSeed } from '@bitgo/sdk-test'; +import { fixedScriptWallet } from '@bitgo/wasm-utxo'; + +import { getUtxoCoin, defaultBitGo } from '../../util'; +import { getDefaultWasmWalletKeys, keychainsBase58 } from '../../util/keychains'; +import { Zec } from '../../../../src/impl/zec'; +/** + * Exercises every client-side flow that runs BEFORE verifyTransaction/signTransaction on a + * shielded (v6 Ironwood) prebuild: prebuild post-processing, explanation, and recipient + * resolution. Each of them must decode the v6 PSBT and resolve the shielded recipient without + * error. + */ +describe('Zec shielded pre-verify flows (v6 Ironwood PSBT)', function () { + const zec = getUtxoCoin('tzec'); + const bgUrl = common.Environments[defaultBitGo.getEnv()].uri; + const { walletKeys } = getDefaultWasmWalletKeys(); + + const keyDocumentObjects = keychainsBase58.map((keychain, keyIdx) => { + return { + id: getSeed(keychain.pub).toString('hex'), + pub: keychain.pub, + source: ['user', 'backup', 'bitgo'][keyIdx], + coinSpecific: {}, + }; + }); + const IRONWOOD_RECEIVER = Buffer.from( + 'd632c28aa0831d671be17709a42c9627e2eb687a1b2a55768ea470c9bae7499cd0bd3d0eb0484e307236b5', + 'hex' + ); + let unifiedAddress: string; + + before(function () { + unifiedAddress = fixedScriptWallet.ZcashUnifiedAddress.encodeOrchardReceiver( + new Uint8Array(IRONWOOD_RECEIVER), + 'tzec' + ); + }); + + function buildShieldedV6PrebuildHex(): string { + const psbt = fixedScriptWallet.ZcashIronwoodBitGoPsbt.createEmpty('tzec', walletKeys, { blockHeight: 4200000 }); + 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) + ); + return Buffer.from(psbt.serialize()).toString('hex'); + } + + afterEach(function () { + nock.cleanAll(); + }); + + it('sendMany recipient validation accepts the unified address', function () { + zec.checkRecipient({ address: unifiedAddress, amount: '5000' }); + }); + + it('postProcessPrebuild decodes the v6 psbt and re-encodes it unchanged', async function () { + const prebuildHex = buildShieldedV6PrebuildHex(); + nock(bgUrl).get('/api/v2/tzec/public/block/latest').reply(200, { height: 4200000 }); + const prebuild = await zec.postProcessPrebuild({ txHex: prebuildHex, txInfo: {} }); + assert.match(prebuild.txHex as string, /^70736274/); // PSBT magic preserved + const decoded = zec.decodeTransaction(prebuild.txHex as string); + assert.ok(decoded instanceof fixedScriptWallet.ZcashIronwoodBitGoPsbt); + }); + + it('explainTransaction decodes the v6 psbt and resolves the shielded recipient', async function () { + const explained = await zec.explainTransaction({ + txHex: buildShieldedV6PrebuildHex(), + pubs: [keyDocumentObjects[0].pub, keyDocumentObjects[1].pub, keyDocumentObjects[2].pub], + }); + assert.strictEqual(explained.outputs.length, 1); + assert.strictEqual(explained.outputs[0].address, unifiedAddress); + assert.strictEqual(explained.outputs[0].amount.toString(), '5000'); + assert.strictEqual(explained.changeOutputs.length, 1); + }); + + it('resolveRecipientsFromPsbt resolves the shielded recipient with its original UA', function () { + const recipients = (zec as Zec).resolveRecipientsFromPsbt(buildShieldedV6PrebuildHex(), walletKeys); + assert.strictEqual(recipients.length, 1); + assert.strictEqual(recipients[0].destination.kind, 'zcashShielded'); + assert.strictEqual(recipients[0].unifiedAddress, unifiedAddress); + assert.strictEqual(Buffer.from(recipients[0].script).toString('hex'), IRONWOOD_RECEIVER.toString('hex')); + }); +}); diff --git a/modules/abstract-utxo/test/unit/impl/zec/unifiedAddress.ts b/modules/abstract-utxo/test/unit/impl/zec/unifiedAddress.ts new file mode 100644 index 0000000000..2c989e102c --- /dev/null +++ b/modules/abstract-utxo/test/unit/impl/zec/unifiedAddress.ts @@ -0,0 +1,317 @@ +import assert from 'assert'; +import * as fs from 'fs'; +import * as path from 'path'; + +import * as sinon from 'sinon'; +import { fixedScriptWallet, isWasmUtxoError } from '@bitgo/wasm-utxo'; +import { ExtraPrebuildParamsOptions, Wallet } from '@bitgo/sdk-core'; + +import { getUtxoCoin, defaultBitGo, getDefaultWasmWalletKeys } from '../../util'; +import { Zec } from '../../../../src/impl/zec'; + +type UaVector = { + network: 'zec' | 'tzec'; + unified: string; + transparentAddress?: string; + orchardReceiverHex?: string; + ironwoodReceiverHex?: string; + transparentPubkeyHashHex: string; +}; + +const MAINNET_UA = JSON.parse( + fs.readFileSync(path.join(__dirname, '../../fixtures/zec/unified_address.json'), 'utf8') +) as UaVector; +const TESTNET_UA = JSON.parse( + fs.readFileSync(path.join(__dirname, '../../fixtures/tzec/unified_address.json'), 'utf8') +) as UaVector; + +describe('Zec Unified Address support', function () { + const zec = getUtxoCoin('zec'); + const tzec = getUtxoCoin('tzec'); + + describe('isValidAddress', function () { + it('accepts a mainnet unified address (transparent + Orchard receivers)', function () { + assert.strictEqual(zec.isValidAddress(MAINNET_UA.unified), true); + }); + + it('accepts a testnet unified address (transparent + Ironwood receivers)', function () { + assert.strictEqual(tzec.isValidAddress(TESTNET_UA.unified), true); + }); + + it('accepts an Orchard-only (single-receiver) unified address', function () { + const orchardOnlyUa = fixedScriptWallet.ZcashUnifiedAddress.encodeOrchardReceiver( + Buffer.from(TESTNET_UA.ironwoodReceiverHex as string, 'hex'), + 'tzec' + ); + assert.strictEqual(tzec.isValidAddress(orchardOnlyUa), true); + }); + + it('rejects a malformed unified address', function () { + assert.strictEqual(zec.isValidAddress('u1notavalidunifiedaddress'), false); + }); + + it('rejects a unified address on the wrong network', function () { + // MAINNET_UA has the "u1..." HRP; tzec expects "utest1...". + assert.strictEqual(tzec.isValidAddress(MAINNET_UA.unified), false); + }); + + it('still validates ordinary transparent addresses', function () { + assert.strictEqual(tzec.isValidAddress(TESTNET_UA.transparentAddress as string), true); + assert.strictEqual(tzec.isValidAddress('not-an-address'), false); + }); + }); + + describe('resolveOutputScript', function () { + it("resolves a unified address's Orchard/Ironwood receiver when preference is 'shielded'", function () { + const script = tzec.resolveOutputScript(TESTNET_UA.unified, 'shielded'); + assert.strictEqual(Buffer.from(script).toString('hex'), TESTNET_UA.ironwoodReceiverHex); + assert.strictEqual(script.length, 43); + }); + + it("resolves a mainnet unified address's Orchard receiver when preference is 'shielded'", function () { + const script = zec.resolveOutputScript(MAINNET_UA.unified, 'shielded'); + assert.strictEqual(Buffer.from(script).toString('hex'), MAINNET_UA.orchardReceiverHex); + }); + + it('throws for a unified address when preference is not shielded (transparent UA resolution is not supported)', function () { + assert.throws(() => tzec.resolveOutputScript(TESTNET_UA.unified)); + assert.throws(() => tzec.resolveOutputScript(TESTNET_UA.unified, 'transparent')); + }); + + it('resolves an ordinary transparent address regardless of preference', function () { + const expectedScript = `76a914${TESTNET_UA.transparentPubkeyHashHex}88ac`; + assert.strictEqual( + Buffer.from(tzec.resolveOutputScript(TESTNET_UA.transparentAddress as string)).toString('hex'), + expectedScript + ); + assert.strictEqual( + Buffer.from(tzec.resolveOutputScript(TESTNET_UA.transparentAddress as string, 'shielded')).toString('hex'), + expectedScript + ); + }); + }); + + describe('getExtraPrebuildParams', function () { + function mockWallet(coin = zec) { + return new Wallet(defaultBitGo, coin, { id: '5b34252f1bf349930e34020a', coin: coin.getChain(), type: 'hot' }); + } + + it('forwards unifiedRecipientPreference when present', async function () { + const wallet = mockWallet(); + const result: Record = await zec.getExtraPrebuildParams({ + wallet, + unifiedRecipientPreference: 'shielded', + } as ExtraPrebuildParamsOptions & { wallet: Wallet }); + assert.strictEqual(result.unifiedRecipientPreference, 'shielded'); + }); + + it('does not set unifiedRecipientPreference when absent', async function () { + const wallet = mockWallet(); + const result = await zec.getExtraPrebuildParams({ wallet } as ExtraPrebuildParamsOptions & { wallet: Wallet }); + assert.strictEqual('unifiedRecipientPreference' in result, false); + }); + + it('still returns the standard extra prebuild params (txFormat) unchanged', async function () { + const wallet = mockWallet(); + const result = await zec.getExtraPrebuildParams({ + wallet, + unifiedRecipientPreference: 'shielded', + } as ExtraPrebuildParamsOptions & { wallet: Wallet }); + assert.strictEqual(result.txFormat, 'psbt-lite'); + }); + }); + + describe('decodeTransaction / decodeTransactionFromPrebuild', function () { + const { walletKeys } = getDefaultWasmWalletKeys(); + + function buildV4Psbt(): fixedScriptWallet.ZcashBitGoPsbt { + const psbt = fixedScriptWallet.ZcashBitGoPsbt.createEmpty('zec', walletKeys, { blockHeight: 3146400 }); + 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 }); + return psbt; + } + + it('decodes a v4 (Sapling-shaped) PSBT as a ZcashBitGoPsbt', function () { + const bytes = Buffer.from(buildV4Psbt().serialize()); + const decoded = zec.decodeTransaction(bytes); + assert.ok(decoded instanceof fixedScriptWallet.ZcashBitGoPsbt); + assert.ok(!(decoded instanceof fixedScriptWallet.ZcashIronwoodBitGoPsbt)); + }); + + it('decodeTransactionFromPrebuild decodes a v4 txHex the same way', function () { + const bytes = Buffer.from(buildV4Psbt().serialize()); + const decoded = zec.decodeTransactionFromPrebuild({ txHex: bytes.toString('hex') }); + assert.ok(decoded instanceof fixedScriptWallet.ZcashBitGoPsbt); + }); + + it('falls back to ZcashIronwoodBitGoPsbt.fromBytes for v6-shaped bytes', function () { + // ZcashBitGoPsbt.fromBytes throws (a real WasmUtxoError) for v6-shaped bytes, telling the + // caller to use ZcashIronwoodBitGoPsbt.fromBytes instead; stub both statics to prove + // Zec.decodeTransaction actually performs that fallback dispatch rather than propagating + // the first error. + class FakeWasmUtxoError extends Error { + code = 'WasmUtxoError.StringError'; + } + Object.defineProperty(FakeWasmUtxoError.prototype, Symbol.for('@bitgo/wasm-utxo/error'), { value: true }); + assert.ok(isWasmUtxoError(new FakeWasmUtxoError('this is a v6 (Ironwood) PSBT'))); + + const fakeIronwoodPsbt = Object.create(fixedScriptWallet.ZcashIronwoodBitGoPsbt.prototype); + const v4Stub = sinon + .stub(fixedScriptWallet.ZcashBitGoPsbt, 'fromBytes') + .throws(new FakeWasmUtxoError('this is a v6 (Ironwood) PSBT: use ZcashIronwoodBitGoPsbt.fromBytes instead')); + const v6Stub = sinon.stub(fixedScriptWallet.ZcashIronwoodBitGoPsbt, 'fromBytes').returns(fakeIronwoodPsbt); + + try { + const psbtMagicBytes = Buffer.from([0x70, 0x73, 0x62, 0x74, 0xff, 0x00]); + const decoded = zec.decodeTransaction(psbtMagicBytes); + assert.strictEqual(decoded, fakeIronwoodPsbt); + assert.strictEqual(v4Stub.calledOnce, true); + assert.strictEqual(v6Stub.calledOnce, true); + } finally { + v4Stub.restore(); + v6Stub.restore(); + } + }); + + it('propagates a non-wasm-utxo error from the v4 decode path without attempting the v6 fallback', function () { + const v4Stub = sinon.stub(fixedScriptWallet.ZcashBitGoPsbt, 'fromBytes').throws(new Error('boom')); + const v6Stub = sinon.stub(fixedScriptWallet.ZcashIronwoodBitGoPsbt, 'fromBytes'); + + try { + const psbtMagicBytes = Buffer.from([0x70, 0x73, 0x62, 0x74, 0xff, 0x00]); + assert.throws(() => zec.decodeTransaction(psbtMagicBytes), /boom/); + assert.strictEqual(v6Stub.called, false); + } finally { + v4Stub.restore(); + v6Stub.restore(); + } + }); + }); + + describe('resolveRecipientsFromPsbt', function () { + const { walletKeys } = getDefaultWasmWalletKeys(); + const IRONWOOD_HEIGHT = 4200000; // after the NU6.3 testnet activation (4134000) + const IRONWOOD_RECEIVER = Buffer.from(TESTNET_UA.ironwoodReceiverHex as string, 'hex'); + + function buildShieldedV6Psbt( + unifiedAddress = fixedScriptWallet.ZcashUnifiedAddress.encodeOrchardReceiver( + new Uint8Array(IRONWOOD_RECEIVER), + 'tzec' + ) + ): 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; + } + + function buildTransparentV4Psbt(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 = tzec.resolveOutputScript(TESTNET_UA.transparentAddress as string); + psbt.addTransparentOutput(externalScript, 12345n, unifiedAddress); + return psbt; + } + + /** Decode a UA back to its receivers and assert they match the testnet fixture. */ + function assertDecodesBackToFixtureRecipients(unifiedAddress: string, zec: Zec): void { + const parsed = fixedScriptWallet.ZcashUnifiedAddress.parse(unifiedAddress, 'tzec'); + assert.strictEqual(parsed.hasOrchardReceiver, true); + assert.ok(parsed.orchardReceiver); + assert.strictEqual(Buffer.from(parsed.orchardReceiver).toString('hex'), TESTNET_UA.ironwoodReceiverHex); + assert.strictEqual(parsed.hasTransparentReceiver, true); + assert.ok(parsed.transparentScript); + assert.strictEqual( + Buffer.from(parsed.transparentScript).toString('hex'), + Buffer.from(zec.resolveOutputScript(TESTNET_UA.transparentAddress as string)).toString('hex') + ); + } + const tzecCoin = tzec as Zec; + + it('resolves a shielded v6 (Ironwood) output to its Orchard Unified Address recipient', function () { + const recipients = tzecCoin.resolveRecipientsFromPsbt(Buffer.from(buildShieldedV6Psbt().serialize()), walletKeys); + assert.strictEqual(recipients.length, 1); + const recipient = recipients[0]; + assert.strictEqual(recipient.destination.kind, 'zcashShielded'); + assert.strictEqual(recipient.amount, 5000n); + assert.ok(recipient.address.startsWith('utest1')); + assert.strictEqual(recipient.address, recipient.destination.unifiedAddress); + assert.strictEqual(Buffer.from(recipient.script).toString('hex'), TESTNET_UA.ironwoodReceiverHex); + }); + + it('resolves transparent external outputs and excludes change', function () { + const recipients = tzecCoin.resolveRecipientsFromPsbt( + Buffer.from(buildTransparentV4Psbt().serialize()), + walletKeys + ); + assert.strictEqual(recipients.length, 1); + const recipient = recipients[0]; + assert.strictEqual(recipient.destination.kind, 'transparent'); + assert.strictEqual(recipient.address, TESTNET_UA.transparentAddress); + assert.strictEqual(recipient.amount, 12345n); + }); + + it('reports the original multi-receiver UA for a shielded output and decodes it back', function () { + // The client passed the full multi-receiver UA; the v6 PCZT stores it verbatim in the + // PSBT's key-value pairs, so the resolved recipient must report that same string. + const recipients = tzecCoin.resolveRecipientsFromPsbt( + Buffer.from(buildShieldedV6Psbt(TESTNET_UA.unified).serialize()), + walletKeys + ); + assert.strictEqual(recipients.length, 1); + const recipient = recipients[0]; + assert.strictEqual(recipient.destination.kind, 'zcashShielded'); + assert.strictEqual(recipient.address, TESTNET_UA.unified); + assert.strictEqual(recipient.unifiedAddress, TESTNET_UA.unified); + assert.strictEqual(recipient.destination.unifiedAddress, TESTNET_UA.unified); + assert.strictEqual(Buffer.from(recipient.script).toString('hex'), TESTNET_UA.ironwoodReceiverHex); + assertDecodesBackToFixtureRecipients(recipient.unifiedAddress as string, tzecCoin); + }); + + it('reports the original multi-receiver UA for a transparent v4 output and decodes it back', function () { + // The original UA is stored in the transparent-output proprietary key-value map and read + // back via transparentOutputUnifiedAddress. + const recipients = tzecCoin.resolveRecipientsFromPsbt( + Buffer.from(buildTransparentV4Psbt(TESTNET_UA.unified).serialize()), + walletKeys + ); + assert.strictEqual(recipients.length, 1); + const recipient = recipients[0]; + assert.strictEqual(recipient.destination.kind, 'zcashUnifiedTransparent'); + assert.strictEqual(recipient.address, TESTNET_UA.unified); + assert.strictEqual(recipient.unifiedAddress, TESTNET_UA.unified); + assert.strictEqual(recipient.destination.unifiedAddress, TESTNET_UA.unified); + assertDecodesBackToFixtureRecipients(recipient.unifiedAddress as string, tzecCoin); + }); + + it('resolves recipients from a hex PSBT string', function () { + const hex = Buffer.from(buildShieldedV6Psbt().serialize()).toString('hex'); + const recipients = tzecCoin.resolveRecipientsFromPsbt(hex, walletKeys); + assert.strictEqual(recipients.length, 1); + assert.strictEqual(recipients[0].destination.kind, 'zcashShielded'); + }); + + it('throws for a non-Zcash PSBT', function () { + const psbt = fixedScriptWallet.BitGoPsbt.createEmpty('btc', walletKeys, {}); + psbt.addWalletInput({ txid: '33'.repeat(32), vout: 0, value: 1000n }, walletKeys, { + scriptId: { chain: 0, index: 0 }, + }); + // Zec.decodeTransaction attempts ZcashBitGoPsbt.fromBytes, which rejects a btc PSBT for + // its missing Zcash consensus branch ID before the Zcash-type guard is ever reached. + assert.throws(() => tzecCoin.resolveRecipientsFromPsbt(Buffer.from(psbt.serialize()), walletKeys)); + }); + }); +}); diff --git a/modules/abstract-utxo/test/unit/transaction/recipient.ts b/modules/abstract-utxo/test/unit/transaction/recipient.ts index 2729b250ca..26b7e2fa48 100644 --- a/modules/abstract-utxo/test/unit/transaction/recipient.ts +++ b/modules/abstract-utxo/test/unit/transaction/recipient.ts @@ -1,5 +1,6 @@ import assert from 'assert'; +import { toOutputScript, fromExtendedAddressFormatToScript } from '../../../src/transaction/recipient'; import { getUtxoCoin } from '../util/utxoCoins'; describe('AbstractUtxoCoin.preprocessBuildParams', function () { @@ -53,3 +54,76 @@ describe('AbstractUtxoCoin.checkRecipient', function () { }, /Only zero amounts allowed for non-encodeable scriptPubkeys/); }); }); + +describe('toOutputScript / fromExtendedAddressFormatToScript resolveScript override', function () { + const coin = getUtxoCoin('btc'); + const address = '1A1zP1eP5QGefi2DMPTfTL5SLmv7DivfNa'; + const defaultScript = fromExtendedAddressFormatToScript(address, coin.name); + + it('fromExtendedAddressFormatToScript uses the default wasm-utxo resolver when none is supplied', function () { + assert.deepStrictEqual(fromExtendedAddressFormatToScript(address, coin.name), defaultScript); + }); + + it('fromExtendedAddressFormatToScript defers to a supplied resolveScript callback', function () { + const fakeScript = Buffer.from('deadbeef', 'hex'); + let calledWith: [string, string] | undefined; + const script = fromExtendedAddressFormatToScript(address, coin.name, (a, c) => { + calledWith = [a, c]; + return fakeScript; + }); + assert.deepStrictEqual(script, fakeScript); + assert.deepStrictEqual(calledWith, [address, coin.name]); + }); + + it('fromExtendedAddressFormatToScript never invokes resolveScript for a scriptPubKey: recipient', function () { + let called = false; + const script = fromExtendedAddressFormatToScript('scriptPubKey:deadbeef', coin.name, () => { + called = true; + return Buffer.from(''); + }); + assert.strictEqual(called, false); + assert.deepStrictEqual(script, Buffer.from('deadbeef', 'hex')); + }); + + it('toOutputScript forwards resolveScript through for a string address', function () { + const fakeScript = Buffer.from('cafebabe', 'hex'); + const script = toOutputScript(address, coin.name, () => fakeScript); + assert.deepStrictEqual(script, fakeScript); + }); + + it('toOutputScript forwards resolveScript through for an { address } object', function () { + const fakeScript = Buffer.from('cafebabe', 'hex'); + const script = toOutputScript({ address }, coin.name, () => fakeScript); + assert.deepStrictEqual(script, fakeScript); + }); + + it('toOutputScript never invokes resolveScript for a { script } object', function () { + let called = false; + const script = toOutputScript({ script: 'deadbeef' }, coin.name, () => { + called = true; + return Buffer.from(''); + }); + assert.strictEqual(called, false); + assert.deepStrictEqual(script, Buffer.from('deadbeef', 'hex')); + }); +}); + +describe('AbstractUtxoCoin.resolveOutputScript', function () { + it('defaults to the coin-agnostic wasm-utxo address decoder', function () { + const coin = getUtxoCoin('btc'); + const address = '1A1zP1eP5QGefi2DMPTfTL5SLmv7DivfNa'; + assert.deepStrictEqual( + Buffer.from(coin.resolveOutputScript(address)), + fromExtendedAddressFormatToScript(address, coin.name) + ); + }); + + it('ignores an unrecognized unifiedRecipientPreference for a non-Zcash coin', function () { + const coin = getUtxoCoin('btc'); + const address = '1A1zP1eP5QGefi2DMPTfTL5SLmv7DivfNa'; + assert.deepStrictEqual( + Buffer.from(coin.resolveOutputScript(address, 'shielded')), + fromExtendedAddressFormatToScript(address, coin.name) + ); + }); +}); diff --git a/modules/sdk-core/src/bitgo/wallet/BuildParams.ts b/modules/sdk-core/src/bitgo/wallet/BuildParams.ts index 1935a05d2c..7fe7e80d00 100644 --- a/modules/sdk-core/src/bitgo/wallet/BuildParams.ts +++ b/modules/sdk-core/src/bitgo/wallet/BuildParams.ts @@ -39,6 +39,8 @@ export const BuildParamsUTXO = t.partial({ isReplaceableByFee: t.boolean, messages: t.array(Bip322Message), qr: t.boolean, + /* Zcash-only: how to resolve a Unified Address recipient ('shielded' or transparent) */ + unifiedRecipientPreference: t.string, }); export const BuildParamsStacks = t.partial({ diff --git a/modules/sdk-core/src/bitgo/wallet/iWallet.ts b/modules/sdk-core/src/bitgo/wallet/iWallet.ts index 9397bc0b24..4fb4df5fcf 100644 --- a/modules/sdk-core/src/bitgo/wallet/iWallet.ts +++ b/modules/sdk-core/src/bitgo/wallet/iWallet.ts @@ -230,6 +230,12 @@ export interface PrebuildTransactionOptions { * the legacy format defined by bitcoinjs-lib, or the 'psbt' format, which follows the BIP-174. */ txFormat?: 'legacy' | 'psbt' | 'psbt-lite'; + /** + * Zcash-only: how to resolve a Unified Address recipient. `'shielded'` resolves it to its + * Orchard/Ironwood receiver (a shielded output); any other value (or omission) resolves it to + * its transparent receiver. + */ + unifiedRecipientPreference?: string; /** * Custom Solana instructions to include in the transaction. * Each instruction contains a program ID, accounts array, and data buffer.