From 70ffbd0aa270d6e6562869897012b7304d6c2add 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 | 107 ++++++ modules/abstract-utxo/src/impl/zec/zec.ts | 144 +++++++- .../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 | 310 ++++++++++++++++++ .../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, 790 insertions(+), 18 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 3e05c1b7b9..d80e53cb6d 100644 --- a/modules/abstract-utxo/src/impl/zec/index.ts +++ b/modules/abstract-utxo/src/impl/zec/index.ts @@ -1,3 +1,4 @@ export * from './zec'; +export * from './recipients'; export * from './tzec'; export * from './address'; 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..58716d3552 --- /dev/null +++ b/modules/abstract-utxo/src/impl/zec/recipients.ts @@ -0,0 +1,107 @@ +/** + * @prettier + */ +import { fixedScriptWallet } from '@bitgo/wasm-utxo'; + +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; +} + +/** + * 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; +} diff --git a/modules/abstract-utxo/src/impl/zec/zec.ts b/modules/abstract-utxo/src/impl/zec/zec.ts index 8aed41fd92..681e52e760 100644 --- a/modules/abstract-utxo/src/impl/zec/zec.ts +++ b/modules/abstract-utxo/src/impl/zec/zec.ts @@ -1,13 +1,14 @@ /** * @prettier */ -import { BitGoBase } from '@bitgo/sdk-core'; -import { fixedScriptWallet } from '@bitgo/wasm-utxo'; +import { fixedScriptWallet, hasPsbtMagic, 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 { isShieldedZcashAddress } from './address'; +import { resolvePsbtRecipients, PsbtRecipient } from './recipients'; export class Zec extends AbstractUtxoCoin { readonly name: UtxoCoinName = 'zec'; @@ -20,10 +21,139 @@ export class Zec extends AbstractUtxoCoin { return new Zec(bitgo); } - isValidAddress(address: string, param?: { anyFormat?: boolean; allowLightning?: boolean } | boolean): boolean { - if (super.isValidAddress(address, param)) { - return true; + /** + * 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 isShieldedZcashAddress(address, this.name as fixedScriptWallet.ZcashNetworkName); + 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 { + try { + const unifiedAddress = fixedScriptWallet.ZcashUnifiedAddress.parse(address, this.name as 'zec' | 'tzec'); + return unifiedAddress.transparentScript !== undefined || unifiedAddress.orchardReceiver !== undefined; + } catch (e) { + // Not a unified address for this network — defer to the base transparent-address + // validation. + 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); any other value resolves the Unified Address's transparent receiver (a plain + * transparent address decodes exactly as the base implementation would). A Unified Address + * without a transparent receiver cannot resolve transparently and throws. + */ + override resolveOutputScript(address: string, unifiedRecipientPreference?: string): Uint8Array { + if (unifiedRecipientPreference === 'shielded') { + return wasmZcashAddress.toShieldedReceiverWithCoin(address, this.name); + } + return wasmZcashAddress.toTransparentReceiverWithCoin(address, this.name); + } + + /** + * Infer the Unified-Address recipient preference from the recipients when the caller did not + * pass one — mirroring wallet-platform's utxo-core `buildTransaction` (`inferIsShielded` + + * `classifyRecipientShieldedness`): a Unified Address carrying only an Orchard receiver can + * only be spent shielded, one carrying only a transparent receiver only transparently, one + * carrying both is ambiguous, and a mix of shielded and transparent recipients is rejected. + */ + getUnifiedRecipientPreference(txParams: { + recipients?: { address?: string; amount: number | bigint | string }[]; + unifiedRecipientPreference?: string; + }): string | undefined { + if (txParams.unifiedRecipientPreference !== undefined) { + return txParams.unifiedRecipientPreference; + } + const shieldedness = (txParams.recipients ?? []).map((recipient) => { + if (recipient.address === undefined) { + // Raw script inherently transparent. + return 'transparent' as const; + } + let unified: fixedScriptWallet.ZcashUnifiedAddress | undefined; + try { + unified = fixedScriptWallet.ZcashUnifiedAddress.parse(recipient.address, this.name as 'zec' | 'tzec'); + } catch (e) { + // Not a unified address: the ordinary transparent address-decoding path handles it. + return 'transparent' as const; + } + if (unified.hasOrchardReceiver && unified.hasTransparentReceiver) { + throw new Error( + `Unified address ${recipient.address} carries both transparent and Orchard receivers; specify unifiedRecipientPreference: "shielded" or "transparent"` + ); + } + if (unified.hasTransparentReceiver) { + return 'transparent' as const; + } + if (unified.hasOrchardReceiver) { + return 'shielded' as const; + } + throw new Error(`Unified 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; + } + + /** + * Deserialize a Zcash PSBT (v4 Sapling-shaped or v6 Ironwood). `ZcashPsbt.fromBytes` reads + * the Zcash transaction version from the parsed metadata and returns the format-specific + * implementation — `ZcashBitGoPsbt` for v4, `ZcashIronwoodBitGoPsbt` for v6 — so no + * byte-level sniffing or fallback dispatch is needed here. + */ + override decodeTransaction(input: Buffer | string): fixedScriptWallet.BitGoPsbt { + const buffer = typeof input === 'string' ? stringToBufferTryFormats(input, ['hex', 'base64']) : input; + if (!hasPsbtMagic(buffer)) { + return super.decodeTransaction(input); + } + return fixedScriptWallet.ZcashPsbt.fromBytes(buffer, this.name as 'zec' | 'tzec'); + } + + 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 outputs are excluded. + */ + resolveRecipientsFromPsbt(input: Buffer | string, walletKeys: fixedScriptWallet.RootWalletKeys): PsbtRecipient[] { + const psbt = this.decodeTransaction(input); + if (!(psbt instanceof fixedScriptWallet.ZcashBitGoPsbt)) { + throw new Error('expected a Zcash PSBT'); + } + return resolvePsbtRecipients(psbt, walletKeys); } } 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..f7931e6848 --- /dev/null +++ b/modules/abstract-utxo/test/unit/impl/zec/unifiedAddress.ts @@ -0,0 +1,310 @@ +import assert from 'assert'; +import * as fs from 'fs'; +import * as path from 'path'; + +import * as sinon from 'sinon'; +import { fixedScriptWallet } 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('resolves the transparent receiver for a unified address when preference is not shielded', function () { + const expectedScript = `76a914${TESTNET_UA.transparentPubkeyHashHex}88ac`; + assert.strictEqual(Buffer.from(tzec.resolveOutputScript(TESTNET_UA.unified)).toString('hex'), expectedScript); + assert.strictEqual( + Buffer.from(tzec.resolveOutputScript(TESTNET_UA.unified, 'transparent')).toString('hex'), + expectedScript + ); + }); + + it('throws for a unified address without a transparent receiver when preference is not shielded', function () { + const orchardOnlyUa = fixedScriptWallet.ZcashUnifiedAddress.encodeOrchardReceiver( + Buffer.from(TESTNET_UA.ironwoodReceiverHex as string, 'hex'), + 'tzec' + ); + assert.throws(() => tzec.resolveOutputScript(orchardOnlyUa)); + assert.throws(() => tzec.resolveOutputScript(orchardOnlyUa, '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('decodes a v6 (Ironwood) PSBT as a ZcashIronwoodBitGoPsbt', function () { + // `ZcashPsbt.fromBytes` reads the transaction version from the parsed metadata and + // dispatches to the format-specific implementation; no error-message sniffing involved. + const psbt = fixedScriptWallet.ZcashIronwoodBitGoPsbt.createEmpty('zec', walletKeys, { blockHeight: 4200000 }); + psbt.addWalletInput({ txid: '33'.repeat(32), vout: 0, value: 100000n }, walletKeys, { + scriptId: { chain: 0, index: 0 }, + }); + psbt.addWalletOutput(walletKeys, { chain: 1, index: 0, value: 90000n }); + const decoded = zec.decodeTransaction(Buffer.from(psbt.serialize())); + assert.ok(decoded instanceof fixedScriptWallet.ZcashIronwoodBitGoPsbt); + }); + + it('propagates a non-wasm-utxo error from the PSBT deserializer', function () { + const fromBytesStub = sinon.stub(fixedScriptWallet.ZcashPsbt, 'fromBytes').throws(new Error('boom')); + + try { + const psbtMagicBytes = Buffer.from([0x70, 0x73, 0x62, 0x74, 0xff, 0x00]); + assert.throws(() => zec.decodeTransaction(psbtMagicBytes), /boom/); + } finally { + fromBytesStub.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.