From db28c959acc6486bc4177d4991aaa1704cb2212d Mon Sep 17 00:00:00 2001 From: Veetrag Jain Date: Sat, 5 Sep 2026 01:19:44 +0530 Subject: [PATCH 1/3] fix(abstract-utxo): add zec psbt decode and UA resolution support Ticket: CSHLD-1639 Refs: 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 | 61 ++-- modules/abstract-utxo/src/impl/zec/zec.ts | 129 ++++++- .../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, 708 insertions(+), 46 deletions(-) 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 77e51324bb..c4c5fe555a 100644 --- a/modules/abstract-utxo/src/impl/zec/index.ts +++ b/modules/abstract-utxo/src/impl/zec/index.ts @@ -1,4 +1,5 @@ export * from './zec'; +export * from './recipients'; export * from './tzec'; export * from './address'; export * from './recipients'; diff --git a/modules/abstract-utxo/src/impl/zec/recipients.ts b/modules/abstract-utxo/src/impl/zec/recipients.ts index d56a4f88c9..af1564c2d5 100644 --- a/modules/abstract-utxo/src/impl/zec/recipients.ts +++ b/modules/abstract-utxo/src/impl/zec/recipients.ts @@ -1,8 +1,12 @@ +/** + * @prettier + */ import { fixedScriptWallet, zcashAddress } from '@bitgo/wasm-utxo'; +import { Triple } from '@bitgo/sdk-core'; import { getReplayProtectionPubkeys } from '../../transaction/fixedScript/replayProtection'; -import { ZcashCoinName, UnifiedRecipientPreference } from './types'; +import { UnifiedRecipientPreference, ZcashCoinName } from './types'; /** * How a recipient parsed from a Zcash PSBT is spent. @@ -50,30 +54,44 @@ export interface PsbtRecipient { * 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. + * 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 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. + * 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 + 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) => { @@ -81,6 +99,10 @@ export function resolvePsbtRecipients( 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; @@ -106,20 +128,7 @@ export function resolvePsbtRecipients( /** * Infer the Unified-Address recipient preference for a Zcash transaction when the caller did - * not pass one — the counterpart of wallet-platform's utxo-core `buildTransaction` - * `classifyRecipientShieldedness`. - * - * A recipient that resolves to a transparent output — an ordinary transparent address, or a - * Unified Address carrying a transparent receiver — is classified `'transparent'`; a Unified - * Address carrying only an Orchard/Ironwood receiver is classified `'shielded'`. A mix of - * shielded and transparent recipients is rejected. An address that is neither a transparent - * address nor a Unified Address propagates the Unified-Address parse error — it is not - * silently defaulted to `'transparent'`. - * - * @returns `'shielded'` when every recipient resolves shielded, `undefined` when every - * recipient resolves transparent (the build's default). The `'transparent'` arm of the - * return type exists so callers can pass the explicit preference through unchanged; this - * function itself never returns `'transparent'`. + * not pass one. A mix of shielded and transparent recipients is rejected. */ export function getUnifiedRecipientPreference( name: ZcashCoinName, @@ -127,17 +136,11 @@ export function getUnifiedRecipientPreference( ): UnifiedRecipientPreference | undefined { const shieldedness = recipients.map((recipient) => { if (recipient.address === undefined) { - // Raw script inherently transparent. return 'transparent' as const; } - // Ordinary transparent address, or a Unified Address carrying a transparent receiver: - // resolves transparently either way (the build's default when no preference is given). if (zcashAddress.hasTransparentReceiver(recipient.address, name)) { return 'transparent' as const; } - // A shielded (Orchard/Ironwood-only) Unified Address is the only remaining resolvable - // form. An address that is none of the above propagates the parse error instead of - // assuming a default. const unified = fixedScriptWallet.ZcashUnifiedAddress.parse(recipient.address, name); if (unified.hasOrchardReceiver) { return 'shielded' as const; diff --git a/modules/abstract-utxo/src/impl/zec/zec.ts b/modules/abstract-utxo/src/impl/zec/zec.ts index f20e03d628..f091d3e30e 100644 --- a/modules/abstract-utxo/src/impl/zec/zec.ts +++ b/modules/abstract-utxo/src/impl/zec/zec.ts @@ -1,12 +1,36 @@ /** * @prettier */ -import { BitGoBase } from '@bitgo/sdk-core'; -import { zcashAddress } from '@bitgo/wasm-utxo'; +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'; @@ -18,9 +42,102 @@ export class Zec extends AbstractUtxoCoin { return new Zec(bitgo); } - isValidAddress(address: string, param?: { anyFormat?: boolean; allowLightning?: boolean } | boolean): boolean { - return ( - zcashAddress.hasTransparentReceiver(address, this.name) || zcashAddress.hasOrchardReceiver(address, this.name) - ); + /** + * 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. From 85093586c7aece03d0024cf127802c45b8cf4e64 Mon Sep 17 00:00:00 2001 From: Otto Allmendinger Date: Mon, 7 Sep 2026 13:24:01 +0200 Subject: [PATCH 2/3] refactor(abstract-utxo): introduce address codec Encapsulate coin-aware address encoding and decoding in a reusable codec. Generic transaction parsing no longer passes coin context or callbacks through recipient helpers. Refs: CSHLD-1639 --- modules/abstract-utxo/src/abstractUtxoCoin.ts | 34 +++-- modules/abstract-utxo/src/impl/bch/bch.ts | 4 +- .../src/transaction/descriptor/parse.ts | 44 +++---- .../descriptor/parseToAmountType.ts | 6 +- .../descriptor/verifyTransaction.ts | 13 +- .../transaction/fixedScript/parseOutput.ts | 13 +- .../fixedScript/parseTransaction.ts | 59 ++++----- .../src/transaction/parseTransaction.ts | 8 +- .../src/transaction/recipient.ts | 118 ++++++++---------- .../src/transaction/verifyTransaction.ts | 7 +- .../test/unit/transaction/descriptor/parse.ts | 4 +- .../descriptor/verifyTransactionQr.ts | 14 ++- 12 files changed, 163 insertions(+), 161 deletions(-) diff --git a/modules/abstract-utxo/src/abstractUtxoCoin.ts b/modules/abstract-utxo/src/abstractUtxoCoin.ts index abc7dbf590..cbce338555 100644 --- a/modules/abstract-utxo/src/abstractUtxoCoin.ts +++ b/modules/abstract-utxo/src/abstractUtxoCoin.ts @@ -62,14 +62,8 @@ import { } from './recovery'; import { getReplayProtectionPubkeys, isReplayProtectionUnspent } from './transaction/fixedScript/replayProtection'; import { supportedCrossChainRecoveries } from './config'; -import { - assertValidTransactionRecipient, - explainTx, - fromExtendedAddressFormat, - isScriptRecipient, - parseTransaction, - verifyTransaction, -} from './transaction'; +import { explainTx, parseTransaction, verifyTransaction } from './transaction'; +import { AddressCodec } from './transaction/recipient'; import type { TransactionExplanation } from './transaction/fixedScript/explainTransaction'; import { Musig2Participant } from './transaction/fixedScript/musig2'; import { @@ -523,7 +517,7 @@ export abstract class AbstractUtxoCoin extends BaseCoin implements Musig2Partici if (address === undefined) { return recipient; // Already { script, amount } — pass through unchanged } - return { ...rest, ...fromExtendedAddressFormat(address) }; + return { ...rest, ...AddressCodec.fromExtendedAddressFormat(address) }; }) : params.recipients; } @@ -544,8 +538,8 @@ export abstract class AbstractUtxoCoin extends BaseCoin implements Musig2Partici } checkRecipient(recipient: { address?: string; amount: number | string }): void { - assertValidTransactionRecipient(recipient); - if (recipient.address && !isScriptRecipient(recipient.address)) { + AddressCodec.assertValidTransactionRecipient(recipient); + if (recipient.address && !AddressCodec.isScriptRecipient(recipient.address)) { super.checkRecipient({ address: recipient.address, amount: recipient.amount }); } } @@ -618,7 +612,14 @@ export abstract class AbstractUtxoCoin extends BaseCoin implements Musig2Partici async parseTransaction( params: ParseTransactionOptions ): Promise> { - return parseTransaction(this, params); + return this.parseTransactionWithAddressCodec(params, new AddressCodec(this.name)); + } + + protected parseTransactionWithAddressCodec( + params: ParseTransactionOptions, + addressCodec: AddressCodec + ): Promise> { + return parseTransaction(this, params, addressCodec); } /** @@ -653,9 +654,16 @@ export abstract class AbstractUtxoCoin extends BaseCoin implements Musig2Partici */ async verifyTransaction( params: VerifyTransactionOptions + ): Promise { + return this.verifyTransactionWithAddressCodec(params, new AddressCodec(this.name)); + } + + protected async verifyTransactionWithAddressCodec( + params: VerifyTransactionOptions, + addressCodec: AddressCodec ): Promise { try { - return await verifyTransaction(this, this.bitgo, params); + return await verifyTransaction(this, this.bitgo, params, addressCodec); } catch (error) { if (error instanceof AggregateValidationError) { const txExplanation = await TxIntentMismatchError.tryGetTxExplanation( diff --git a/modules/abstract-utxo/src/impl/bch/bch.ts b/modules/abstract-utxo/src/impl/bch/bch.ts index 451d282ff0..50fcb966a5 100644 --- a/modules/abstract-utxo/src/impl/bch/bch.ts +++ b/modules/abstract-utxo/src/impl/bch/bch.ts @@ -3,7 +3,7 @@ import { address as wasmAddress } from '@bitgo/wasm-utxo'; import { AbstractUtxoCoin } from '../../abstractUtxoCoin'; import { UtxoCoinName } from '../../names'; -import { isScriptRecipient } from '../../transaction'; +import { AddressCodec } from '../../transaction'; export class Bch extends AbstractUtxoCoin { readonly name: UtxoCoinName = 'bch'; @@ -29,7 +29,7 @@ export class Bch extends AbstractUtxoCoin { * @returns {*} address string */ canonicalAddress(address: string, version: unknown = 'base58'): string { - if (isScriptRecipient(address)) { + if (AddressCodec.isScriptRecipient(address)) { return address; } diff --git a/modules/abstract-utxo/src/transaction/descriptor/parse.ts b/modules/abstract-utxo/src/transaction/descriptor/parse.ts index 82261cb964..72459ce752 100644 --- a/modules/abstract-utxo/src/transaction/descriptor/parse.ts +++ b/modules/abstract-utxo/src/transaction/descriptor/parse.ts @@ -6,9 +6,8 @@ import { BaseOutput, BaseParsedTransaction, BaseParsedTransactionOutputs } from import { getKeySignatures, toBip32Triple, UtxoNamedKeychains } from '../../keychains'; import { getDescriptorMapFromWallet, getPolicyForEnv } from '../../descriptor'; import { IDescriptorWallet } from '../../descriptor/descriptorWallet'; -import { fromExtendedAddressFormatToScript, toExtendedAddressFormat } from '../recipient'; +import { AddressCodec } from '../recipient'; import { outputDifferencesWithExpected, OutputDifferenceWithExpected } from '../outputDifference'; -import { UtxoCoinName } from '../../names'; import { decodeDescriptorPsbt } from '../decode'; function sumValues(arr: { value: bigint }[]): bigint { @@ -21,11 +20,11 @@ export type RecipientOutput = Omit & { value: bigint | 'max'; }; -function toRecipientOutput(recipient: ITransactionRecipient, coinName: UtxoCoinName): RecipientOutput { +function toRecipientOutput(recipient: ITransactionRecipient, addressCodec: AddressCodec): RecipientOutput { return { address: recipient.address, value: recipient.amount === 'max' ? 'max' : BigInt(recipient.amount), - script: fromExtendedAddressFormatToScript(recipient.address, coinName), + script: addressCodec.fromExtendedAddressFormatToScript(recipient.address), scriptId: undefined, // Recipients are external outputs }; } @@ -40,9 +39,9 @@ function parseOutputsWithPsbt( psbt: Psbt, descriptorMap: descriptorWallet.DescriptorMap, recipientOutputs: RecipientOutput[], - coinName: UtxoCoinName + addressCodec: AddressCodec ): ParsedOutputs { - const parsed = descriptorWallet.parse(psbt, descriptorMap, coinName); + const parsed = descriptorWallet.parse(psbt, descriptorMap, addressCodec.coinName); const outputs: ParsedOutput[] = parsed.outputs.map((output) => ({ ...output, script: Buffer.from(output.script), @@ -56,15 +55,15 @@ function parseOutputsWithPsbt( }; } -function toBaseOutputs(outputs: ParsedOutput[], coinName: UtxoCoinName): BaseOutput[]; -function toBaseOutputs(outputs: RecipientOutput[], coinName: UtxoCoinName): BaseOutput[]; +function toBaseOutputs(outputs: ParsedOutput[], addressCodec: AddressCodec): BaseOutput[]; +function toBaseOutputs(outputs: RecipientOutput[], addressCodec: AddressCodec): BaseOutput[]; function toBaseOutputs( outputs: (ParsedOutput | RecipientOutput)[], - coinName: UtxoCoinName + addressCodec: AddressCodec ): BaseOutput[] { return outputs.map( (o): BaseOutput => ({ - address: toExtendedAddressFormat(o.script, coinName), + address: addressCodec.toExtendedAddressFormat(o.script), amount: o.value === 'max' ? 'max' : BigInt(o.value), external: o.scriptId === undefined, }) @@ -75,18 +74,18 @@ export type ParsedOutputsBigInt = BaseParsedTransactionOutputs o.scriptId === undefined); const implicitExternalOutputs = implicitOutputs.filter((o) => o.scriptId === undefined); return { - outputs: toBaseOutputs(outputs, coinName), - changeOutputs: toBaseOutputs(changeOutputs, coinName), - explicitExternalOutputs: toBaseOutputs(explicitExternalOutputs, coinName), + outputs: toBaseOutputs(outputs, addressCodec), + changeOutputs: toBaseOutputs(changeOutputs, addressCodec), + explicitExternalOutputs: toBaseOutputs(explicitExternalOutputs, addressCodec), explicitExternalSpendAmount: sumValues(explicitExternalOutputs), - implicitExternalOutputs: toBaseOutputs(implicitExternalOutputs, coinName), + implicitExternalOutputs: toBaseOutputs(implicitExternalOutputs, addressCodec), implicitExternalSpendAmount: sumValues(implicitExternalOutputs), - missingOutputs: toBaseOutputs(missingOutputs, coinName), + missingOutputs: toBaseOutputs(missingOutputs, addressCodec), }; } @@ -94,17 +93,17 @@ export function toBaseParsedTransactionOutputsFromPsbt( psbt: Psbt | Uint8Array, descriptorMap: descriptorWallet.DescriptorMap, recipients: ITransactionRecipient[], - coinName: UtxoCoinName + addressCodec: AddressCodec ): ParsedOutputsBigInt { const wasmPsbt = psbt instanceof Psbt ? psbt : Psbt.deserialize(psbt); return toBaseParsedTransactionOutputs( parseOutputsWithPsbt( wasmPsbt, descriptorMap, - recipients.map((r) => toRecipientOutput(r, coinName)), - coinName + recipients.map((r) => toRecipientOutput(r, addressCodec)), + addressCodec ), - coinName + addressCodec ); } @@ -116,7 +115,8 @@ export type ParsedDescriptorTransaction = BaseP export function parse( coin: AbstractUtxoCoin, wallet: IDescriptorWallet, - params: ParseTransactionOptions + params: ParseTransactionOptions, + addressCodec: AddressCodec = new AddressCodec(coin.name) ): ParsedDescriptorTransaction { if (params.txParams.allowExternalChangeAddress) { throw new Error('allowExternalChangeAddress is not supported for descriptor wallets'); @@ -136,7 +136,7 @@ export function parse( const walletKeys = toBip32Triple(keychains); const descriptorMap = getDescriptorMapFromWallet(wallet, walletKeys, getPolicyForEnv(params.wallet.bitgo.env)); return { - ...toBaseParsedTransactionOutputsFromPsbt(wasmPsbt, descriptorMap, recipients, coin.name), + ...toBaseParsedTransactionOutputsFromPsbt(wasmPsbt, descriptorMap, recipients, addressCodec), keychains, keySignatures: getKeySignatures(wallet) ?? {}, customChange: undefined, diff --git a/modules/abstract-utxo/src/transaction/descriptor/parseToAmountType.ts b/modules/abstract-utxo/src/transaction/descriptor/parseToAmountType.ts index 9ddac22ffb..1decb0e19f 100644 --- a/modules/abstract-utxo/src/transaction/descriptor/parseToAmountType.ts +++ b/modules/abstract-utxo/src/transaction/descriptor/parseToAmountType.ts @@ -1,6 +1,7 @@ import { AbstractUtxoCoin, ParseTransactionOptions } from '../../abstractUtxoCoin'; import { BaseOutput, BaseParsedTransaction } from '../types'; import { IDescriptorWallet } from '../../descriptor/descriptorWallet'; +import { AddressCodec } from '../recipient'; import { parse, ParsedDescriptorTransaction } from './parse'; @@ -76,9 +77,10 @@ export function parsedDescriptorTransactionToTNumber( coin: AbstractUtxoCoin, wallet: IDescriptorWallet, - params: ParseTransactionOptions + params: ParseTransactionOptions, + addressCodec: AddressCodec = new AddressCodec(coin.name) ): BaseParsedTransaction> { - return parsedDescriptorTransactionToTNumber>(parse(coin, wallet, params), { + return parsedDescriptorTransactionToTNumber>(parse(coin, wallet, params, addressCodec), { amountTypeAggregate: coin.amountType, amountTypeBaseOutput: 'string', }); diff --git a/modules/abstract-utxo/src/transaction/descriptor/verifyTransaction.ts b/modules/abstract-utxo/src/transaction/descriptor/verifyTransaction.ts index 3e836b860e..bef56d839b 100644 --- a/modules/abstract-utxo/src/transaction/descriptor/verifyTransaction.ts +++ b/modules/abstract-utxo/src/transaction/descriptor/verifyTransaction.ts @@ -3,7 +3,7 @@ import type { Psbt, descriptorWallet } from '@bitgo/wasm-utxo'; import { AbstractUtxoCoin, VerifyTransactionOptions } from '../../abstractUtxoCoin'; import { BaseOutput, BaseParsedTransactionOutputs } from '../types'; -import { UtxoCoinName } from '../../names'; +import { AddressCodec } from '../recipient'; import { decodeDescriptorPsbt } from '../decode'; import { toBaseParsedTransactionOutputsFromPsbt } from './parse'; @@ -54,9 +54,9 @@ export function assertValidTransaction( psbt: Psbt | Uint8Array, descriptors: descriptorWallet.DescriptorMap, recipients: ITransactionRecipient[], - coinName: UtxoCoinName + addressCodec: AddressCodec ): void { - assertExpectedOutputDifference(toBaseParsedTransactionOutputsFromPsbt(psbt, descriptors, recipients, coinName)); + assertExpectedOutputDifference(toBaseParsedTransactionOutputsFromPsbt(psbt, descriptors, recipients, addressCodec)); } /** @@ -74,7 +74,8 @@ export function assertValidTransaction( export async function verifyTransaction( coin: AbstractUtxoCoin, params: VerifyTransactionOptions, - descriptorMap: descriptorWallet.DescriptorMap + descriptorMap: descriptorWallet.DescriptorMap, + addressCodec: AddressCodec = new AddressCodec(coin.name) ): Promise { let psbt: Psbt; try { @@ -94,13 +95,13 @@ export async function verifyTransaction( ); } - assertValidTransaction(psbt, descriptorMap, params.txParams.recipients ?? [], coin.name); + assertValidTransaction(psbt, descriptorMap, params.txParams.recipients ?? [], addressCodec); const parsedOutputs = toBaseParsedTransactionOutputsFromPsbt( psbt, descriptorMap, params.txParams.recipients ?? [], - coin.name + addressCodec ); if (params.txParams.qr) { diff --git a/modules/abstract-utxo/src/transaction/fixedScript/parseOutput.ts b/modules/abstract-utxo/src/transaction/fixedScript/parseOutput.ts index 4351e6d521..5a079a42de 100644 --- a/modules/abstract-utxo/src/transaction/fixedScript/parseOutput.ts +++ b/modules/abstract-utxo/src/transaction/fixedScript/parseOutput.ts @@ -14,7 +14,7 @@ import { import { AbstractUtxoCoin } from '../../abstractUtxoCoin'; import { Output, FixedScriptWalletOutput } from '../types'; -import { fromExtendedAddressFormatToScript } from '../recipient'; +import type { AddressCodec } from '../recipient'; const debug = debugLib('bitgo:v2:parseoutput'); @@ -199,10 +199,10 @@ export interface ParseOutputOptions { verification: VerificationOptions; keychainArray: Triple<{ pub: string }>; wallet: IWallet; + addressCodec: AddressCodec; txParams: { recipients: ITransactionRecipient[]; changeAddress?: string; - unifiedRecipientPreference?: string; }; customChange?: CustomChangeOptions; reqId?: IRequestTracer; @@ -215,6 +215,7 @@ export async function parseOutput({ verification, keychainArray, wallet, + addressCodec, txParams, customChange, reqId, @@ -280,12 +281,10 @@ 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, resolveScript).equals( - fromExtendedAddressFormatToScript(currentAddress, coin.name, resolveScript) - ) + addressCodec + .fromExtendedAddressFormatToScript(recipient.address) + .equals(addressCodec.fromExtendedAddressFormatToScript(currentAddress)) ); if (isCurrentAddressInRecipients) { diff --git a/modules/abstract-utxo/src/transaction/fixedScript/parseTransaction.ts b/modules/abstract-utxo/src/transaction/fixedScript/parseTransaction.ts index 8e7e9aa759..b8c79e2492 100644 --- a/modules/abstract-utxo/src/transaction/fixedScript/parseTransaction.ts +++ b/modules/abstract-utxo/src/transaction/fixedScript/parseTransaction.ts @@ -14,13 +14,7 @@ import { UtxoNamedKeychains, } from '../../keychains'; import { verifyKeySignature } from '../../verifyKey'; -import { - assertValidTransactionRecipient, - fromExtendedAddressFormatToScript, - isScriptRecipient, - toExtendedAddressFormat, - toOutputScript, -} from '../recipient'; +import { AddressCodec } from '../recipient'; import { ComparableOutput, ExpectedOutput, outputDifference } from '../outputDifference'; import { toTNumber } from '../../tnumber'; @@ -39,9 +33,9 @@ function toCanonicalTransactionRecipient( address: string; } { const amount = BigInt(output.valueString); - assertValidTransactionRecipient({ amount, address: output.address }); + AddressCodec.assertValidTransactionRecipient({ amount, address: output.address }); assert(output.address, 'address is required'); - if (isScriptRecipient(output.address)) { + if (AddressCodec.isScriptRecipient(output.address)) { return { amount, address: output.address }; } return { amount, address: coin.canonicalAddress(output.address) }; @@ -49,7 +43,8 @@ function toCanonicalTransactionRecipient( async function parseRbfTransaction( coin: AbstractUtxoCoin, - params: ParseTransactionOptions + params: ParseTransactionOptions, + addressCodec: AddressCodec = new AddressCodec(coin.name) ): Promise> { const { txParams, wallet } = params; @@ -68,27 +63,28 @@ async function parseRbfTransaction( ); // Recurse into parseTransaction with the derived recipients and without rbfTxIds - return parseTransaction(coin, { - ...params, - txParams: { - ...txParams, - recipients, - rbfTxIds: undefined, + return parseTransaction( + coin, + { + ...params, + txParams: { + ...txParams, + recipients, + rbfTxIds: undefined, + }, }, - }); + addressCodec + ); } function toExpectedOutputs( - coin: AbstractUtxoCoin, + addressCodec: AddressCodec, txParams: { 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) { @@ -98,21 +94,21 @@ function toExpectedOutputs( } return [ { - script: toOutputScript(output, coin.name, resolveScript), + script: addressCodec.toOutputScript(output), value: output.amount === 'max' ? 'max' : BigInt(output.amount), }, ]; } return [ { - script: fromExtendedAddressFormatToScript(output.address, coin.name, resolveScript), + script: addressCodec.fromExtendedAddressFormatToScript(output.address), value: output.amount === 'max' ? 'max' : BigInt(output.amount), }, ]; }); if (txParams.allowExternalChangeAddress && txParams.changeAddress) { expectedOutputs.push({ - script: toOutputScript(txParams.changeAddress, coin.name, resolveScript), + script: addressCodec.toOutputScript(txParams.changeAddress), // 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', @@ -139,13 +135,14 @@ function verifyCustomChangeKeys(userKeychain: UtxoKeychain, customChange: Custom export async function parseTransaction( coin: AbstractUtxoCoin, - params: ParseTransactionOptions + params: ParseTransactionOptions, + addressCodec: AddressCodec ): Promise> { const { txParams, txPrebuild, wallet, verification = {}, reqId } = params; // Branch off early for RBF transactions if (txParams.rbfTxIds) { - return parseRbfTransaction(coin, params); + return parseRbfTransaction(coin, params, addressCodec); } if (!_.isUndefined(verification.disableNetworking) && !_.isBoolean(verification.disableNetworking)) { @@ -173,7 +170,7 @@ export async function parseTransaction( throw new Error('missing required txPrebuild property txHex'); } - const expectedOutputs = toExpectedOutputs(coin, txParams); + const expectedOutputs = toExpectedOutputs(addressCodec, txParams); // get the keychains from the custom change wallet if needed let customChange: CustomChangeOptions | undefined; @@ -232,10 +229,10 @@ export async function parseTransaction( verification, keychainArray: toKeychainTriple(keychains), wallet, + addressCodec, txParams: { recipients: txParams.recipients ?? [], changeAddress: txParams.changeAddress, - unifiedRecipientPreference: txParams.unifiedRecipientPreference, }, customChange, reqId, @@ -251,9 +248,7 @@ export async function parseTransaction( function toComparableOutputsWithExternal(outputs: Output[]): ComparableOutputWithExternal[] { return outputs.map((output) => ({ - script: fromExtendedAddressFormatToScript(output.address, coin.name, (address) => - coin.resolveOutputScript(address, txParams.unifiedRecipientPreference) - ), + script: addressCodec.fromExtendedAddressFormatToScript(output.address), value: output.amount === 'max' ? 'max' : (BigInt(output.amount) as bigint | 'max'), external: output.external, })); @@ -293,7 +288,7 @@ export async function parseTransaction( function toOutputs(outputs: ExpectedOutput[] | ComparableOutputWithExternal[]): Output[] { return outputs.map((output) => ({ - address: toExtendedAddressFormat(output.script, coin.name), + address: addressCodec.toExtendedAddressFormat(output.script), amount: output.value.toString(), external: output.external, })); diff --git a/modules/abstract-utxo/src/transaction/parseTransaction.ts b/modules/abstract-utxo/src/transaction/parseTransaction.ts index d597b42a33..965686154d 100644 --- a/modules/abstract-utxo/src/transaction/parseTransaction.ts +++ b/modules/abstract-utxo/src/transaction/parseTransaction.ts @@ -1,17 +1,19 @@ import { AbstractUtxoCoin, ParseTransactionOptions } from '../abstractUtxoCoin'; import { isDescriptorWallet } from '../descriptor'; +import { AddressCodec } from './recipient'; import { ParsedTransaction } from './types'; import * as descriptor from './descriptor'; import * as fixedScript from './fixedScript'; export async function parseTransaction( coin: AbstractUtxoCoin, - params: ParseTransactionOptions + params: ParseTransactionOptions, + addressCodec: AddressCodec = new AddressCodec(coin.name) ): Promise> { if (isDescriptorWallet(params.wallet)) { - return descriptor.parseToAmountType(coin, params.wallet, params); + return descriptor.parseToAmountType(coin, params.wallet, params, addressCodec); } else { - return fixedScript.parseTransaction(coin, params); + return fixedScript.parseTransaction(coin, params, addressCodec); } } diff --git a/modules/abstract-utxo/src/transaction/recipient.ts b/modules/abstract-utxo/src/transaction/recipient.ts index 20440e601b..a8e60794fa 100644 --- a/modules/abstract-utxo/src/transaction/recipient.ts +++ b/modules/abstract-utxo/src/transaction/recipient.ts @@ -1,83 +1,69 @@ -import { address } from '@bitgo/wasm-utxo'; +import { address as wasmAddress } from '@bitgo/wasm-utxo'; import { UtxoCoinName } from '../names'; const ScriptRecipientPrefix = 'scriptPubKey:'; +const OP_RETURN = 0x6a; -/** - * Check if the address is a script recipient (starts with `scriptPubKey:`). - * @param address - */ -export function isScriptRecipient(address: string): boolean { - return address.toLowerCase().startsWith(ScriptRecipientPrefix.toLowerCase()); -} +export type UnifiedRecipientPreference = 'transparent' | 'shielded'; -/** - * An extended address is one that encodes either a regular address or a hex encoded script with the prefix `scriptPubKey:`. - * This function converts the extended address format to either a script or an address. - * @param extendedAddress - */ -export function fromExtendedAddressFormat(extendedAddress: string): { address: string } | { script: string } { - if (isScriptRecipient(extendedAddress)) { - return { script: extendedAddress.slice(ScriptRecipientPrefix.length) }; - } - return { address: extendedAddress }; -} +/** Address/network-aware recipient conversion with overridable address decoding. */ +export class AddressCodec { + constructor(public readonly coinName: UtxoCoinName) {} -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'); + /** Check if the address is a script recipient (starts with `scriptPubKey:`). */ + static isScriptRecipient(address: string): boolean { + return address.toLowerCase().startsWith(ScriptRecipientPrefix.toLowerCase()); } - 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, - resolveScript?: (address: string, coinName: UtxoCoinName) => Uint8Array -): Buffer { - if (typeof v === 'string') { - return fromExtendedAddressFormatToScript(v, coinName, resolveScript); - } - if ('script' in v) { - return Buffer.from(v.script, 'hex'); + /** Convert an extended address to either a regular address or a raw script. */ + static fromExtendedAddressFormat(extendedAddress: string): { address: string } | { script: string } { + if (AddressCodec.isScriptRecipient(extendedAddress)) { + return { script: extendedAddress.slice(ScriptRecipientPrefix.length) }; + } + return { address: extendedAddress }; } - if ('address' in v) { - return fromExtendedAddressFormatToScript(v.address, coinName, resolveScript); + + static assertValidTransactionRecipient(output: { amount: bigint | number | string; address?: string }): void { + // In the case that this is an OP_RETURN output or another non-encodable scriptPubkey, we dont have an address. + // We will verify that the amount is zero, and if it isnt then we will throw an error. + if (!output.address || AddressCodec.isScriptRecipient(output.address)) { + if (output.amount.toString() !== '0') { + throw new Error( + `Only zero amounts allowed for non-encodeable scriptPubkeys: amount: ${output.amount}, address: ${output.address}` + ); + } + } } - throw new Error('invalid input'); -} -const OP_RETURN = 0x6a; + decode(address: string): Uint8Array { + return wasmAddress.toOutputScriptWithCoin(address, this.coinName); + } -/** - * Convert a script or address to the extended address format. - * @param script - * @param coinName - * @returns if the script is an OP_RETURN script, then it will be prefixed with `scriptPubKey:`, otherwise it will be converted to an address. - */ -export function toExtendedAddressFormat(script: Buffer, coinName: UtxoCoinName): string { - return script[0] === OP_RETURN - ? `${ScriptRecipientPrefix}${script.toString('hex')}` - : address.fromOutputScriptWithCoin(script, coinName); -} + fromExtendedAddressFormatToScript(extendedAddress: string): Buffer { + const result = AddressCodec.fromExtendedAddressFormat(extendedAddress); + if ('script' in result) { + return Buffer.from(result.script, 'hex'); + } + return Buffer.from(this.decode(result.address)); + } -export function assertValidTransactionRecipient(output: { amount: bigint | number | string; address?: string }): void { - // In the case that this is an OP_RETURN output or another non-encodable scriptPubkey, we dont have an address. - // We will verify that the amount is zero, and if it isnt then we will throw an error. - if (!output.address || isScriptRecipient(output.address)) { - if (output.amount.toString() !== '0') { - throw new Error( - `Only zero amounts allowed for non-encodeable scriptPubkeys: amount: ${output.amount}, address: ${output.address}` - ); + toOutputScript(v: string | { address: string } | { script: string }): Buffer { + if (typeof v === 'string') { + return this.fromExtendedAddressFormatToScript(v); } + if ('script' in v) { + return Buffer.from(v.script, 'hex'); + } + if ('address' in v) { + return this.fromExtendedAddressFormatToScript(v.address); + } + throw new Error('invalid input'); + } + + toExtendedAddressFormat(script: Buffer): string { + return script[0] === OP_RETURN + ? `${ScriptRecipientPrefix}${script.toString('hex')}` + : wasmAddress.fromOutputScriptWithCoin(script, this.coinName); } } diff --git a/modules/abstract-utxo/src/transaction/verifyTransaction.ts b/modules/abstract-utxo/src/transaction/verifyTransaction.ts index bda9ac9404..c44915d8fa 100644 --- a/modules/abstract-utxo/src/transaction/verifyTransaction.ts +++ b/modules/abstract-utxo/src/transaction/verifyTransaction.ts @@ -4,20 +4,23 @@ import { AbstractUtxoCoin, VerifyTransactionOptions } from '../abstractUtxoCoin' import { getDescriptorMapFromWallet, isDescriptorWallet, getPolicyForEnv } from '../descriptor'; import { fetchKeychains, toBip32Triple } from '../keychains'; +import { AddressCodec } from './recipient'; import * as fixedScript from './fixedScript'; import * as descriptor from './descriptor'; export async function verifyTransaction( coin: AbstractUtxoCoin, bitgo: BitGoBase, - params: VerifyTransactionOptions + params: VerifyTransactionOptions, + addressCodec: AddressCodec = new AddressCodec(coin.name) ): Promise { if (isDescriptorWallet(params.wallet)) { const walletKeys = toBip32Triple(await fetchKeychains(coin, params.wallet)); return descriptor.verifyTransaction( coin, params, - getDescriptorMapFromWallet(params.wallet, walletKeys, getPolicyForEnv(bitgo.env)) + getDescriptorMapFromWallet(params.wallet, walletKeys, getPolicyForEnv(bitgo.env)), + addressCodec ); } else { return fixedScript.verifyTransaction(coin, bitgo, params); diff --git a/modules/abstract-utxo/test/unit/transaction/descriptor/parse.ts b/modules/abstract-utxo/test/unit/transaction/descriptor/parse.ts index fdc4957c23..e6439fd6ae 100644 --- a/modules/abstract-utxo/test/unit/transaction/descriptor/parse.ts +++ b/modules/abstract-utxo/test/unit/transaction/descriptor/parse.ts @@ -13,8 +13,10 @@ import { ErrorImplicitExternalOutputs, ErrorMissingOutputs, } from '../../../../src/transaction/descriptor/verifyTransaction'; +import { AddressCodec } from '../../../../src/transaction/recipient'; import { toAmountType } from '../../../../src/transaction/descriptor/parseToAmountType'; import { BaseOutput } from '../../../../src/transaction/types'; +import { getUtxoCoin } from '../../util'; import { getFixtureRoot } from './fixtures.utils'; @@ -72,7 +74,7 @@ describe('parse', function () { psbt, getDescriptorMap('Wsh2Of3', getDefaultXPubs('a')), recipients.map(toBaseOutputString), - 'btc' + new AddressCodec(getUtxoCoin('btc').name) ); } diff --git a/modules/abstract-utxo/test/unit/transaction/descriptor/verifyTransactionQr.ts b/modules/abstract-utxo/test/unit/transaction/descriptor/verifyTransactionQr.ts index 00f86979a0..ae79cc4526 100644 --- a/modules/abstract-utxo/test/unit/transaction/descriptor/verifyTransactionQr.ts +++ b/modules/abstract-utxo/test/unit/transaction/descriptor/verifyTransactionQr.ts @@ -3,13 +3,14 @@ import assert from 'assert'; import * as testutils from '@bitgo/wasm-utxo/testutils'; import { verifyTransaction } from '../../../../src/transaction/descriptor/verifyTransaction'; -import { toExtendedAddressFormat } from '../../../../src/transaction/recipient'; +import { AddressCodec } from '../../../../src/transaction/recipient'; import { getUtxoCoin } from '../../util'; const { getDefaultXPubs, getDescriptor, getDescriptorMap, mockPsbt } = testutils.descriptor; describe('descriptor verifyTransaction - quantum-resistant sweep', function () { const coin = getUtxoCoin('tbtc'); + const addressCodec = new AddressCodec(coin.name); const xpubsSelf = getDefaultXPubs('a'); const xpubsOther = getDefaultXPubs('b'); @@ -46,7 +47,7 @@ describe('descriptor verifyTransaction - quantum-resistant sweep', function () { it('should reject when external outputs exist and qr is true', async function () { const psbt = buildPsbtWithExternal(); const externalScript = Buffer.from(descriptorOther.atDerivationIndex(0).scriptPubkey()); - const externalAddress = toExtendedAddressFormat(externalScript, 'tbtc'); + const externalAddress = new AddressCodec(coin.name).toExtendedAddressFormat(externalScript); await assert.rejects( verifyTransaction( @@ -59,7 +60,8 @@ describe('descriptor verifyTransaction - quantum-resistant sweep', function () { txPrebuild: { txHex: Buffer.from(psbt.serialize()).toString('hex') }, wallet: {} as any, }, - descriptorMap + descriptorMap, + addressCodec ), /quantum-resistant sweep transactions must only contain wallet-internal outputs/ ); @@ -75,7 +77,8 @@ describe('descriptor verifyTransaction - quantum-resistant sweep', function () { txPrebuild: { txHex: Buffer.from(psbt.serialize()).toString('hex') }, wallet: {} as any, }, - descriptorMap + descriptorMap, + addressCodec ); assert.strictEqual(result, true); @@ -91,7 +94,8 @@ describe('descriptor verifyTransaction - quantum-resistant sweep', function () { txPrebuild: { txHex: Buffer.from(psbt.serialize()).toString('hex') }, wallet: {} as any, }, - descriptorMap + descriptorMap, + addressCodec ); assert.strictEqual(result, true); From 49b20f1dcc1aeb17bbea62e4ca5a646a005dc6a0 Mon Sep 17 00:00:00 2001 From: Otto Allmendinger Date: Mon, 7 Sep 2026 13:24:01 +0200 Subject: [PATCH 3/3] feat(abstract-utxo): support zcash unified address codec Keep Unified Address receiver selection in a Zcash-specific codec. Wire transaction parsing and verification to use the selected transparent or shielded receiver. Refs: CSHLD-1639 --- modules/abstract-utxo/src/abstractUtxoCoin.ts | 16 ---- .../src/impl/zec/addressCodec.ts | 34 ++++++++ modules/abstract-utxo/src/impl/zec/zec.ts | 64 +++++++------- .../test/unit/impl/zec/unifiedAddress.ts | 30 ++++--- .../test/unit/parseTransaction.ts | 58 +++++++++++++ .../test/unit/transaction/recipient.ts | 84 +++++++++++++------ .../sdk-core/src/bitgo/wallet/BuildParams.ts | 2 +- modules/sdk-core/src/bitgo/wallet/iWallet.ts | 2 +- 8 files changed, 201 insertions(+), 89 deletions(-) create mode 100644 modules/abstract-utxo/src/impl/zec/addressCodec.ts diff --git a/modules/abstract-utxo/src/abstractUtxoCoin.ts b/modules/abstract-utxo/src/abstractUtxoCoin.ts index cbce338555..efd345ce1e 100644 --- a/modules/abstract-utxo/src/abstractUtxoCoin.ts +++ b/modules/abstract-utxo/src/abstractUtxoCoin.ts @@ -248,12 +248,6 @@ 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,16 +538,6 @@ 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/addressCodec.ts b/modules/abstract-utxo/src/impl/zec/addressCodec.ts new file mode 100644 index 0000000000..692ed530d8 --- /dev/null +++ b/modules/abstract-utxo/src/impl/zec/addressCodec.ts @@ -0,0 +1,34 @@ +import { fixedScriptWallet, zcashAddress as wasmZcashAddress } from '@bitgo/wasm-utxo'; + +import { AddressCodec, type UnifiedRecipientPreference } from '../../transaction/recipient'; + +/** + * 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). + */ +export function tryParseUnifiedAddress( + address: string, + network: 'zec' | 'tzec' +): fixedScriptWallet.ZcashUnifiedAddress | undefined { + try { + return fixedScriptWallet.ZcashUnifiedAddress.parse(address, network); + } catch (e) { + return undefined; + } +} + +export class ZcashAddressCodec extends AddressCodec { + constructor(coinName: 'zec' | 'tzec', private readonly unifiedRecipientPreference?: UnifiedRecipientPreference) { + super(coinName); + } + + override decode(address: string): Uint8Array { + if ( + this.unifiedRecipientPreference === 'shielded' && + tryParseUnifiedAddress(address, this.coinName as 'zec' | 'tzec') + ) { + return wasmZcashAddress.toShieldedReceiverWithCoin(address, this.coinName); + } + return wasmZcashAddress.toTransparentReceiverWithCoin(address, this.coinName); + } +} diff --git a/modules/abstract-utxo/src/impl/zec/zec.ts b/modules/abstract-utxo/src/impl/zec/zec.ts index f091d3e30e..31b2df1224 100644 --- a/modules/abstract-utxo/src/impl/zec/zec.ts +++ b/modules/abstract-utxo/src/impl/zec/zec.ts @@ -1,34 +1,26 @@ /** * @prettier */ -import { - address as wasmAddress, - fixedScriptWallet, - hasPsbtMagic, - isWasmUtxoError, - zcashAddress as wasmZcashAddress, -} from '@bitgo/wasm-utxo'; +import { fixedScriptWallet, hasPsbtMagic, isWasmUtxoError } from '@bitgo/wasm-utxo'; import { BitGoBase, ExtraPrebuildParamsOptions, Wallet } from '@bitgo/sdk-core'; -import { AbstractUtxoCoin } from '../../abstractUtxoCoin'; +import { AbstractUtxoCoin, ParseTransactionOptions, VerifyTransactionOptions } from '../../abstractUtxoCoin'; import { stringToBufferTryFormats } from '../../transaction/decode'; +import type { UnifiedRecipientPreference } from '../../transaction/recipient'; +import type { ParsedTransaction } from '../../transaction/types'; import { UtxoCoinName } from '../../names'; +import { ZcashAddressCodec, tryParseUnifiedAddress } from './addressCodec'; 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; - } +function getUnifiedRecipientPreference( + txParams: ParseTransactionOptions['txParams'] +): UnifiedRecipientPreference | undefined { + return ( + txParams as ParseTransactionOptions['txParams'] & { + unifiedRecipientPreference?: UnifiedRecipientPreference; + } + ).unifiedRecipientPreference; } export class Zec extends AbstractUtxoCoin { @@ -50,7 +42,7 @@ export class Zec extends AbstractUtxoCoin { */ override async getExtraPrebuildParams(buildParams: ExtraPrebuildParamsOptions & { wallet: Wallet }) { const extraParams = await super.getExtraPrebuildParams(buildParams); - const unifiedRecipientPreference = buildParams.unifiedRecipientPreference as string | undefined; + const unifiedRecipientPreference = buildParams.unifiedRecipientPreference as UnifiedRecipientPreference | undefined; if (unifiedRecipientPreference === undefined) { return extraParams; } @@ -74,18 +66,22 @@ export class Zec extends AbstractUtxoCoin { 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); + override parseTransaction( + params: ParseTransactionOptions + ): Promise> { + return this.parseTransactionWithAddressCodec( + params, + new ZcashAddressCodec(this.name as 'zec' | 'tzec', getUnifiedRecipientPreference(params.txParams)) + ); + } + + override verifyTransaction( + params: VerifyTransactionOptions + ): Promise { + return this.verifyTransactionWithAddressCodec( + params, + new ZcashAddressCodec(this.name as 'zec' | 'tzec', getUnifiedRecipientPreference(params.txParams)) + ); } /** diff --git a/modules/abstract-utxo/test/unit/impl/zec/unifiedAddress.ts b/modules/abstract-utxo/test/unit/impl/zec/unifiedAddress.ts index 2c989e102c..59ea6c7ea9 100644 --- a/modules/abstract-utxo/test/unit/impl/zec/unifiedAddress.ts +++ b/modules/abstract-utxo/test/unit/impl/zec/unifiedAddress.ts @@ -8,6 +8,7 @@ import { ExtraPrebuildParamsOptions, Wallet } from '@bitgo/sdk-core'; import { getUtxoCoin, defaultBitGo, getDefaultWasmWalletKeys } from '../../util'; import { Zec } from '../../../../src/impl/zec'; +import { ZcashAddressCodec } from '../../../../src/impl/zec/addressCodec'; type UaVector = { network: 'zec' | 'tzec'; @@ -61,31 +62,40 @@ describe('Zec Unified Address support', function () { }); }); - describe('resolveOutputScript', function () { + describe('ZcashAddressCodec', function () { it("resolves a unified address's Orchard/Ironwood receiver when preference is 'shielded'", function () { - const script = tzec.resolveOutputScript(TESTNET_UA.unified, 'shielded'); + const script = new ZcashAddressCodec('tzec', 'shielded').decode(TESTNET_UA.unified); 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'); + const script = new ZcashAddressCodec('zec', 'shielded').decode(MAINNET_UA.unified); 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 a unified address's transparent receiver by default and with 'transparent'", function () { + const expectedScript = `76a914${TESTNET_UA.transparentPubkeyHashHex}88ac`; + assert.strictEqual( + Buffer.from(new ZcashAddressCodec('tzec').decode(TESTNET_UA.unified)).toString('hex'), + expectedScript + ); + assert.strictEqual( + Buffer.from(new ZcashAddressCodec('tzec', 'transparent').decode(TESTNET_UA.unified)).toString('hex'), + expectedScript + ); }); 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'), + Buffer.from(new ZcashAddressCodec('tzec').decode(TESTNET_UA.transparentAddress as string)).toString('hex'), expectedScript ); assert.strictEqual( - Buffer.from(tzec.resolveOutputScript(TESTNET_UA.transparentAddress as string, 'shielded')).toString('hex'), + Buffer.from(new ZcashAddressCodec('tzec', 'shielded').decode(TESTNET_UA.transparentAddress as string)).toString( + 'hex' + ), expectedScript ); }); @@ -221,7 +231,7 @@ describe('Zec Unified Address support', function () { scriptId: { chain: 0, index: 1 }, }); psbt.addWalletOutput(walletKeys, { chain: 1, index: 0, value: 100000n }); - const externalScript = tzec.resolveOutputScript(TESTNET_UA.transparentAddress as string); + const externalScript = new ZcashAddressCodec('tzec').decode(TESTNET_UA.transparentAddress as string); psbt.addTransparentOutput(externalScript, 12345n, unifiedAddress); return psbt; } @@ -236,7 +246,7 @@ describe('Zec Unified Address support', function () { assert.ok(parsed.transparentScript); assert.strictEqual( Buffer.from(parsed.transparentScript).toString('hex'), - Buffer.from(zec.resolveOutputScript(TESTNET_UA.transparentAddress as string)).toString('hex') + Buffer.from(new ZcashAddressCodec('tzec').decode(TESTNET_UA.transparentAddress as string)).toString('hex') ); } const tzecCoin = tzec as Zec; diff --git a/modules/abstract-utxo/test/unit/parseTransaction.ts b/modules/abstract-utxo/test/unit/parseTransaction.ts index c34b74acbd..3097dca554 100644 --- a/modules/abstract-utxo/test/unit/parseTransaction.ts +++ b/modules/abstract-utxo/test/unit/parseTransaction.ts @@ -1,4 +1,6 @@ import assert from 'assert'; +import * as fs from 'fs'; +import * as path from 'path'; import * as sinon from 'sinon'; import { Wallet, UnexpectedAddressError, VerificationOptions } from '@bitgo/sdk-core'; @@ -11,6 +13,10 @@ import { getUtxoCoin } from './util'; describe('Parse Transaction', function () { const coin = getUtxoCoin('tbtc'); + const zec = getUtxoCoin('tzec'); + const testnetUnifiedAddress = JSON.parse( + fs.readFileSync(path.join(__dirname, 'fixtures/tzec/unified_address.json'), 'utf8') + ).unified as string; /* * mock objects which get passed into parse transaction. @@ -125,6 +131,58 @@ describe('Parse Transaction', function () { }); }); + it('preserves script recipients through the transaction path', async function () { + const scriptRecipient = 'scriptPubKey:6a0c3230323651312d6175646974'; + stubExplainTransaction = sinon.stub(coin, 'explainTransaction').resolves({ + outputs: [{ address: scriptRecipient, amount: '0', external: false }], + changeOutputs: [], + } as unknown as TransactionExplanation); + + const parsedTransaction = await coin.parseTransaction({ + txParams: { recipients: [{ address: scriptRecipient, amount: '0' }] }, + txPrebuild: { txHex: '' }, + wallet: wallet as unknown as UtxoWallet, + verification, + }); + + assert.deepStrictEqual(parsedTransaction.outputs[0], { + address: scriptRecipient, + amount: '0', + external: false, + }); + }); + + for (const unifiedRecipientPreference of ['transparent', 'shielded'] as const) { + it(`uses the ${unifiedRecipientPreference} Zcash address codec in the transaction path`, async function () { + stubExplainTransaction = sinon.stub(zec, 'explainTransaction').resolves({ + outputs: [ + { + address: testnetUnifiedAddress, + amount: outputAmount, + external: false, + }, + ], + changeOutputs: [], + } as unknown as TransactionExplanation); + + const parsedTransaction = await zec.parseTransaction({ + txParams: { + recipients: [{ address: testnetUnifiedAddress, amount: outputAmount }], + unifiedRecipientPreference, + }, + txPrebuild: { txHex: '' }, + wallet: wallet as unknown as UtxoWallet, + verification, + }); + + assert.deepStrictEqual(parsedTransaction.outputs[0], { + address: testnetUnifiedAddress, + amount: outputAmount, + external: false, + }); + }); + } + describe('txHexPsbt (pending approval flow)', function () { it('should pass txHexPsbt to explainTransaction when both txHex and txHexPsbt are present', async function () { stubExplainTransaction = sinon.stub(coin, 'explainTransaction').resolves({ diff --git a/modules/abstract-utxo/test/unit/transaction/recipient.ts b/modules/abstract-utxo/test/unit/transaction/recipient.ts index 26b7e2fa48..adc0d00533 100644 --- a/modules/abstract-utxo/test/unit/transaction/recipient.ts +++ b/modules/abstract-utxo/test/unit/transaction/recipient.ts @@ -1,8 +1,15 @@ import assert from 'assert'; +import * as fs from 'fs'; +import * as path from 'path'; -import { toOutputScript, fromExtendedAddressFormatToScript } from '../../../src/transaction/recipient'; +import { AddressCodec } from '../../../src/transaction/recipient'; +import { ZcashAddressCodec } from '../../../src/impl/zec/addressCodec'; import { getUtxoCoin } from '../util/utxoCoins'; +const TESTNET_UA = JSON.parse( + fs.readFileSync(path.join(__dirname, '../fixtures/tzec/unified_address.json'), 'utf8') +) as { unified: string; ironwoodReceiverHex: string; transparentPubkeyHashHex: string }; + describe('AbstractUtxoCoin.preprocessBuildParams', function () { const coin = getUtxoCoin('btc'); @@ -55,66 +62,89 @@ describe('AbstractUtxoCoin.checkRecipient', function () { }); }); -describe('toOutputScript / fromExtendedAddressFormatToScript resolveScript override', function () { +describe('transaction-scoped address codec', function () { const coin = getUtxoCoin('btc'); const address = '1A1zP1eP5QGefi2DMPTfTL5SLmv7DivfNa'; - const defaultScript = fromExtendedAddressFormatToScript(address, coin.name); + const addressCodec = new AddressCodec(coin.name); + const defaultScript = addressCodec.fromExtendedAddressFormatToScript(address); - it('fromExtendedAddressFormatToScript uses the default wasm-utxo resolver when none is supplied', function () { - assert.deepStrictEqual(fromExtendedAddressFormatToScript(address, coin.name), defaultScript); + it('decodes ordinary addresses with the transaction codec', function () { + assert.deepStrictEqual(addressCodec.fromExtendedAddressFormatToScript(address), defaultScript); }); - it('fromExtendedAddressFormatToScript defers to a supplied resolveScript callback', function () { + it('uses the codec policy for addresses', function () { const fakeScript = Buffer.from('deadbeef', 'hex'); - let calledWith: [string, string] | undefined; - const script = fromExtendedAddressFormatToScript(address, coin.name, (a, c) => { - calledWith = [a, c]; + let calledWith: string | undefined; + const codec = new AddressCodec(coin.name); + codec.decode = (a: string) => { + calledWith = a; return fakeScript; - }); + }; + const script = codec.fromExtendedAddressFormatToScript(address); assert.deepStrictEqual(script, fakeScript); - assert.deepStrictEqual(calledWith, [address, coin.name]); + assert.strictEqual(calledWith, address); }); - it('fromExtendedAddressFormatToScript never invokes resolveScript for a scriptPubKey: recipient', function () { + it('never invokes the codec for a scriptPubKey recipient', function () { let called = false; - const script = fromExtendedAddressFormatToScript('scriptPubKey:deadbeef', coin.name, () => { + const codec = new AddressCodec(coin.name); + codec.decode = () => { called = true; return Buffer.from(''); - }); + }; + const script = codec.fromExtendedAddressFormatToScript('scriptPubKey:deadbeef'); assert.strictEqual(called, false); assert.deepStrictEqual(script, Buffer.from('deadbeef', 'hex')); }); - it('toOutputScript forwards resolveScript through for a string address', function () { + it('forwards the codec through toOutputScript for an address string', function () { const fakeScript = Buffer.from('cafebabe', 'hex'); - const script = toOutputScript(address, coin.name, () => fakeScript); + const codec = new AddressCodec(coin.name); + codec.decode = () => fakeScript; + const script = codec.toOutputScript(address); assert.deepStrictEqual(script, fakeScript); }); - it('toOutputScript forwards resolveScript through for an { address } object', function () { + it('forwards the codec through toOutputScript for an { address } object', function () { const fakeScript = Buffer.from('cafebabe', 'hex'); - const script = toOutputScript({ address }, coin.name, () => fakeScript); + const codec = new AddressCodec(coin.name); + codec.decode = () => fakeScript; + const script = codec.toOutputScript({ address }); assert.deepStrictEqual(script, fakeScript); }); - it('toOutputScript never invokes resolveScript for a { script } object', function () { + it('never invokes the codec for a { script } object', function () { let called = false; - const script = toOutputScript({ script: 'deadbeef' }, coin.name, () => { + const codec = new AddressCodec(coin.name); + codec.decode = () => { called = true; return Buffer.from(''); - }); + }; + const script = codec.toOutputScript({ script: 'deadbeef' }); 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 () { +describe('Zcash transaction-scoped address codec', function () { + it('resolves a transparent Unified Address with the Zcash transparent receiver', function () { + const script = new ZcashAddressCodec('tzec', 'transparent').fromExtendedAddressFormatToScript(TESTNET_UA.unified); + assert.strictEqual(script.toString('hex'), `76a914${TESTNET_UA.transparentPubkeyHashHex}88ac`); + }); + + it('resolves a shielded Unified Address with the Zcash shielded receiver', function () { + const script = new ZcashAddressCodec('tzec', 'shielded').fromExtendedAddressFormatToScript(TESTNET_UA.unified); + assert.strictEqual(script.toString('hex'), TESTNET_UA.ironwoodReceiverHex); + }); +}); + +describe('AddressCodec', function () { + it('defaults to the coin-agnostic wasm-utxo address codec', function () { const coin = getUtxoCoin('btc'); const address = '1A1zP1eP5QGefi2DMPTfTL5SLmv7DivfNa'; assert.deepStrictEqual( - Buffer.from(coin.resolveOutputScript(address)), - fromExtendedAddressFormatToScript(address, coin.name) + Buffer.from(new AddressCodec(coin.name).decode(address)), + new AddressCodec(coin.name).fromExtendedAddressFormatToScript(address) ); }); @@ -122,8 +152,8 @@ describe('AbstractUtxoCoin.resolveOutputScript', function () { const coin = getUtxoCoin('btc'); const address = '1A1zP1eP5QGefi2DMPTfTL5SLmv7DivfNa'; assert.deepStrictEqual( - Buffer.from(coin.resolveOutputScript(address, 'shielded')), - fromExtendedAddressFormatToScript(address, coin.name) + Buffer.from(new AddressCodec(coin.name).decode(address)), + new AddressCodec(coin.name).fromExtendedAddressFormatToScript(address) ); }); }); diff --git a/modules/sdk-core/src/bitgo/wallet/BuildParams.ts b/modules/sdk-core/src/bitgo/wallet/BuildParams.ts index 7fe7e80d00..3ece9a8707 100644 --- a/modules/sdk-core/src/bitgo/wallet/BuildParams.ts +++ b/modules/sdk-core/src/bitgo/wallet/BuildParams.ts @@ -40,7 +40,7 @@ export const BuildParamsUTXO = t.partial({ messages: t.array(Bip322Message), qr: t.boolean, /* Zcash-only: how to resolve a Unified Address recipient ('shielded' or transparent) */ - unifiedRecipientPreference: t.string, + unifiedRecipientPreference: t.union([t.literal('transparent'), t.literal('shielded')]), }); 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 4fb4df5fcf..514f03f212 100644 --- a/modules/sdk-core/src/bitgo/wallet/iWallet.ts +++ b/modules/sdk-core/src/bitgo/wallet/iWallet.ts @@ -235,7 +235,7 @@ export interface PrebuildTransactionOptions { * Orchard/Ironwood receiver (a shielded output); any other value (or omission) resolves it to * its transparent receiver. */ - unifiedRecipientPreference?: string; + unifiedRecipientPreference?: 'transparent' | 'shielded'; /** * Custom Solana instructions to include in the transaction. * Each instruction contains a program ID, accounts array, and data buffer.