From 494d19ebd8502b9120fb8e3c261d179d69e57d43 Mon Sep 17 00:00:00 2001 From: Veetrag Jain Date: Tue, 15 Sep 2026 22:35:23 +0530 Subject: [PATCH] feat(abstract-utxo): support ZEC v6 psbt decoding BREAKING CHANGE: explainTx, explainPsbtWasm and zec's resolvePsbtRecipients now require address codec to decode and verify the psbt output Ticket: CSHLD-1640 --- modules/abstract-utxo/src/abstractUtxoCoin.ts | 9 +- modules/abstract-utxo/src/impl/zec/address.ts | 43 ++ .../abstract-utxo/src/impl/zec/recipients.ts | 10 +- modules/abstract-utxo/src/impl/zec/types.ts | 7 + modules/abstract-utxo/src/impl/zec/zec.ts | 78 +++- .../src/transaction/explainTransaction.ts | 6 +- .../fixedScript/explainPsbtWasm.ts | 8 +- .../fixedScript/parseTransaction.ts | 22 +- .../src/transaction/recipient.ts | 41 +- modules/abstract-utxo/test/unit/bip322.ts | 26 +- .../test/unit/customChangeWallet.ts | 2 + .../test/unit/impl/zec/psbtDecode.ts | 75 ++++ .../unit/impl/zec/shieldedPrebuildAndSign.ts | 388 ++++++++++++++++++ .../test/unit/impl/zec/unit/address.ts | 32 +- .../impl/zec/unit/resolvePsbtRecipients.ts | 10 +- .../transaction/fixedScript/explainPsbt.ts | 9 +- .../unit/transaction/fixedScript/parsePsbt.ts | 1 + 17 files changed, 734 insertions(+), 33 deletions(-) create mode 100644 modules/abstract-utxo/test/unit/impl/zec/psbtDecode.ts create mode 100644 modules/abstract-utxo/test/unit/impl/zec/shieldedPrebuildAndSign.ts diff --git a/modules/abstract-utxo/src/abstractUtxoCoin.ts b/modules/abstract-utxo/src/abstractUtxoCoin.ts index 2aaf0cf02f..ff6953ae42 100644 --- a/modules/abstract-utxo/src/abstractUtxoCoin.ts +++ b/modules/abstract-utxo/src/abstractUtxoCoin.ts @@ -907,9 +907,14 @@ export abstract class AbstractUtxoCoin extends BaseCoin implements Musig2Partici if (wallet && isDescriptorWallet(wallet)) { // Descriptor wallets decode prebuild bytes straight into the wasm-utxo // descriptor Psbt, skipping the fixedScriptWallet.BitGoPsbt intermediate. - return explainTx(decodeDescriptorPsbt(params), { ...params, wallet }, this.wasmName); + return explainTx(decodeDescriptorPsbt(params), { ...params, wallet }, this.wasmName, this.addressCodec); } - return explainTx(this.decodeTransactionFromPrebuild(params), { ...params, wallet }, this.wasmName); + return explainTx( + this.decodeTransactionFromPrebuild(params), + { ...params, wallet }, + this.wasmName, + this.addressCodec + ); } /** diff --git a/modules/abstract-utxo/src/impl/zec/address.ts b/modules/abstract-utxo/src/impl/zec/address.ts index 5273252d81..484a248e93 100644 --- a/modules/abstract-utxo/src/impl/zec/address.ts +++ b/modules/abstract-utxo/src/impl/zec/address.ts @@ -4,6 +4,8 @@ import type { UnifiedRecipientPreference } from '@bitgo/sdk-core'; import { AddressCodec } from '../../transaction/recipient'; import { UtxoCoinName, WasmUtxoCoinName } from '../../names'; +import type { ZecAddressCodecOutput } from './types'; + export type ZcashAddressKind = 'transparent' | 'shielded'; /** @@ -79,6 +81,47 @@ export class ZecAddressCodec extends AddressCodec { } return zcashAddress.toShieldedReceiverWithCoin(address, this.wasmName); } + + /** Change addresses are always transparent wallet addresses. */ + override decodeChangeAddress(address: string): Uint8Array { + return zcashAddress.toTransparentReceiverWithCoin(address, this.wasmName); + } + override isMatchingScript(output: ZecAddressCodecOutput): boolean { + const address = output.address; + if (address === undefined || address === null) { + return true; + } + if (AddressCodec.isScriptRecipient(address)) { + return super.isMatchingScript(output); + } + + const matchesOutput = (decode: () => Uint8Array): boolean => { + try { + return Buffer.from(decode()).equals(Buffer.from(output.script)); + } catch { + return false; + } + }; + const isShielded = output.isShielded; + if (isShielded === true) { + return matchesOutput(() => zcashAddress.toShieldedReceiverWithCoin(address, this.wasmName)); + } + if (isShielded === false) { + return matchesOutput(() => zcashAddress.toTransparentReceiverWithCoin(address, this.wasmName)); + } + return ( + matchesOutput(() => zcashAddress.toTransparentReceiverWithCoin(address, this.wasmName)) || + matchesOutput(() => zcashAddress.toShieldedReceiverWithCoin(address, this.wasmName)) + ); + } + + /** Preserve a shielded output's original UA only after validating it against the raw script. */ + override toExtendedAddressFormat(script: Buffer, address?: string): string { + if (address !== undefined && !this.isMatchingScript({ address, script })) { + throw new Error(`address ${address} does not match the output script`); + } + return address ?? super.toExtendedAddressFormat(script); + } } /** diff --git a/modules/abstract-utxo/src/impl/zec/recipients.ts b/modules/abstract-utxo/src/impl/zec/recipients.ts index bbb3b470cd..9d5c29b2bd 100644 --- a/modules/abstract-utxo/src/impl/zec/recipients.ts +++ b/modules/abstract-utxo/src/impl/zec/recipients.ts @@ -2,6 +2,7 @@ import { fixedScriptWallet, zcashAddress } from '@bitgo/wasm-utxo'; import type { UnifiedRecipientPreference } from '@bitgo/sdk-core'; import { getReplayProtectionPubkeys } from '../../transaction/fixedScript/replayProtection'; +import type { AddressCodec } from '../../transaction/recipient'; import { ZcashCoinName } from './types'; @@ -70,7 +71,8 @@ export interface PsbtRecipient { */ export function resolvePsbtRecipients( psbt: fixedScriptWallet.ZcashBitGoPsbt, - walletKeys: fixedScriptWallet.RootWalletKeys + walletKeys: fixedScriptWallet.RootWalletKeys, + addressCodec: AddressCodec ): PsbtRecipient[] { const parsed = psbt.parseTransactionWithWalletKeys(walletKeys, { replayProtection: { publicKeys: getReplayProtectionPubkeys('zec') }, @@ -86,8 +88,12 @@ export function resolvePsbtRecipients( if (output.address === null) { return; } + // The raw parsed receiver/script is authoritative; proprietary Unified Address metadata + // must match it before it is exposed to callers. + if (!addressCodec.isMatchingScript(output)) { + throw new Error(`Output ${i} address ${output.address} does not match its raw recipient`); + } // The original client-passed Unified Address, stored verbatim in the PSBT's key-value - // pairs: the orchard PCZT for a shielded output (parsed `address` reports it in full), the // transparent-output proprietary map for a v4 transparent output. const unifiedAddress = output.isShielded ? output.address : psbt.transparentOutputUnifiedAddress(i) ?? undefined; recipients.push({ diff --git a/modules/abstract-utxo/src/impl/zec/types.ts b/modules/abstract-utxo/src/impl/zec/types.ts index ffb5747a9b..43c026ca0c 100644 --- a/modules/abstract-utxo/src/impl/zec/types.ts +++ b/modules/abstract-utxo/src/impl/zec/types.ts @@ -1,2 +1,9 @@ +import type { AddressCodecOutput } from '../../transaction/recipient'; + /** A Zcash coin name — the only UTXO coins with shielded (Orchard/Ironwood) support. */ export type ZcashCoinName = 'zec' | 'tzec'; + +/** Parsed output metadata used to distinguish Zcash transparent and shielded receivers. */ +export interface ZecAddressCodecOutput extends AddressCodecOutput { + isShielded?: boolean; +} diff --git a/modules/abstract-utxo/src/impl/zec/zec.ts b/modules/abstract-utxo/src/impl/zec/zec.ts index 0a69f14a0b..e2a9f5f2f1 100644 --- a/modules/abstract-utxo/src/impl/zec/zec.ts +++ b/modules/abstract-utxo/src/impl/zec/zec.ts @@ -1,12 +1,24 @@ /** * @prettier */ -import { BitGoBase, MPCAlgorithm } from '@bitgo/sdk-core'; +import { fixedScriptWallet, hasPsbtMagic, zcashAddress } from '@bitgo/wasm-utxo'; +import { + BitGoBase, + ExtraPrebuildParamsOptions, + MPCAlgorithm, + Wallet, + UnifiedRecipientPreference, +} from '@bitgo/sdk-core'; -import { AbstractUtxoCoin } from '../../abstractUtxoCoin'; -import { UtxoCoinName } from '../../names'; +import { AbstractUtxoCoin, ParseTransactionOptions } from '../../abstractUtxoCoin'; +import type { ParsedTransaction } from '../../transaction/types'; +import { stringToBufferTryFormats } from '../../transaction/decode'; +import { UtxoCoinName, toWasmUtxoCoinName } from '../../names'; +import { AddressCodec } from '../../transaction/recipient'; import { ZecAddressCodec } from './address'; +import { resolvePsbtRecipients, PsbtRecipient } from './recipients'; +import type { ZcashCoinName } from './types'; export class Zec extends AbstractUtxoCoin { readonly name: UtxoCoinName = 'zec'; @@ -36,4 +48,64 @@ export class Zec extends AbstractUtxoCoin { isValidAddress(address: string, param?: { anyFormat?: boolean; allowLightning?: boolean } | boolean): boolean { return this.addressCodec.isValidAddress(address); } + + private inferUnifiedRecipientPreference( + recipients: { address: string | undefined }[] | undefined + ): UnifiedRecipientPreference | undefined { + const shieldedness = (recipients ?? []).map((recipient): UnifiedRecipientPreference | undefined => { + const address = recipient.address; + if (address === undefined) { + return 'transparent'; + } + if (AddressCodec.isScriptRecipient(address)) { + return 'transparent'; + } + const hasTransparentReceiver = zcashAddress.hasTransparentReceiver(address, this.wasmName); + const hasOrchardReceiver = zcashAddress.hasOrchardReceiver(address, this.wasmName); + return hasOrchardReceiver && !hasTransparentReceiver + ? 'shielded' + : hasTransparentReceiver + ? 'transparent' + : undefined; + }); + if (shieldedness.includes('shielded') && shieldedness.includes('transparent')) { + throw new Error('Mixed shielded and transparent recipients are not supported'); + } + return shieldedness.includes('shielded') ? 'shielded' : undefined; + } + + override async parseTransaction( + params: ParseTransactionOptions + ): Promise> { + const preference = + params.txParams.unifiedRecipientPreference ?? this.inferUnifiedRecipientPreference(params.txParams.recipients); + return this.parseTransactionWithAddressCodec( + params, + new ZecAddressCodec(this.name, this.wasmName, preference ?? 'transparent') + ); + } + + override async getExtraPrebuildParams(buildParams: ExtraPrebuildParamsOptions & { wallet: Wallet }) { + const extraParams = await super.getExtraPrebuildParams(buildParams); + const { unifiedRecipientPreference } = buildParams; + if (unifiedRecipientPreference === undefined) { + return extraParams; + } + return { ...extraParams, unifiedRecipientPreference }; + } + + override decodeTransaction(input: Buffer | string): fixedScriptWallet.BitGoPsbt { + const buffer = typeof input === 'string' ? stringToBufferTryFormats(input, ['hex', 'base64']) : input; + if (!hasPsbtMagic(buffer)) { + return super.decodeTransaction(input); + } + return fixedScriptWallet.ZcashPsbt.fromBytes(buffer, toWasmUtxoCoinName(this.name) as ZcashCoinName); + } + resolveRecipientsFromPsbt(input: Buffer | string, walletKeys: fixedScriptWallet.RootWalletKeys): PsbtRecipient[] { + const psbt = this.decodeTransaction(input); + if (!(psbt instanceof fixedScriptWallet.ZcashBitGoPsbt)) { + throw new Error('expected a Zcash PSBT'); + } + return resolvePsbtRecipients(psbt, walletKeys, this.addressCodec); + } } diff --git a/modules/abstract-utxo/src/transaction/explainTransaction.ts b/modules/abstract-utxo/src/transaction/explainTransaction.ts index bb6642a919..51781ffee7 100644 --- a/modules/abstract-utxo/src/transaction/explainTransaction.ts +++ b/modules/abstract-utxo/src/transaction/explainTransaction.ts @@ -11,7 +11,7 @@ import { getReplayProtectionPubkeys } from './fixedScript/replayProtection'; import type { TransactionExplanationUtxolibPsbt, TransactionExplanationWasm } from './fixedScript/explainTransaction'; import * as fixedScript from './fixedScript'; import * as descriptor from './descriptor'; - +import type { AddressCodec } from './recipient'; /** * Decompose a raw transaction into useful information, such as the total amounts, * change amounts, and transaction outputs. @@ -24,7 +24,8 @@ export function explainTx( customChangeXpubs?: Triple; txInfo?: { unspents?: Unspent[] }; }, - coinName: UtxoCoinName | WasmUtxoCoinName + coinName: UtxoCoinName | WasmUtxoCoinName, + addressCodec: AddressCodec ): TransactionExplanationUtxolibPsbt | TransactionExplanationWasm { if (params.wallet && isDescriptorWallet(params.wallet)) { if (!(tx instanceof WasmPsbt)) { @@ -47,6 +48,7 @@ export function explainTx( throw new Error('pub triple must be valid triple or RootWalletKeys'); } return fixedScript.explainPsbtWasm(tx, walletXpubs, { + addressCodec, replayProtection: { publicKeys: getReplayProtectionPubkeys(coinName), }, diff --git a/modules/abstract-utxo/src/transaction/fixedScript/explainPsbtWasm.ts b/modules/abstract-utxo/src/transaction/fixedScript/explainPsbtWasm.ts index 813dc5013f..03100ac1bd 100644 --- a/modules/abstract-utxo/src/transaction/fixedScript/explainPsbtWasm.ts +++ b/modules/abstract-utxo/src/transaction/fixedScript/explainPsbtWasm.ts @@ -3,9 +3,9 @@ import { Triple } from '@bitgo/sdk-core'; import type { FixedScriptWalletOutput, Output, BitGoPsbt } from '../types'; import type { Bip322Message } from '../../abstractUtxoCoin'; +import { AddressCodec } from '../recipient'; import type { TransactionExplanationWasm } from './explainTransaction'; - function scriptToAddress(script: Uint8Array): string { return `scriptPubKey:${Buffer.from(script).toString('hex')}`; } @@ -40,6 +40,7 @@ function toExternalOutputBigInt(output: ParsedExternalOutput): Output { } interface ExplainPsbtWasmParams { + addressCodec: AddressCodec; replayProtection: { checkSignature?: boolean; publicKeys: Buffer[]; @@ -98,10 +99,11 @@ export function explainPsbtWasmBigInt( const parsedCustomChangeOutputs = params.customChangeWalletXpubs ? psbt.parseOutputsWithWalletKeys(params.customChangeWalletXpubs) : undefined; - const customChangeOutputs: FixedScriptWalletOutput[] = []; - parsed.outputs.forEach((output, i) => { + if (!params.addressCodec.isMatchingScript(output)) { + throw new Error(`Output ${i} address ${output.address} does not match its raw script`); + } const parseCustomChangeOutput = parsedCustomChangeOutputs?.[i]; if (isParsedWalletOutput(output)) { changeOutputs.push(toChangeOutputBigInt(output)); diff --git a/modules/abstract-utxo/src/transaction/fixedScript/parseTransaction.ts b/modules/abstract-utxo/src/transaction/fixedScript/parseTransaction.ts index 5391478609..d7a315395f 100644 --- a/modules/abstract-utxo/src/transaction/fixedScript/parseTransaction.ts +++ b/modules/abstract-utxo/src/transaction/fixedScript/parseTransaction.ts @@ -25,6 +25,9 @@ export type ComparableOutputWithExternal = (ComparableOutput | E external: boolean | undefined; }; +type ExpectedOutputWithAddress = ExpectedOutput & { address?: string }; +type ComparableOutputWithAddress = ComparableOutputWithExternal & { address: string }; + function toCanonicalTransactionRecipient( coin: AbstractUtxoCoin, output: { valueString: string; address?: string } @@ -84,9 +87,9 @@ function toExpectedOutputs( allowExternalChangeAddress?: boolean; changeAddress?: string; } -): ExpectedOutput[] { +): ExpectedOutputWithAddress[] { // verify that each recipient from txParams has their own output - const expectedOutputs: ExpectedOutput[] = (txParams.recipients ?? []).flatMap((output) => { + const expectedOutputs: ExpectedOutputWithAddress[] = (txParams.recipients ?? []).flatMap((output) => { if (output.address === undefined) { assert('script' in output, 'script is required for non-encodeable scriptPubkeys'); if (output.amount.toString() !== '0') { @@ -103,6 +106,7 @@ function toExpectedOutputs( { script: addressCodec.fromExtendedAddressFormatToScript(output.address), value: output.amount === 'max' ? 'max' : BigInt(output.amount), + address: output.address, }, ]; }); @@ -114,6 +118,7 @@ function toExpectedOutputs( value: 'max', // Note that the change output is not required to exist, so we mark it as optional. optional: true, + address: txParams.changeAddress, }); } return expectedOutputs; @@ -246,11 +251,15 @@ export async function parseTransaction( const changeOutputs = _.filter(allOutputDetails, { external: false }); - function toComparableOutputsWithExternal(outputs: Output[]): ComparableOutputWithExternal[] { + function toComparableOutputsWithExternal(outputs: Output[]): ComparableOutputWithAddress[] { return outputs.map((output) => ({ - script: addressCodec.fromExtendedAddressFormatToScript(output.address), + // Change/custom-change outputs are always transparent wallet addresses. + script: output.external + ? addressCodec.fromExtendedAddressFormatToScript(output.address) + : Buffer.from(addressCodec.decodeChangeAddress(output.address)), value: output.amount === 'max' ? 'max' : (BigInt(output.amount) as bigint | 'max'), external: output.external, + address: output.address, })); } @@ -277,7 +286,6 @@ export async function parseTransaction( * * This has become obsolete with the intoduction of `utxocore.paygo.verifyPayGoAddressProof()`. */ - // make sure that all the extra addresses are change addresses // get all the additional external outputs the server added and calculate their values const implicitExternalOutputs = implicitOutputs.filter((output) => output.external); @@ -286,9 +294,9 @@ export async function parseTransaction( coin.amountType ) as TNumber; - function toOutputs(outputs: ExpectedOutput[] | ComparableOutputWithExternal[]): Output[] { + function toOutputs(outputs: ExpectedOutputWithAddress[] | ComparableOutputWithAddress[]): Output[] { return outputs.map((output) => ({ - address: addressCodec.toExtendedAddressFormat(output.script), + address: addressCodec.toExtendedAddressFormat(output.script, output.address), amount: output.value.toString(), external: output.external, })); diff --git a/modules/abstract-utxo/src/transaction/recipient.ts b/modules/abstract-utxo/src/transaction/recipient.ts index d17c67fc65..6ec0f34465 100644 --- a/modules/abstract-utxo/src/transaction/recipient.ts +++ b/modules/abstract-utxo/src/transaction/recipient.ts @@ -5,6 +5,11 @@ import { toWasmUtxoCoinName, UtxoCoinName, WasmUtxoCoinName } from '../names'; const ScriptRecipientPrefix = 'scriptPubKey:'; const OP_RETURN = 0x6a; +export interface AddressCodecOutput { + address?: string | null; + script: Uint8Array; +} + /** Address/network-aware recipient conversion. */ export class AddressCodec { constructor( @@ -41,6 +46,38 @@ export class AddressCodec { return wasmAddress.toOutputScriptWithCoin(address, this.wasmName); } + /** + * Resolve a change address to its script. Change addresses are always transparent wallet + * addresses, so coins whose address resolution depends on transaction context (e.g. Zcash + * Unified Addresses with a bound recipient preference) override this to bypass that + * context. The base implementation defers to decode. + */ + decodeChangeAddress(address: string): Uint8Array { + return this.decode(address); + } + + /** + * Validate that an output address represents its raw script. Coins with multiple receiver + * types may override this to choose the receiver represented by parsed output metadata. + */ + isMatchingScript(output: { address?: string | null; script: Uint8Array }): boolean { + if (output.address === undefined || output.address === null) { + return true; + } + try { + return this.fromExtendedAddressFormatToScript(output.address).equals(Buffer.from(output.script)); + } catch { + return false; + } + } + /** + * Convert an output script back to its address form. The optional original address is accepted + * by coin-specific codecs that need to preserve an address not re-encodable from the script. + */ + toExtendedAddressFormat(script: Buffer, _address?: string): string { + return script[0] === OP_RETURN ? `${ScriptRecipientPrefix}${script.toString('hex')}` : this.encode(script); + } + encode(script: Uint8Array): string { return wasmAddress.fromOutputScriptWithCoin(script, this.wasmName); } @@ -73,10 +110,6 @@ export class AddressCodec { } throw new Error('invalid input'); } - - toExtendedAddressFormat(script: Buffer): string { - return script[0] === OP_RETURN ? `${ScriptRecipientPrefix}${script.toString('hex')}` : this.encode(script); - } } /** Legacy helper retained for consumers that use the module-level recipient API. */ diff --git a/modules/abstract-utxo/test/unit/bip322.ts b/modules/abstract-utxo/test/unit/bip322.ts index 6c70d02747..2654ef8bd6 100644 --- a/modules/abstract-utxo/test/unit/bip322.ts +++ b/modules/abstract-utxo/test/unit/bip322.ts @@ -6,6 +6,7 @@ import { bip322 as wasmBip322, fixedScriptWallet, BIP32, type Triple } from '@bi import { getKeyTriple } from '@bitgo/wasm-utxo/testutils'; import { explainPsbtWasm } from '../../src/transaction/fixedScript'; +import { AddressCodec } from '../../src/transaction/recipient'; import { BIP322MessageBroadcastable, BIP322MessageInfo, @@ -439,20 +440,37 @@ describe('BIP322', function () { it('should successfully run with a user nonce', function () { const psbt = createUnsignedPsbt(); - assertCommon(explainPsbtWasm(psbt, walletKeys, { replayProtection: { publicKeys: [] } }), 0); + assertCommon( + explainPsbtWasm(psbt, walletKeys, { + addressCodec: new AddressCodec('btc'), + replayProtection: { publicKeys: [] }, + }), + 0 + ); }); it('should successfully run with a user signature', function () { const psbt = createUnsignedPsbt(); psbt.sign(BIP32.fromBase58(xprivs[0])); - assertCommon(explainPsbtWasm(psbt, walletKeys, { replayProtection: { publicKeys: [] } }), 1); + assertCommon( + explainPsbtWasm(psbt, walletKeys, { + addressCodec: new AddressCodec('btc'), + replayProtection: { publicKeys: [] }, + }), + 1 + ); }); - it('should successfully run with a hsm signature', function () { const psbt = createUnsignedPsbt(); psbt.sign(BIP32.fromBase58(xprivs[0])); psbt.sign(BIP32.fromBase58(xprivs[2])); - assertCommon(explainPsbtWasm(psbt, walletKeys, { replayProtection: { publicKeys: [] } }), 2); + assertCommon( + explainPsbtWasm(psbt, walletKeys, { + addressCodec: new AddressCodec('btc'), + replayProtection: { publicKeys: [] }, + }), + 2 + ); }); }); diff --git a/modules/abstract-utxo/test/unit/customChangeWallet.ts b/modules/abstract-utxo/test/unit/customChangeWallet.ts index 92be7cc6d6..6beebe52e4 100644 --- a/modules/abstract-utxo/test/unit/customChangeWallet.ts +++ b/modules/abstract-utxo/test/unit/customChangeWallet.ts @@ -8,6 +8,7 @@ import { common, Wallet } from '@bitgo/sdk-core'; import { getSeed } from '@bitgo/sdk-test'; import { explainPsbtWasm } from '../../src/transaction/fixedScript'; +import { AddressCodec } from '../../src/transaction/recipient'; import { verifyKeySignature } from '../../src/verifyKey'; import { defaultBitGo, getUtxoCoin } from './util'; @@ -18,6 +19,7 @@ function explainPsbt( customChangeWalletKeys: utxolib.bitgo.RootWalletKeys | undefined ) { return explainPsbtWasm(psbt, fixedScriptWallet.RootWalletKeys.from(walletKeys), { + addressCodec: new AddressCodec('btc'), replayProtection: { publicKeys: [] }, customChangeWalletXpubs: customChangeWalletKeys ? fixedScriptWallet.RootWalletKeys.from(customChangeWalletKeys) diff --git a/modules/abstract-utxo/test/unit/impl/zec/psbtDecode.ts b/modules/abstract-utxo/test/unit/impl/zec/psbtDecode.ts new file mode 100644 index 0000000000..2b458a58c9 --- /dev/null +++ b/modules/abstract-utxo/test/unit/impl/zec/psbtDecode.ts @@ -0,0 +1,75 @@ +import assert from 'node:assert/strict'; + +import { fixedScriptWallet } from '@bitgo/wasm-utxo'; + +import { getUtxoCoin, getDefaultWasmWalletKeys } from '../../util'; + +/** + * Zec.decodeTransaction must deserialize both supported Zcash PSBT formats. `ZcashPsbt.fromBytes` + * reads the Zcash transaction version from the parsed metadata and dispatches to the + * format-specific implementation — `ZcashBitGoPsbt` for v4, `ZcashIronwoodBitGoPsbt` for v6 — + * so a shielded (v6 Ironwood) PSBT decodes to a parser that understands its orchard PCZT + * instead of silently degrading to the generic v4-shaped wrapper. + */ +describe('Zec PSBT decode (v4 + v6 Ironwood)', function () { + const zec = getUtxoCoin('zec'); + const tzec = getUtxoCoin('tzec'); + 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; + } + + function buildV6Psbt(): fixedScriptWallet.ZcashIronwoodBitGoPsbt { + const psbt = fixedScriptWallet.ZcashIronwoodBitGoPsbt.createEmpty('tzec', walletKeys, { blockHeight: 4200000 }); + psbt.addWalletInput({ txid: '33'.repeat(32), vout: 0, value: 100000n }, walletKeys, { + scriptId: { chain: 0, index: 0 }, + }); + psbt.addWalletOutput(walletKeys, { chain: 1, index: 0, value: 90000n }); + return psbt; + } + + it('decodes a v4 (Sapling-shaped) PSBT as a ZcashBitGoPsbt', function () { + const decoded = zec.decodeTransaction(Buffer.from(buildV4Psbt().serialize())); + assert.ok(decoded instanceof fixedScriptWallet.ZcashBitGoPsbt); + assert.ok(!(decoded instanceof fixedScriptWallet.ZcashIronwoodBitGoPsbt)); + }); + + it('decodes a v6 (Ironwood) PSBT as a ZcashIronwoodBitGoPsbt', function () { + const decoded = tzec.decodeTransaction(Buffer.from(buildV6Psbt().serialize())); + assert.ok(decoded instanceof fixedScriptWallet.ZcashIronwoodBitGoPsbt); + assert.strictEqual(decoded.getVersion(), 6); + }); + + it('decodes a v6 PSBT from a hex string', function () { + const hex = Buffer.from(buildV6Psbt().serialize()).toString('hex'); + const decoded = tzec.decodeTransaction(hex); + assert.ok(decoded instanceof fixedScriptWallet.ZcashIronwoodBitGoPsbt); + }); + + it('decodes a v6 PSBT from a base64 string', function () { + const base64 = Buffer.from(buildV6Psbt().serialize()).toString('base64'); + const decoded = tzec.decodeTransaction(base64); + assert.ok(decoded instanceof fixedScriptWallet.ZcashIronwoodBitGoPsbt); + }); + + it('decodeTransactionFromPrebuild decodes a v6 psbt hex', function () { + const hex = Buffer.from(buildV6Psbt().serialize()).toString('hex'); + const decoded = tzec.decodeTransactionFromPrebuild({ txHex: hex }); + assert.ok(decoded instanceof fixedScriptWallet.ZcashIronwoodBitGoPsbt); + }); + + it('throws the legacy-format error for a non-PSBT transaction', function () { + assert.throws(() => zec.decodeTransaction(Buffer.alloc(32)), /txFormat=legacy is deprecated/); + }); + + it('propagates deserializer errors for malformed PSBT bytes', function () { + // PSBT magic followed by junk. + assert.throws(() => zec.decodeTransaction(Buffer.from('70736274ff00', 'hex')), /Failed to deserialize PSBT/); + }); +}); 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..b112f69171 --- /dev/null +++ b/modules/abstract-utxo/test/unit/impl/zec/shieldedPrebuildAndSign.ts @@ -0,0 +1,388 @@ +import * as assert from 'assert'; + +import * as sinon from 'sinon'; +import nock = require('nock'); +import { common, VerificationOptions, Wallet } from '@bitgo/sdk-core'; +import { getSeed } from '@bitgo/sdk-test'; +import { fixedScriptWallet } from '@bitgo/wasm-utxo'; + +import { defaultBitGo, getUtxoCoin, keychainsBase58 } from '../../util'; +import { getDefaultWasmWalletKeys } from '../../util/keychains'; +import type { Zec } from '../../../../src/impl/zec'; +import { UtxoWallet } from '../../../../src/wallet'; + +// ZIP-316 testnet vectors (testnetWallet from the wasm-utxo unified_address fixtures). +const TESTNET_UNIFIED = + 'utest1w5m0qcnp8egl8qa296n70n8nvj0tqnzk90p7f48v7mjhhdrdqs8vgqydslg5plmzefawefnpmgmlm6hcy38m972erwxs04s02cq2prhguz8kqly75m6zjy56m08d5jnycgtpqtjeprte576gkmrxyszepgx76yzuwhh7m4lfz9jaq7unjk0x5ant46juxz73hsc6q4v3dqtzww00vps'; +const TESTNET_TRANSPARENT_ADDRESS = 'tmM4DvLVJKXZt5ydn1tqYTHvahpKSwgjuRk'; + +/** + * Exercises every client-side flow that runs BEFORE verifyTransaction/signTransaction on a + * shielded (v6 Ironwood) prebuild: prebuild post-processing, explanation, and recipient + * validation. Each of them must decode the v6 PSBT — via `Zec.decodeTransaction` -> + * `ZcashPsbt.fromBytes`, which auto-detects the transaction version — and handle the shielded + * recipient without error. + */ +const IRONWOOD_RECEIVER = Buffer.from( + 'd632c28aa0831d671be17709a42c9627e2eb687a1b2a55768ea470c9bae7499cd0bd3d0eb0484e307236b5', + 'hex' +); +let unifiedAddress: string; +let walletKeys: fixedScriptWallet.RootWalletKeys; + +before(function () { + unifiedAddress = fixedScriptWallet.ZcashUnifiedAddress.encodeOrchardReceiver( + new Uint8Array(IRONWOOD_RECEIVER), + 'tzec' + ); + walletKeys = getDefaultWasmWalletKeys().walletKeys; +}); + +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'); +} + +function mutateSerializedUnifiedAddress(psbtHex: string, unifiedAddress: string): string { + const bytes = Buffer.from(psbtHex, 'hex'); + const metadata = Buffer.from(unifiedAddress, 'utf8'); + const offset = bytes.indexOf(metadata); + assert.notStrictEqual(offset, -1, 'Unified Address metadata must be present in the serialized PSBT'); + // Change only the metadata bytes. The resulting address is intentionally invalid, which + // verifies that deserialization does not expose attacker-controlled proprietary metadata. + bytes[offset] = bytes[offset] === 0x75 ? 0x76 : 0x75; + return bytes.toString('hex'); +} +describe('Zec shielded pre-verify flows (v6 Ironwood PSBT)', function () { + const zec = getUtxoCoin('tzec'); + const bgUrl = common.Environments[defaultBitGo.getEnv()].uri; + + const keyDocumentObjects = keychainsBase58.map((keychain, keyIdx) => { + return { + id: getSeed(keychain.pub).toString('hex'), + pub: keychain.pub, + source: ['user', 'backup', 'bitgo'][keyIdx], + coinSpecific: {}, + }; + }); + + 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('rejects a v6 PSBT whose preserved UA metadata was mutated after serialization', async function () { + const mutatedHex = mutateSerializedUnifiedAddress(buildShieldedV6PrebuildHex(), unifiedAddress); + await assert.rejects( + zec.explainTransaction({ + txHex: mutatedHex, + pubs: [keyDocumentObjects[0].pub, keyDocumentObjects[1].pub, keyDocumentObjects[2].pub], + }), + /does not match its raw script/ + ); + }); +}); + +/** + * `parseTransaction` must take `unifiedRecipientPreference` into account when decoding a + * prebuild: a shielded recipient resolves to its 43-byte Orchard receiver, which only matches + * the PSBT's shielded output when the decode uses the 'shielded' preference. The preference is + * the caller's explicit `unifiedRecipientPreference`, or — when absent — inferred from the + * recipients themselves. + */ +describe('Zec parseTransaction unifiedRecipientPreference (v6 Ironwood PSBT)', function () { + const tzec = getUtxoCoin('tzec'); + + function getMockWallet(): UtxoWallet { + const mockWallet = sinon.createStubInstance(Wallet); + mockWallet.id.returns('test-wallet-id'); + mockWallet.coin.returns('tzec'); + mockWallet.coinSpecific.returns(undefined); + return mockWallet as unknown as UtxoWallet; + } + + function getVerification(): VerificationOptions { + const pubs = keychainsBase58.map((k) => k.pub); + return { + disableNetworking: true, + keychains: { + user: { id: '0', pub: pubs[0], type: 'independent' }, + backup: { id: '1', pub: pubs[1], type: 'independent' }, + bitgo: { id: '2', pub: pubs[2], type: 'independent' }, + }, + }; + } + + async function parseShieldedV6Prebuild( + txParams: { + recipients: { address: string; amount: string }[]; + unifiedRecipientPreference?: 'shielded' | 'transparent'; + }, + txHex = buildShieldedV6PrebuildHex() + ) { + return tzec.parseTransaction({ + wallet: getMockWallet(), + txParams, + txPrebuild: { txHex }, + verification: getVerification(), + }); + } + + it('infers the shielded preference from an Orchard-only Unified Address recipient', async function () { + const parsed = await parseShieldedV6Prebuild({ + recipients: [{ address: unifiedAddress, amount: '5000' }], + }); + const externalOutputs = parsed.outputs.filter((o) => o.external); + assert.strictEqual(externalOutputs.length, 1); + assert.strictEqual(externalOutputs[0].address, unifiedAddress); + }); + it('rejects a v6 PSBT with mutated UA metadata during parseTransaction', async function () { + const mutatedHex = mutateSerializedUnifiedAddress(buildShieldedV6PrebuildHex(), unifiedAddress); + await assert.rejects( + parseShieldedV6Prebuild({ recipients: [{ address: unifiedAddress, amount: '5000' }] }, mutatedHex), + /does not match its raw script/ + ); + }); + it('honors an explicit shielded preference', async function () { + const parsed = await parseShieldedV6Prebuild({ + recipients: [{ address: unifiedAddress, amount: '5000' }], + unifiedRecipientPreference: 'shielded', + }); + const externalOutputs = parsed.outputs.filter((o) => o.external); + assert.strictEqual(externalOutputs.length, 1); + assert.strictEqual(externalOutputs[0].address, unifiedAddress); + }); + + it('rejects a transparent resolution of a shielded recipient (intent mismatch)', async function () { + // Forcing the 'transparent' preference resolves the recipient's script — but an + // Orchard-only UA has no transparent receiver, so the decode must fail rather than + // silently mismatch. + await assert.rejects( + parseShieldedV6Prebuild({ + recipients: [{ address: unifiedAddress, amount: '5000' }], + unifiedRecipientPreference: 'transparent', + }) + ); + }); +}); + +describe('Zec resolveRecipientsFromPsbt (decode + recipient resolution)', function () { + const tzec = getUtxoCoin('tzec') as Zec; + const { walletKeys } = getDefaultWasmWalletKeys(); + const IRONWOOD_HEIGHT = 4200000; // after the NU6.3 testnet activation (4134000) + + 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.addressCodec.decode(TESTNET_TRANSPARENT_ADDRESS); + 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): 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'), IRONWOOD_RECEIVER.toString('hex')); + assert.strictEqual(parsed.hasTransparentReceiver, true); + assert.ok(parsed.transparentScript); + assert.strictEqual( + Buffer.from(parsed.transparentScript).toString('hex'), + Buffer.from(tzec.addressCodec.decode(TESTNET_TRANSPARENT_ADDRESS)).toString('hex') + ); + } + it('rejects a v4 PSBT whose preserved UA metadata was mutated after serialization', function () { + const mutatedHex = mutateSerializedUnifiedAddress( + Buffer.from(buildTransparentV4Psbt(TESTNET_UNIFIED).serialize()).toString('hex'), + TESTNET_UNIFIED + ); + assert.throws(() => tzec.resolveRecipientsFromPsbt(mutatedHex, walletKeys), /does not match its raw recipient/); + }); + + it('explainTransaction rejects a v4 PSBT whose preserved UA metadata was mutated', async function () { + const mutatedHex = mutateSerializedUnifiedAddress( + Buffer.from(buildTransparentV4Psbt(TESTNET_UNIFIED).serialize()).toString('hex'), + TESTNET_UNIFIED + ); + await assert.rejects( + tzec.explainTransaction({ + txHex: mutatedHex, + pubs: keychainsBase58.map((keychain) => keychain.pub) as [string, string, string], + }), + /does not match its raw script/ + ); + }); + it('resolves a shielded v6 (Ironwood) output to its Orchard Unified Address recipient', function () { + const recipients = tzec.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'), IRONWOOD_RECEIVER.toString('hex')); + }); + + it('rejects a v6 PSBT whose preserved UA metadata was mutated after serialization', function () { + const mutatedHex = mutateSerializedUnifiedAddress( + Buffer.from(buildShieldedV6Psbt(TESTNET_UNIFIED).serialize()).toString('hex'), + TESTNET_UNIFIED + ); + assert.throws(() => tzec.resolveRecipientsFromPsbt(mutatedHex, walletKeys), /does not match its raw recipient/); + }); + + it('explainTransaction rejects a v6 PSBT whose preserved UA metadata was mutated', async function () { + const mutatedHex = mutateSerializedUnifiedAddress( + Buffer.from(buildShieldedV6Psbt(TESTNET_UNIFIED).serialize()).toString('hex'), + TESTNET_UNIFIED + ); + await assert.rejects( + tzec.explainTransaction({ + txHex: mutatedHex, + pubs: keychainsBase58.map((keychain) => keychain.pub) as [string, string, string], + }), + /does not match its raw script/ + ); + }); + it('resolves transparent external outputs and excludes change', function () { + const recipients = tzec.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_TRANSPARENT_ADDRESS); + assert.strictEqual(recipient.amount, 12345n); + }); + + it('reports the original multi-receiver UA for a shielded output and decodes it back', function () { + const recipients = tzec.resolveRecipientsFromPsbt( + Buffer.from(buildShieldedV6Psbt(TESTNET_UNIFIED).serialize()), + walletKeys + ); + assert.strictEqual(recipients.length, 1); + const recipient = recipients[0]; + assert.strictEqual(recipient.destination.kind, 'zcashShielded'); + assert.strictEqual(recipient.address, TESTNET_UNIFIED); + assert.strictEqual(recipient.unifiedAddress, TESTNET_UNIFIED); + assert.strictEqual(recipient.destination.unifiedAddress, TESTNET_UNIFIED); + assert.strictEqual(Buffer.from(recipient.script).toString('hex'), IRONWOOD_RECEIVER.toString('hex')); + assertDecodesBackToFixtureRecipients(recipient.unifiedAddress as string); + }); + + it('reports the original multi-receiver UA for a transparent v4 output and decodes it back', function () { + const recipients = tzec.resolveRecipientsFromPsbt( + Buffer.from(buildTransparentV4Psbt(TESTNET_UNIFIED).serialize()), + walletKeys + ); + assert.strictEqual(recipients.length, 1); + const recipient = recipients[0]; + assert.strictEqual(recipient.destination.kind, 'zcashUnifiedTransparent'); + assert.strictEqual(recipient.address, TESTNET_UNIFIED); + assert.strictEqual(recipient.unifiedAddress, TESTNET_UNIFIED); + assert.strictEqual(recipient.destination.unifiedAddress, TESTNET_UNIFIED); + assertDecodesBackToFixtureRecipients(recipient.unifiedAddress as string); + }); + + it('resolves recipients from a hex PSBT string', function () { + const hex = Buffer.from(buildShieldedV6Psbt().serialize()).toString('hex'); + const recipients = tzec.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 hands the btc PSBT to ZcashPsbt.fromBytes, which rejects it for + // its missing Zcash consensus branch ID before the Zcash-type guard is ever reached. + assert.throws(() => tzec.resolveRecipientsFromPsbt(Buffer.from(psbt.serialize()), walletKeys)); + }); +}); + +describe('Zec getExtraPrebuildParams (unifiedRecipientPreference forwarding)', function () { + const zec = getUtxoCoin('zec'); + + function mockWallet(coin = zec): Wallet { + 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', + }); + assert.strictEqual(result.unifiedRecipientPreference, 'shielded'); + }); + + it('does not set unifiedRecipientPreference when absent', async function () { + const wallet = mockWallet(); + const result = await zec.getExtraPrebuildParams({ 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', + }); + assert.strictEqual(result.txFormat, 'psbt-lite'); + }); +}); diff --git a/modules/abstract-utxo/test/unit/impl/zec/unit/address.ts b/modules/abstract-utxo/test/unit/impl/zec/unit/address.ts index 511a8bfdb2..71d7a44a09 100644 --- a/modules/abstract-utxo/test/unit/impl/zec/unit/address.ts +++ b/modules/abstract-utxo/test/unit/impl/zec/unit/address.ts @@ -1,7 +1,7 @@ import assert from 'node:assert/strict'; import { BitGoAPI } from '@bitgo/sdk-api'; -import { fixedScriptWallet } from '@bitgo/wasm-utxo'; +import { fixedScriptWallet, zcashAddress } from '@bitgo/wasm-utxo'; import { Zec, @@ -11,6 +11,7 @@ import { isShieldedZcashAddress, isValidZcashAddress, } from '../../../../../src/impl/zec'; +import type { ZecAddressCodecOutput } from '../../../../../src/impl/zec/types'; // ZIP-316 unified-address test vectors, copied from // BitGoWASM/packages/wasm-utxo/test/fixtures/zcash/unified_address.json so @@ -222,6 +223,35 @@ describe('ZecAddressCodec', function () { assert.throws(() => codec.decode('not-a-real-address')); }); + it('isMatchingScript validates the receiver represented by parsed output metadata', function () { + const codec = new ZecAddressCodec('tzec', 'tzec'); + const transparentScript = codec.decode(testnetWallet.unified); + const shieldedScript = zcashAddress.toShieldedReceiverWithCoin(testnetWallet.unified, 'tzec'); + const transparentOutput: ZecAddressCodecOutput = { + address: testnetWallet.unified, + script: transparentScript, + isShielded: false, + }; + const shieldedOutput: ZecAddressCodecOutput = { + address: testnetWallet.unified, + script: shieldedScript, + isShielded: true, + }; + const transparentMetadataForShieldedOutput: ZecAddressCodecOutput = { ...transparentOutput, isShielded: true }; + const shieldedMetadataForTransparentOutput: ZecAddressCodecOutput = { ...shieldedOutput, isShielded: false }; + assert.strictEqual(codec.isMatchingScript(transparentMetadataForShieldedOutput), false); + assert.strictEqual(codec.isMatchingScript(shieldedMetadataForTransparentOutput), false); + assert.strictEqual( + codec.isMatchingScript({ address: testnetWallet.unified, script: Buffer.from('00', 'hex') }), + false + ); + assert.strictEqual( + codec.toExtendedAddressFormat(Buffer.from(shieldedScript), testnetWallet.unified), + testnetWallet.unified + ); + assert.throws(() => codec.toExtendedAddressFormat(Buffer.from(transparentScript), 'not-a-real-address')); + }); + // -- encode (inherited) ---------------------------------------------------- it('encode: round-trips a transparent script to address', function () { diff --git a/modules/abstract-utxo/test/unit/impl/zec/unit/resolvePsbtRecipients.ts b/modules/abstract-utxo/test/unit/impl/zec/unit/resolvePsbtRecipients.ts index e710e549eb..db258e32dd 100644 --- a/modules/abstract-utxo/test/unit/impl/zec/unit/resolvePsbtRecipients.ts +++ b/modules/abstract-utxo/test/unit/impl/zec/unit/resolvePsbtRecipients.ts @@ -2,6 +2,7 @@ import assert from 'node:assert/strict'; import { address as wasmAddress, fixedScriptWallet } from '@bitgo/wasm-utxo'; +import { ZecAddressCodec } from '../../../../../src/impl/zec'; import { resolvePsbtRecipients } from '../../../../../src/impl/zec/recipients'; import { getDefaultWasmWalletKeys } from '../../../util'; @@ -21,6 +22,7 @@ const IRONWOOD_RECEIVER = Buffer.from( describe('resolvePsbtRecipients', function () { const { walletKeys } = getDefaultWasmWalletKeys(); + const addressCodec = new ZecAddressCodec('tzec', 'tzec'); function buildV4Psbt(unifiedAddress?: string): fixedScriptWallet.ZcashBitGoPsbt { const psbt = fixedScriptWallet.ZcashBitGoPsbt.createEmpty('tzec', walletKeys, { blockHeight: 3146400 }); @@ -49,7 +51,7 @@ describe('resolvePsbtRecipients', function () { } it('resolves external transparent outputs and excludes wallet change (v4)', function () { - const recipients = resolvePsbtRecipients(buildV4Psbt(), walletKeys); + const recipients = resolvePsbtRecipients(buildV4Psbt(), walletKeys, addressCodec); assert.strictEqual(recipients.length, 1); const recipient = recipients[0]; assert.strictEqual(recipient.destination.kind, 'transparent'); @@ -63,7 +65,7 @@ describe('resolvePsbtRecipients', function () { }); it('reports the original UA verbatim for a transparent output built from a Unified Address (v4)', function () { - const recipients = resolvePsbtRecipients(buildV4Psbt(testnetWallet.unified), walletKeys); + const recipients = resolvePsbtRecipients(buildV4Psbt(testnetWallet.unified), walletKeys, addressCodec); assert.strictEqual(recipients.length, 1); const recipient = recipients[0]; assert.deepStrictEqual(recipient.destination, { @@ -77,7 +79,7 @@ describe('resolvePsbtRecipients', function () { }); it('resolves a shielded v6 output to its (re-encoded) Orchard Unified Address recipient', function () { - const recipients = resolvePsbtRecipients(buildV6Psbt(), walletKeys); + const recipients = resolvePsbtRecipients(buildV6Psbt(), walletKeys, addressCodec); assert.strictEqual(recipients.length, 1); const recipient = recipients[0]; const expectedAddress = fixedScriptWallet.ZcashUnifiedAddress.encodeOrchardReceiver( @@ -98,7 +100,7 @@ describe('resolvePsbtRecipients', function () { new Uint8Array(IRONWOOD_RECEIVER), 'tzec' ); - const recipients = resolvePsbtRecipients(buildV6Psbt(orchardOnlyUa), walletKeys); + const recipients = resolvePsbtRecipients(buildV6Psbt(orchardOnlyUa), walletKeys, addressCodec); assert.strictEqual(recipients.length, 1); const recipient = recipients[0]; assert.deepStrictEqual(recipient.destination, { kind: 'zcashShielded', unifiedAddress: orchardOnlyUa }); diff --git a/modules/abstract-utxo/test/unit/transaction/fixedScript/explainPsbt.ts b/modules/abstract-utxo/test/unit/transaction/fixedScript/explainPsbt.ts index 2cc561e871..3233667a4a 100644 --- a/modules/abstract-utxo/test/unit/transaction/fixedScript/explainPsbt.ts +++ b/modules/abstract-utxo/test/unit/transaction/fixedScript/explainPsbt.ts @@ -10,9 +10,11 @@ import { aggregateTransactionExplanations, type TransactionExplanationBigInt, } from '../../../../src/transaction/fixedScript'; - +import { AddressCodec } from '../../../../src/transaction/recipient'; +import { getCoinNameForNetwork } from '../../util'; function describeTransactionWith(acidTest: testutil.AcidTest) { describe(`${acidTest.name}`, function () { + const addressCodec = new AddressCodec(getCoinNameForNetwork(acidTest.network)); let walletXpubs: fixedScriptWallet.RootWalletKeys; let customChangeWalletXpubs: fixedScriptWallet.RootWalletKeys | undefined; let wasmPsbt: fixedScriptWallet.BitGoPsbt; @@ -28,6 +30,7 @@ function describeTransactionWith(acidTest: testutil.AcidTest) { it('should return expected outputs from explainPsbtWasm', function () { const wasmExplanation = explainPsbtWasm(wasmPsbt, walletXpubs, { + addressCodec, replayProtection: { publicKeys: [acidTest.getReplayProtectionPublicKey()], }, @@ -56,6 +59,7 @@ function describeTransactionWith(acidTest: testutil.AcidTest) { it('explainPsbtWasmBigInt returns bigint amounts and inputs array', function () { const result = explainPsbtWasmBigInt(wasmPsbt, walletXpubs, { + addressCodec, replayProtection: { publicKeys: [acidTest.getReplayProtectionPublicKey()] }, }); assert.strictEqual(typeof result.fee, 'bigint'); @@ -79,6 +83,7 @@ function describeTransactionWith(acidTest: testutil.AcidTest) { it('returns custom change outputs when parameter is set', function () { const wasmExplanation = explainPsbtWasm(wasmPsbt, walletXpubs, { + addressCodec, replayProtection: { publicKeys: [acidTest.getReplayProtectionPublicKey()], }, @@ -116,6 +121,7 @@ describe('explainPsbt(Wasm)', function () { assert.throws( () => explainPsbtWasmBigInt(wasmPsbt, walletXpubs, { + addressCodec: new AddressCodec('btc'), replayProtection: { publicKeys: [] }, }), /Fee calculation error: outputs exceed inputs/ @@ -137,6 +143,7 @@ describe('aggregateTransactionExplanations', function () { const wasmPsbt = fixedScriptWallet.BitGoPsbt.fromBytes(psbtBytes, networkName); const walletXpubs = fixedScriptWallet.RootWalletKeys.from(acidTest.rootWalletKeys); exp = explainPsbtWasmBigInt(wasmPsbt, walletXpubs, { + addressCodec: new AddressCodec('btc'), replayProtection: { publicKeys: [acidTest.getReplayProtectionPublicKey()] }, }); }); diff --git a/modules/abstract-utxo/test/unit/transaction/fixedScript/parsePsbt.ts b/modules/abstract-utxo/test/unit/transaction/fixedScript/parsePsbt.ts index e6c2676073..78d4cc30b2 100644 --- a/modules/abstract-utxo/test/unit/transaction/fixedScript/parsePsbt.ts +++ b/modules/abstract-utxo/test/unit/transaction/fixedScript/parsePsbt.ts @@ -93,6 +93,7 @@ function describeParseTransactionWith( wasmPsbt, acidTest.rootWalletKeys.triple.map((k) => k.neutered().toBase58()) as Triple, { + addressCodec: coin.addressCodec, replayProtection: { publicKeys: [acidTest.getReplayProtectionPublicKey()], },