From ea0aceddd1edc9799658fc1c9eaa820356fbc371 Mon Sep 17 00:00:00 2001 From: Veetrag Jain Date: Mon, 14 Sep 2026 17:08:18 +0530 Subject: [PATCH 1/2] feat(abstract-utxo): support ZEC v6 psbt decoding Ticket: CSHLD-1640 --- modules/abstract-utxo/src/impl/zec/address.ts | 10 + modules/abstract-utxo/src/impl/zec/zec.ts | 78 ++++- .../fixedScript/parseTransaction.ts | 25 +- .../src/transaction/recipient.ts | 25 ++ .../test/unit/impl/zec/psbtDecode.ts | 75 +++++ .../unit/impl/zec/shieldedPrebuildAndSign.ts | 317 ++++++++++++++++++ 6 files changed, 519 insertions(+), 11 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/impl/zec/address.ts b/modules/abstract-utxo/src/impl/zec/address.ts index 5273252d81..f68ef8e549 100644 --- a/modules/abstract-utxo/src/impl/zec/address.ts +++ b/modules/abstract-utxo/src/impl/zec/address.ts @@ -79,6 +79,16 @@ 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); + } + + /** Preserve a shielded output's original UA because its raw receiver cannot be encoded. */ + override outputScriptToAddress(script: Buffer, address?: string): string { + return address ?? this.toExtendedAddressFormat(script); + } } /** diff --git a/modules/abstract-utxo/src/impl/zec/zec.ts b/modules/abstract-utxo/src/impl/zec/zec.ts index 0a69f14a0b..87c8bc06fb 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); + } } diff --git a/modules/abstract-utxo/src/transaction/fixedScript/parseTransaction.ts b/modules/abstract-utxo/src/transaction/fixedScript/parseTransaction.ts index 5391478609..09d6a3775a 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,17 +106,19 @@ function toExpectedOutputs( { script: addressCodec.fromExtendedAddressFormatToScript(output.address), value: output.amount === 'max' ? 'max' : BigInt(output.amount), + address: output.address, }, ]; }); if (txParams.allowExternalChangeAddress && txParams.changeAddress) { expectedOutputs.push({ - script: addressCodec.toOutputScript(txParams.changeAddress), + script: addressCodec.decodeChangeScript(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', // 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,16 @@ 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 === false + ? addressCodec.decodeChangeScript(output.address) + : addressCodec.fromExtendedAddressFormatToScript(output.address), value: output.amount === 'max' ? 'max' : (BigInt(output.amount) as bigint | 'max'), external: output.external, + address: output.address, })); } @@ -277,7 +287,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 +295,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.outputScriptToAddress(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..bf27617291 100644 --- a/modules/abstract-utxo/src/transaction/recipient.ts +++ b/modules/abstract-utxo/src/transaction/recipient.ts @@ -41,6 +41,31 @@ 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); + } + + /** Resolve a transparent change address directly to a Buffer script. */ + decodeChangeScript(address: string): Buffer { + return Buffer.from(this.decodeChangeAddress(address)); + } + + /** + * Convert an output's scriptPubKey back to the address form the output should report. The + * base implementation encodes the script. Coins whose output scripts cannot always be + * re-encoded (e.g. Zcash shielded recipients, whose raw Orchard receiver has no scriptPubKey + * encoding) override this and may fall back to the output's original address. + */ + outputScriptToAddress(script: Buffer, address?: string): string { + return this.toExtendedAddressFormat(script); + } + encode(script: Uint8Array): string { return wasmAddress.fromOutputScriptWithCoin(script, this.wasmName); } 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..a3d02cd61e --- /dev/null +++ b/modules/abstract-utxo/test/unit/impl/zec/shieldedPrebuildAndSign.ts @@ -0,0 +1,317 @@ +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'); +} + +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); + }); +}); + +/** + * `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'; + }) { + return tzec.parseTransaction({ + wallet: getMockWallet(), + txParams, + txPrebuild: { txHex: buildShieldedV6PrebuildHex() }, + 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('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('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('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'); + }); +}); From 02b0b528fc1bd655c7c0e7563c8815670b96fe4a Mon Sep 17 00:00:00 2001 From: Otto Allmendinger Date: Mon, 14 Sep 2026 15:27:51 +0200 Subject: [PATCH 2/2] fix(abstract-utxo): validate Zcash output addresses Validate preserved Unified Addresses against raw Zcash output recipients before exposing them in transaction explanations. Refs: CSHLD-1640 --- modules/abstract-utxo/src/abstractUtxoCoin.ts | 4 +-- modules/abstract-utxo/src/impl/zec/address.ts | 34 ++++++++++++++++--- .../src/transaction/explainTransaction.ts | 5 ++- .../fixedScript/explainPsbtWasm.ts | 7 +++- .../src/transaction/recipient.ts | 17 ++++++++++ modules/abstract-utxo/test/unit/bip322.ts | 16 +++++++-- .../test/unit/customChangeWallet.ts | 2 ++ .../test/unit/impl/zec/unit/address.ts | 25 +++++++++++++- .../transaction/fixedScript/explainPsbt.ts | 8 +++++ .../unit/transaction/fixedScript/parsePsbt.ts | 1 + 10 files changed, 107 insertions(+), 12 deletions(-) diff --git a/modules/abstract-utxo/src/abstractUtxoCoin.ts b/modules/abstract-utxo/src/abstractUtxoCoin.ts index 2aaf0cf02f..326d7d66a8 100644 --- a/modules/abstract-utxo/src/abstractUtxoCoin.ts +++ b/modules/abstract-utxo/src/abstractUtxoCoin.ts @@ -907,9 +907,9 @@ 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 f68ef8e549..669b9641dc 100644 --- a/modules/abstract-utxo/src/impl/zec/address.ts +++ b/modules/abstract-utxo/src/impl/zec/address.ts @@ -1,7 +1,7 @@ import { address as wasmAddress, fixedScriptWallet, isCoinName, zcashAddress } from '@bitgo/wasm-utxo'; import type { UnifiedRecipientPreference } from '@bitgo/sdk-core'; -import { AddressCodec } from '../../transaction/recipient'; +import { AddressCodec, type AddressCodecOutput } from '../../transaction/recipient'; import { UtxoCoinName, WasmUtxoCoinName } from '../../names'; export type ZcashAddressKind = 'transparent' | 'shielded'; @@ -85,9 +85,35 @@ export class ZecAddressCodec extends AddressCodec { return zcashAddress.toTransparentReceiverWithCoin(address, this.wasmName); } - /** Preserve a shielded output's original UA because its raw receiver cannot be encoded. */ - override outputScriptToAddress(script: Buffer, address?: string): string { - return address ?? this.toExtendedAddressFormat(script); + override isMatchingScript(output: AddressCodecOutput): 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 = Reflect.get(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)) + ); } } diff --git a/modules/abstract-utxo/src/transaction/explainTransaction.ts b/modules/abstract-utxo/src/transaction/explainTransaction.ts index bb6642a919..c600b6c623 100644 --- a/modules/abstract-utxo/src/transaction/explainTransaction.ts +++ b/modules/abstract-utxo/src/transaction/explainTransaction.ts @@ -11,6 +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 { AddressCodec } from './recipient'; /** * Decompose a raw transaction into useful information, such as the total amounts, @@ -24,7 +25,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 +49,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..f9369d346c 100644 --- a/modules/abstract-utxo/src/transaction/fixedScript/explainPsbtWasm.ts +++ b/modules/abstract-utxo/src/transaction/fixedScript/explainPsbtWasm.ts @@ -1,8 +1,9 @@ -import { fixedScriptWallet, bip322 } from '@bitgo/wasm-utxo'; +import { bip322, fixedScriptWallet } from '@bitgo/wasm-utxo'; import { Triple } from '@bitgo/sdk-core'; import type { FixedScriptWalletOutput, Output, BitGoPsbt } from '../types'; import type { Bip322Message } from '../../abstractUtxoCoin'; +import type { AddressCodec } from '../recipient'; import type { TransactionExplanationWasm } from './explainTransaction'; @@ -40,6 +41,7 @@ function toExternalOutputBigInt(output: ParsedExternalOutput): Output { } interface ExplainPsbtWasmParams { + addressCodec: AddressCodec; replayProtection: { checkSignature?: boolean; publicKeys: Buffer[]; @@ -102,6 +104,9 @@ export function explainPsbtWasmBigInt( 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/recipient.ts b/modules/abstract-utxo/src/transaction/recipient.ts index bf27617291..807fcb5d40 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( @@ -86,6 +91,18 @@ export class AddressCodec { return Buffer.from(this.decode(result.address)); } + isMatchingScript(output: AddressCodecOutput): boolean { + if (output.address === undefined || output.address === null) { + return true; + } + + try { + return this.fromExtendedAddressFormatToScript(output.address).equals(Buffer.from(output.script)); + } catch { + return false; + } + } + toOutputScript(v: string | { address: string } | { script: string }): Buffer { if (typeof v === 'string') { return this.fromExtendedAddressFormatToScript(v); diff --git a/modules/abstract-utxo/test/unit/bip322.ts b/modules/abstract-utxo/test/unit/bip322.ts index 6c70d02747..13f664d95d 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,29 @@ 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/unit/address.ts b/modules/abstract-utxo/test/unit/impl/zec/unit/address.ts index 511a8bfdb2..0935849605 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, @@ -222,6 +222,29 @@ describe('ZecAddressCodec', function () { assert.throws(() => codec.decode('not-a-real-address')); }); + it('isMatchingScript selects the receiver represented by the parsed output', function () { + const codec = new ZecAddressCodec('tzec', 'tzec'); + const transparentScript = codec.decode(testnetWallet.unified); + const shieldedScript = zcashAddress.toShieldedReceiverWithCoin(testnetWallet.unified, 'tzec'); + + assert.strictEqual( + codec.isMatchingScript({ address: testnetWallet.unified, script: transparentScript, isShielded: false }), + true + ); + assert.strictEqual( + codec.isMatchingScript({ address: testnetWallet.unified, script: shieldedScript, isShielded: true }), + true + ); + assert.strictEqual( + codec.isMatchingScript({ address: testnetWallet.unified, script: transparentScript, isShielded: true }), + false + ); + assert.strictEqual( + codec.isMatchingScript({ address: testnetWallet.unified, script: shieldedScript, isShielded: false }), + false + ); + }); + // -- encode (inherited) ---------------------------------------------------- it('encode: round-trips a transparent script to address', function () { diff --git a/modules/abstract-utxo/test/unit/transaction/fixedScript/explainPsbt.ts b/modules/abstract-utxo/test/unit/transaction/fixedScript/explainPsbt.ts index 2cc561e871..6939696c42 100644 --- a/modules/abstract-utxo/test/unit/transaction/fixedScript/explainPsbt.ts +++ b/modules/abstract-utxo/test/unit/transaction/fixedScript/explainPsbt.ts @@ -10,9 +10,12 @@ 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 +31,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 +60,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 +84,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 +122,7 @@ describe('explainPsbt(Wasm)', function () { assert.throws( () => explainPsbtWasmBigInt(wasmPsbt, walletXpubs, { + addressCodec: new AddressCodec('btc'), replayProtection: { publicKeys: [] }, }), /Fee calculation error: outputs exceed inputs/ @@ -137,6 +144,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()], },