diff --git a/modules/abstract-utxo/src/abstractUtxoCoin.ts b/modules/abstract-utxo/src/abstractUtxoCoin.ts index efd345ce1e..bfa5bc0c9e 100644 --- a/modules/abstract-utxo/src/abstractUtxoCoin.ts +++ b/modules/abstract-utxo/src/abstractUtxoCoin.ts @@ -248,6 +248,8 @@ export interface TransactionParams extends BaseTransactionParams { /** Parameters for bridging intents (e.g. BTC -> sBTC peg-in), present when `type === 'bridging'`. */ bridgingParams?: BridgingParams; qr?: boolean; + /** Zcash-only preference for resolving Unified Address recipients. */ + unifiedRecipientPreference?: string; } export interface ParseTransactionOptions extends BaseParseTransactionOptions { diff --git a/modules/abstract-utxo/src/impl/zec/addressCodec.ts b/modules/abstract-utxo/src/impl/zec/addressCodec.ts new file mode 100644 index 0000000000..da161b0844 --- /dev/null +++ b/modules/abstract-utxo/src/impl/zec/addressCodec.ts @@ -0,0 +1,36 @@ +import { fixedScriptWallet, zcashAddress as wasmZcashAddress } from '@bitgo/wasm-utxo'; + +import { AddressCodec } from '../../transaction/recipient'; + +import type { UnifiedRecipientPreference } from './types'; + +/** + * Parse `address` as a ZIP-316 Unified Address for `network`, or return `undefined` if it isn't + * one (malformed, wrong network, or not bech32m-shaped at all). + */ +export function tryParseUnifiedAddress( + address: string, + network: 'zec' | 'tzec' +): fixedScriptWallet.ZcashUnifiedAddress | undefined { + try { + return fixedScriptWallet.ZcashUnifiedAddress.parse(address, network); + } catch (e) { + return undefined; + } +} + +export class ZcashAddressCodec extends AddressCodec { + constructor(coinName: 'zec' | 'tzec', private readonly unifiedRecipientPreference?: UnifiedRecipientPreference) { + super(coinName); + } + + override decode(address: string): Uint8Array { + if ( + this.unifiedRecipientPreference === 'shielded' && + tryParseUnifiedAddress(address, this.coinName as 'zec' | 'tzec') + ) { + return wasmZcashAddress.toShieldedReceiverWithCoin(address, this.coinName); + } + return wasmZcashAddress.toTransparentReceiverWithCoin(address, this.coinName); + } +} diff --git a/modules/abstract-utxo/src/impl/zec/index.ts b/modules/abstract-utxo/src/impl/zec/index.ts index 77e51324bb..c4c5fe555a 100644 --- a/modules/abstract-utxo/src/impl/zec/index.ts +++ b/modules/abstract-utxo/src/impl/zec/index.ts @@ -1,4 +1,5 @@ export * from './zec'; +export * from './recipients'; export * from './tzec'; export * from './address'; export * from './recipients'; diff --git a/modules/abstract-utxo/src/impl/zec/recipients.ts b/modules/abstract-utxo/src/impl/zec/recipients.ts index d56a4f88c9..af1564c2d5 100644 --- a/modules/abstract-utxo/src/impl/zec/recipients.ts +++ b/modules/abstract-utxo/src/impl/zec/recipients.ts @@ -1,8 +1,12 @@ +/** + * @prettier + */ import { fixedScriptWallet, zcashAddress } from '@bitgo/wasm-utxo'; +import { Triple } from '@bitgo/sdk-core'; import { getReplayProtectionPubkeys } from '../../transaction/fixedScript/replayProtection'; -import { ZcashCoinName, UnifiedRecipientPreference } from './types'; +import { UnifiedRecipientPreference, ZcashCoinName } from './types'; /** * How a recipient parsed from a Zcash PSBT is spent. @@ -50,30 +54,44 @@ export interface PsbtRecipient { * The original Unified Address the client supplied for this recipient, when the PSBT stores * one: the v6 (Ironwood) PCZT for a shielded output, the transparent-output proprietary * key-value map for a v4 transparent output. `undefined` when the recipient was built from a - * plain address. + * plain address (or the single-receiver UA re-encoding is byte-identical for a shielded + * output). */ unifiedAddress?: string; destination: PsbtRecipientDestination; } +export type ResolvePsbtRecipientsOptions = { + /** + * Custom change wallet xpubs, when the transaction spends to a custom change wallet. Outputs + * matching these keys are classified as change, not recipients — matching how + * `explainPsbtWasm` treats them. + */ + customChangeXpubs?: Triple; +}; + /** * Resolve the recipient list of a decoded Zcash PSBT (v4 Sapling-shaped or v6 Ironwood). * * Mirrors the recipient resolution of wallet-platform's utxo-core `buildTransaction` in the - * decode direction: every non-wallet output with a resolvable address is a recipient. A - * shielded output parses with `isShielded: true`, its `script` being the raw 43-byte receiver; - * when the build stored the client's original Unified Address (the v6 PCZT for shielded - * outputs, the transparent-output proprietary key-value map for v4), both the parsed address - * and `unifiedAddress` report it verbatim. Opaque outputs with no address (e.g. OP_RETURN) are - * skipped, as they carry no recipient. + * decode direction: every non-wallet, non-custom-change output with a resolvable address is a + * recipient. A shielded output parses with `isShielded: true`, its `script` being the raw + * 43-byte receiver; when the build stored the client's original Unified Address (the v6 PCZT for + * shielded outputs, the transparent-output proprietary key-value map for v4), both the parsed + * address and `unifiedAddress` report it verbatim. Opaque outputs with no address (e.g. + * OP_RETURN) are skipped, as they carry no recipient. */ export function resolvePsbtRecipients( psbt: fixedScriptWallet.ZcashBitGoPsbt, - walletKeys: fixedScriptWallet.RootWalletKeys + walletKeys: fixedScriptWallet.RootWalletKeys, + opts: ResolvePsbtRecipientsOptions = {} ): PsbtRecipient[] { const parsed = psbt.parseTransactionWithWalletKeys(walletKeys, { replayProtection: { publicKeys: getReplayProtectionPubkeys('zec') }, }); + const customChangeOutputs = opts.customChangeXpubs + ? psbt.parseOutputsWithWalletKeys(opts.customChangeXpubs) + : undefined; const recipients: PsbtRecipient[] = []; parsed.outputs.forEach((output, i) => { @@ -81,6 +99,10 @@ export function resolvePsbtRecipients( if (output.scriptId !== null) { return; } + // Outputs owned by the custom change wallet, if one was supplied. + if (customChangeOutputs?.[i]?.scriptId != null) { + return; + } // Opaque outputs (e.g. OP_RETURN) carry no recipient address. if (output.address === null) { return; @@ -106,20 +128,7 @@ export function resolvePsbtRecipients( /** * Infer the Unified-Address recipient preference for a Zcash transaction when the caller did - * not pass one — the counterpart of wallet-platform's utxo-core `buildTransaction` - * `classifyRecipientShieldedness`. - * - * A recipient that resolves to a transparent output — an ordinary transparent address, or a - * Unified Address carrying a transparent receiver — is classified `'transparent'`; a Unified - * Address carrying only an Orchard/Ironwood receiver is classified `'shielded'`. A mix of - * shielded and transparent recipients is rejected. An address that is neither a transparent - * address nor a Unified Address propagates the Unified-Address parse error — it is not - * silently defaulted to `'transparent'`. - * - * @returns `'shielded'` when every recipient resolves shielded, `undefined` when every - * recipient resolves transparent (the build's default). The `'transparent'` arm of the - * return type exists so callers can pass the explicit preference through unchanged; this - * function itself never returns `'transparent'`. + * not pass one. A mix of shielded and transparent recipients is rejected. */ export function getUnifiedRecipientPreference( name: ZcashCoinName, @@ -127,17 +136,11 @@ export function getUnifiedRecipientPreference( ): UnifiedRecipientPreference | undefined { const shieldedness = recipients.map((recipient) => { if (recipient.address === undefined) { - // Raw script inherently transparent. return 'transparent' as const; } - // Ordinary transparent address, or a Unified Address carrying a transparent receiver: - // resolves transparently either way (the build's default when no preference is given). if (zcashAddress.hasTransparentReceiver(recipient.address, name)) { return 'transparent' as const; } - // A shielded (Orchard/Ironwood-only) Unified Address is the only remaining resolvable - // form. An address that is none of the above propagates the parse error instead of - // assuming a default. const unified = fixedScriptWallet.ZcashUnifiedAddress.parse(recipient.address, name); if (unified.hasOrchardReceiver) { return 'shielded' as const; diff --git a/modules/abstract-utxo/src/impl/zec/zec.ts b/modules/abstract-utxo/src/impl/zec/zec.ts index f20e03d628..6d2fef4634 100644 --- a/modules/abstract-utxo/src/impl/zec/zec.ts +++ b/modules/abstract-utxo/src/impl/zec/zec.ts @@ -1,12 +1,28 @@ /** * @prettier */ -import { BitGoBase } from '@bitgo/sdk-core'; -import { zcashAddress } from '@bitgo/wasm-utxo'; +import { fixedScriptWallet, hasPsbtMagic, isWasmUtxoError } from '@bitgo/wasm-utxo'; +import { BitGoBase, ExtraPrebuildParamsOptions, Wallet } from '@bitgo/sdk-core'; -import { AbstractUtxoCoin } from '../../abstractUtxoCoin'; +import { AbstractUtxoCoin, ParseTransactionOptions, VerifyTransactionOptions } from '../../abstractUtxoCoin'; +import { stringToBufferTryFormats } from '../../transaction/decode'; +import type { ParsedTransaction } from '../../transaction/types'; import { UtxoCoinName } from '../../names'; +import { ZcashAddressCodec, tryParseUnifiedAddress } from './addressCodec'; +import { resolvePsbtRecipients, ResolvePsbtRecipientsOptions, PsbtRecipient } from './recipients'; +import type { UnifiedRecipientPreference } from './types'; + +function getUnifiedRecipientPreference( + txParams: ParseTransactionOptions['txParams'] +): UnifiedRecipientPreference | undefined { + return ( + txParams as ParseTransactionOptions['txParams'] & { + unifiedRecipientPreference?: UnifiedRecipientPreference; + } + ).unifiedRecipientPreference; +} + export class Zec extends AbstractUtxoCoin { readonly name: UtxoCoinName = 'zec'; @@ -18,9 +34,106 @@ export class Zec extends AbstractUtxoCoin { return new Zec(bitgo); } - isValidAddress(address: string, param?: { anyFormat?: boolean; allowLightning?: boolean } | boolean): boolean { - return ( - zcashAddress.hasTransparentReceiver(address, this.name) || zcashAddress.hasOrchardReceiver(address, this.name) + /** + * Forward `unifiedRecipientPreference` alongside the standard extra build params. Zcash builds + * that carry this preference always go through the wasm-utxo (Ironwood/v6-capable) build path + * on Wallet Platform rather than the legacy utxolib path, since utxolib has no notion of + * Unified Addresses or shielded outputs. + */ + override async getExtraPrebuildParams(buildParams: ExtraPrebuildParamsOptions & { wallet: Wallet }) { + const extraParams = await super.getExtraPrebuildParams(buildParams); + const unifiedRecipientPreference = buildParams.unifiedRecipientPreference as UnifiedRecipientPreference | undefined; + if (unifiedRecipientPreference === undefined) { + return extraParams; + } + return { ...extraParams, unifiedRecipientPreference }; + } + + /** + * In addition to ordinary transparent addresses, Zcash accepts ZIP-316 Unified Addresses that + * carry a transparent receiver, an Orchard/Ironwood receiver, or both. `unifiedRecipientPreference` + * (which of those receivers a build should spend to) is not this method's concern — it only + * answers whether `address` is a spendable address at all. + */ + override isValidAddress( + address: string, + param?: { anyFormat?: boolean; allowLightning?: boolean } | boolean + ): boolean { + const unifiedAddress = tryParseUnifiedAddress(address, this.name as 'zec' | 'tzec'); + if (unifiedAddress !== undefined) { + return unifiedAddress.transparentScript !== undefined || unifiedAddress.orchardReceiver !== undefined; + } + return super.isValidAddress(address, param); + } + + override parseTransaction( + params: ParseTransactionOptions + ): Promise> { + return this.parseTransactionWithAddressCodec( + params, + new ZcashAddressCodec(this.name as 'zec' | 'tzec', getUnifiedRecipientPreference(params.txParams)) + ); + } + + override verifyTransaction( + params: VerifyTransactionOptions + ): Promise { + return this.verifyTransactionWithAddressCodec( + params, + new ZcashAddressCodec(this.name as 'zec' | 'tzec', getUnifiedRecipientPreference(params.txParams)) ); } + + /** + * Zcash v6 (Ironwood) PSBTs carry their shielded side as an orchard PCZT and cannot be + * deserialized by the generic `ZcashBitGoPsbt` — attempt that first (the common, non-shielding + * case) and fall back to `ZcashIronwoodBitGoPsbt.fromBytes` for v6-shaped bytes. + */ + override decodeTransaction(input: Buffer | string): fixedScriptWallet.BitGoPsbt { + const buffer = typeof input === 'string' ? stringToBufferTryFormats(input, ['hex', 'base64']) : input; + if (!hasPsbtMagic(buffer)) { + return super.decodeTransaction(input); + } + try { + return fixedScriptWallet.ZcashBitGoPsbt.fromBytes(buffer, this.name as 'zec' | 'tzec'); + } catch (e) { + // `ZcashBitGoPsbt.fromBytes` signals v6 (Ironwood) bytes with a plain Error (not a + // WasmUtxoError) telling the caller to use `ZcashIronwoodBitGoPsbt.fromBytes` instead — + // see its doc comment. Fall back for that message as well as wasm-layer errors. + if (isWasmUtxoError(e) || (e instanceof Error && e.message.includes('v6 (Ironwood)'))) { + return fixedScriptWallet.ZcashIronwoodBitGoPsbt.fromBytes(buffer, this.name as 'zec' | 'tzec'); + } + throw e; + } + } + + override decodeTransactionFromPrebuild(prebuild: { + txHex?: string; + txBase64?: string; + txHexPsbt?: string; + }): fixedScriptWallet.BitGoPsbt { + const string = prebuild.txHexPsbt ?? prebuild.txHex ?? prebuild.txBase64; + if (!string) { + throw new Error('missing required txHex or txBase64 property'); + } + return this.decodeTransaction(string); + } + + /** + * Decode a Zcash PSBT (v4 Sapling-shaped or v6 Ironwood) and resolve its recipient list. + * The decode-side counterpart of the wallet-platform build path's recipient resolution: + * shielded outputs resolve to their single-receiver Orchard Unified Address, transparent + * outputs to their transparent address. Change and custom-change outputs are excluded. + */ + resolveRecipientsFromPsbt( + input: Buffer | string, + walletKeys: fixedScriptWallet.RootWalletKeys, + opts: ResolvePsbtRecipientsOptions = {} + ): PsbtRecipient[] { + const psbt = this.decodeTransaction(input); + if (!(psbt instanceof fixedScriptWallet.ZcashBitGoPsbt)) { + throw new Error('expected a Zcash PSBT'); + } + return resolvePsbtRecipients(psbt, walletKeys, opts); + } } diff --git a/modules/abstract-utxo/test/unit/fixtures/tzec/unified_address.json b/modules/abstract-utxo/test/unit/fixtures/tzec/unified_address.json new file mode 100644 index 0000000000..53d700fae9 --- /dev/null +++ b/modules/abstract-utxo/test/unit/fixtures/tzec/unified_address.json @@ -0,0 +1,8 @@ +{ + "_note": "ZIP-316 unified-address test vector for tzec (testnet), derived from wallet-data/testnet-wallet-full.json in the Ironwood reference sandbox. See @bitgo/wasm-utxo test/fixtures/zcash/unified_address.json (testnetWallet).", + "network": "tzec", + "unified": "utest1w5m0qcnp8egl8qa296n70n8nvj0tqnzk90p7f48v7mjhhdrdqs8vgqydslg5plmzefawefnpmgmlm6hcy38m972erwxs04s02cq2prhguz8kqly75m6zjy56m08d5jnycgtpqtjeprte576gkmrxyszepgx76yzuwhh7m4lfz9jaq7unjk0x5ant46juxz73hsc6q4v3dqtzww00vps", + "transparentAddress": "tmM4DvLVJKXZt5ydn1tqYTHvahpKSwgjuRk", + "ironwoodReceiverHex": "d632c28aa0831d671be17709a42c9627e2eb687a1b2a55768ea470c9bae7499cd0bd3d0eb0484e307236b5", + "transparentPubkeyHashHex": "7c6b843a25873c036aff575516e3802bcc47f634" +} diff --git a/modules/abstract-utxo/test/unit/fixtures/zec/unified_address.json b/modules/abstract-utxo/test/unit/fixtures/zec/unified_address.json new file mode 100644 index 0000000000..8e7a4851d2 --- /dev/null +++ b/modules/abstract-utxo/test/unit/fixtures/zec/unified_address.json @@ -0,0 +1,7 @@ +{ + "_note": "ZIP-316 unified-address test vector for zec (mainnet), from the official zcash-test-vectors (unified_address.py). See @bitgo/wasm-utxo test/fixtures/zcash/unified_address.json (zip316Mainnet).", + "network": "zec", + "unified": "u1pg2aaph7jp8rpf6yhsza25722sg5fcn3vaca6ze27hqjw7jvvhhuxkpcg0ge9xh6drsgdkda8qjq5chpehkcpxf87rnjryjqwymdheptpvnljqqrjqzjwkc2ma6hcq666kgwfytxwac8eyex6ndgr6ezte66706e3vaqrd25dzvzkc69kw0jgywtd0cmq52q5lkw6uh7hyvzjse8ksx", + "orchardReceiverHex": "cecbe5e689a453a3fe10ccf7617e6c1fb382819d7fc9200a1f42092ac84a30378f8c1fb90dff71a6d5042d", + "transparentPubkeyHashHex": "cad268758c5e71493066446b98e71df9d1d6a5ca" +} diff --git a/modules/abstract-utxo/test/unit/impl/zec/shieldedPrebuildAndSign.ts b/modules/abstract-utxo/test/unit/impl/zec/shieldedPrebuildAndSign.ts new file mode 100644 index 0000000000..d9cd945615 --- /dev/null +++ b/modules/abstract-utxo/test/unit/impl/zec/shieldedPrebuildAndSign.ts @@ -0,0 +1,91 @@ +import * as assert from 'assert'; + +import nock = require('nock'); +import { common } from '@bitgo/sdk-core'; +import { getSeed } from '@bitgo/sdk-test'; +import { fixedScriptWallet } from '@bitgo/wasm-utxo'; + +import { getUtxoCoin, defaultBitGo } from '../../util'; +import { getDefaultWasmWalletKeys, keychainsBase58 } from '../../util/keychains'; +import { Zec } from '../../../../src/impl/zec'; +/** + * Exercises every client-side flow that runs BEFORE verifyTransaction/signTransaction on a + * shielded (v6 Ironwood) prebuild: prebuild post-processing, explanation, and recipient + * resolution. Each of them must decode the v6 PSBT and resolve the shielded recipient without + * error. + */ +describe('Zec shielded pre-verify flows (v6 Ironwood PSBT)', function () { + const zec = getUtxoCoin('tzec'); + const bgUrl = common.Environments[defaultBitGo.getEnv()].uri; + const { walletKeys } = getDefaultWasmWalletKeys(); + + const keyDocumentObjects = keychainsBase58.map((keychain, keyIdx) => { + return { + id: getSeed(keychain.pub).toString('hex'), + pub: keychain.pub, + source: ['user', 'backup', 'bitgo'][keyIdx], + coinSpecific: {}, + }; + }); + const IRONWOOD_RECEIVER = Buffer.from( + 'd632c28aa0831d671be17709a42c9627e2eb687a1b2a55768ea470c9bae7499cd0bd3d0eb0484e307236b5', + 'hex' + ); + let unifiedAddress: string; + + before(function () { + unifiedAddress = fixedScriptWallet.ZcashUnifiedAddress.encodeOrchardReceiver( + new Uint8Array(IRONWOOD_RECEIVER), + 'tzec' + ); + }); + + function buildShieldedV6PrebuildHex(): string { + const psbt = fixedScriptWallet.ZcashIronwoodBitGoPsbt.createEmpty('tzec', walletKeys, { blockHeight: 4200000 }); + psbt.addWalletInput({ txid: '11'.repeat(32), vout: 0, value: 100000n }, walletKeys, { + scriptId: { chain: 0, index: 0 }, + }); + psbt.addWalletOutput(walletKeys, { chain: 1, index: 0, value: 90000n }); + psbt.addShieldedOutputs( + [{ recipient: new Uint8Array(IRONWOOD_RECEIVER), amount: 5000n, unifiedAddress }], + new Uint8Array(32) + ); + return Buffer.from(psbt.serialize()).toString('hex'); + } + + afterEach(function () { + nock.cleanAll(); + }); + + it('sendMany recipient validation accepts the unified address', function () { + zec.checkRecipient({ address: unifiedAddress, amount: '5000' }); + }); + + it('postProcessPrebuild decodes the v6 psbt and re-encodes it unchanged', async function () { + const prebuildHex = buildShieldedV6PrebuildHex(); + nock(bgUrl).get('/api/v2/tzec/public/block/latest').reply(200, { height: 4200000 }); + const prebuild = await zec.postProcessPrebuild({ txHex: prebuildHex, txInfo: {} }); + assert.match(prebuild.txHex as string, /^70736274/); // PSBT magic preserved + const decoded = zec.decodeTransaction(prebuild.txHex as string); + assert.ok(decoded instanceof fixedScriptWallet.ZcashIronwoodBitGoPsbt); + }); + + it('explainTransaction decodes the v6 psbt and resolves the shielded recipient', async function () { + const explained = await zec.explainTransaction({ + txHex: buildShieldedV6PrebuildHex(), + pubs: [keyDocumentObjects[0].pub, keyDocumentObjects[1].pub, keyDocumentObjects[2].pub], + }); + assert.strictEqual(explained.outputs.length, 1); + assert.strictEqual(explained.outputs[0].address, unifiedAddress); + assert.strictEqual(explained.outputs[0].amount.toString(), '5000'); + assert.strictEqual(explained.changeOutputs.length, 1); + }); + + it('resolveRecipientsFromPsbt resolves the shielded recipient with its original UA', function () { + const recipients = (zec as Zec).resolveRecipientsFromPsbt(buildShieldedV6PrebuildHex(), walletKeys); + assert.strictEqual(recipients.length, 1); + assert.strictEqual(recipients[0].destination.kind, 'zcashShielded'); + assert.strictEqual(recipients[0].unifiedAddress, unifiedAddress); + assert.strictEqual(Buffer.from(recipients[0].script).toString('hex'), IRONWOOD_RECEIVER.toString('hex')); + }); +}); diff --git a/modules/abstract-utxo/test/unit/impl/zec/unifiedAddress.ts b/modules/abstract-utxo/test/unit/impl/zec/unifiedAddress.ts new file mode 100644 index 0000000000..59ea6c7ea9 --- /dev/null +++ b/modules/abstract-utxo/test/unit/impl/zec/unifiedAddress.ts @@ -0,0 +1,327 @@ +import assert from 'assert'; +import * as fs from 'fs'; +import * as path from 'path'; + +import * as sinon from 'sinon'; +import { fixedScriptWallet, isWasmUtxoError } from '@bitgo/wasm-utxo'; +import { ExtraPrebuildParamsOptions, Wallet } from '@bitgo/sdk-core'; + +import { getUtxoCoin, defaultBitGo, getDefaultWasmWalletKeys } from '../../util'; +import { Zec } from '../../../../src/impl/zec'; +import { ZcashAddressCodec } from '../../../../src/impl/zec/addressCodec'; + +type UaVector = { + network: 'zec' | 'tzec'; + unified: string; + transparentAddress?: string; + orchardReceiverHex?: string; + ironwoodReceiverHex?: string; + transparentPubkeyHashHex: string; +}; + +const MAINNET_UA = JSON.parse( + fs.readFileSync(path.join(__dirname, '../../fixtures/zec/unified_address.json'), 'utf8') +) as UaVector; +const TESTNET_UA = JSON.parse( + fs.readFileSync(path.join(__dirname, '../../fixtures/tzec/unified_address.json'), 'utf8') +) as UaVector; + +describe('Zec Unified Address support', function () { + const zec = getUtxoCoin('zec'); + const tzec = getUtxoCoin('tzec'); + + describe('isValidAddress', function () { + it('accepts a mainnet unified address (transparent + Orchard receivers)', function () { + assert.strictEqual(zec.isValidAddress(MAINNET_UA.unified), true); + }); + + it('accepts a testnet unified address (transparent + Ironwood receivers)', function () { + assert.strictEqual(tzec.isValidAddress(TESTNET_UA.unified), true); + }); + + it('accepts an Orchard-only (single-receiver) unified address', function () { + const orchardOnlyUa = fixedScriptWallet.ZcashUnifiedAddress.encodeOrchardReceiver( + Buffer.from(TESTNET_UA.ironwoodReceiverHex as string, 'hex'), + 'tzec' + ); + assert.strictEqual(tzec.isValidAddress(orchardOnlyUa), true); + }); + + it('rejects a malformed unified address', function () { + assert.strictEqual(zec.isValidAddress('u1notavalidunifiedaddress'), false); + }); + + it('rejects a unified address on the wrong network', function () { + // MAINNET_UA has the "u1..." HRP; tzec expects "utest1...". + assert.strictEqual(tzec.isValidAddress(MAINNET_UA.unified), false); + }); + + it('still validates ordinary transparent addresses', function () { + assert.strictEqual(tzec.isValidAddress(TESTNET_UA.transparentAddress as string), true); + assert.strictEqual(tzec.isValidAddress('not-an-address'), false); + }); + }); + + describe('ZcashAddressCodec', function () { + it("resolves a unified address's Orchard/Ironwood receiver when preference is 'shielded'", function () { + const script = new ZcashAddressCodec('tzec', 'shielded').decode(TESTNET_UA.unified); + assert.strictEqual(Buffer.from(script).toString('hex'), TESTNET_UA.ironwoodReceiverHex); + assert.strictEqual(script.length, 43); + }); + + it("resolves a mainnet unified address's Orchard receiver when preference is 'shielded'", function () { + const script = new ZcashAddressCodec('zec', 'shielded').decode(MAINNET_UA.unified); + assert.strictEqual(Buffer.from(script).toString('hex'), MAINNET_UA.orchardReceiverHex); + }); + + it("resolves a unified address's transparent receiver by default and with 'transparent'", function () { + const expectedScript = `76a914${TESTNET_UA.transparentPubkeyHashHex}88ac`; + assert.strictEqual( + Buffer.from(new ZcashAddressCodec('tzec').decode(TESTNET_UA.unified)).toString('hex'), + expectedScript + ); + assert.strictEqual( + Buffer.from(new ZcashAddressCodec('tzec', 'transparent').decode(TESTNET_UA.unified)).toString('hex'), + expectedScript + ); + }); + + it('resolves an ordinary transparent address regardless of preference', function () { + const expectedScript = `76a914${TESTNET_UA.transparentPubkeyHashHex}88ac`; + assert.strictEqual( + Buffer.from(new ZcashAddressCodec('tzec').decode(TESTNET_UA.transparentAddress as string)).toString('hex'), + expectedScript + ); + assert.strictEqual( + Buffer.from(new ZcashAddressCodec('tzec', 'shielded').decode(TESTNET_UA.transparentAddress as string)).toString( + 'hex' + ), + expectedScript + ); + }); + }); + + describe('getExtraPrebuildParams', function () { + function mockWallet(coin = zec) { + return new Wallet(defaultBitGo, coin, { id: '5b34252f1bf349930e34020a', coin: coin.getChain(), type: 'hot' }); + } + + it('forwards unifiedRecipientPreference when present', async function () { + const wallet = mockWallet(); + const result: Record = await zec.getExtraPrebuildParams({ + wallet, + unifiedRecipientPreference: 'shielded', + } as ExtraPrebuildParamsOptions & { wallet: Wallet }); + assert.strictEqual(result.unifiedRecipientPreference, 'shielded'); + }); + + it('does not set unifiedRecipientPreference when absent', async function () { + const wallet = mockWallet(); + const result = await zec.getExtraPrebuildParams({ wallet } as ExtraPrebuildParamsOptions & { wallet: Wallet }); + assert.strictEqual('unifiedRecipientPreference' in result, false); + }); + + it('still returns the standard extra prebuild params (txFormat) unchanged', async function () { + const wallet = mockWallet(); + const result = await zec.getExtraPrebuildParams({ + wallet, + unifiedRecipientPreference: 'shielded', + } as ExtraPrebuildParamsOptions & { wallet: Wallet }); + assert.strictEqual(result.txFormat, 'psbt-lite'); + }); + }); + + describe('decodeTransaction / decodeTransactionFromPrebuild', function () { + const { walletKeys } = getDefaultWasmWalletKeys(); + + function buildV4Psbt(): fixedScriptWallet.ZcashBitGoPsbt { + const psbt = fixedScriptWallet.ZcashBitGoPsbt.createEmpty('zec', walletKeys, { blockHeight: 3146400 }); + psbt.addWalletInput({ txid: '11'.repeat(32), vout: 0, value: 100000n }, walletKeys, { + scriptId: { chain: 0, index: 0 }, + }); + psbt.addWalletOutput(walletKeys, { chain: 1, index: 0, value: 90000n }); + return psbt; + } + + it('decodes a v4 (Sapling-shaped) PSBT as a ZcashBitGoPsbt', function () { + const bytes = Buffer.from(buildV4Psbt().serialize()); + const decoded = zec.decodeTransaction(bytes); + assert.ok(decoded instanceof fixedScriptWallet.ZcashBitGoPsbt); + assert.ok(!(decoded instanceof fixedScriptWallet.ZcashIronwoodBitGoPsbt)); + }); + + it('decodeTransactionFromPrebuild decodes a v4 txHex the same way', function () { + const bytes = Buffer.from(buildV4Psbt().serialize()); + const decoded = zec.decodeTransactionFromPrebuild({ txHex: bytes.toString('hex') }); + assert.ok(decoded instanceof fixedScriptWallet.ZcashBitGoPsbt); + }); + + it('falls back to ZcashIronwoodBitGoPsbt.fromBytes for v6-shaped bytes', function () { + // ZcashBitGoPsbt.fromBytes throws (a real WasmUtxoError) for v6-shaped bytes, telling the + // caller to use ZcashIronwoodBitGoPsbt.fromBytes instead; stub both statics to prove + // Zec.decodeTransaction actually performs that fallback dispatch rather than propagating + // the first error. + class FakeWasmUtxoError extends Error { + code = 'WasmUtxoError.StringError'; + } + Object.defineProperty(FakeWasmUtxoError.prototype, Symbol.for('@bitgo/wasm-utxo/error'), { value: true }); + assert.ok(isWasmUtxoError(new FakeWasmUtxoError('this is a v6 (Ironwood) PSBT'))); + + const fakeIronwoodPsbt = Object.create(fixedScriptWallet.ZcashIronwoodBitGoPsbt.prototype); + const v4Stub = sinon + .stub(fixedScriptWallet.ZcashBitGoPsbt, 'fromBytes') + .throws(new FakeWasmUtxoError('this is a v6 (Ironwood) PSBT: use ZcashIronwoodBitGoPsbt.fromBytes instead')); + const v6Stub = sinon.stub(fixedScriptWallet.ZcashIronwoodBitGoPsbt, 'fromBytes').returns(fakeIronwoodPsbt); + + try { + const psbtMagicBytes = Buffer.from([0x70, 0x73, 0x62, 0x74, 0xff, 0x00]); + const decoded = zec.decodeTransaction(psbtMagicBytes); + assert.strictEqual(decoded, fakeIronwoodPsbt); + assert.strictEqual(v4Stub.calledOnce, true); + assert.strictEqual(v6Stub.calledOnce, true); + } finally { + v4Stub.restore(); + v6Stub.restore(); + } + }); + + it('propagates a non-wasm-utxo error from the v4 decode path without attempting the v6 fallback', function () { + const v4Stub = sinon.stub(fixedScriptWallet.ZcashBitGoPsbt, 'fromBytes').throws(new Error('boom')); + const v6Stub = sinon.stub(fixedScriptWallet.ZcashIronwoodBitGoPsbt, 'fromBytes'); + + try { + const psbtMagicBytes = Buffer.from([0x70, 0x73, 0x62, 0x74, 0xff, 0x00]); + assert.throws(() => zec.decodeTransaction(psbtMagicBytes), /boom/); + assert.strictEqual(v6Stub.called, false); + } finally { + v4Stub.restore(); + v6Stub.restore(); + } + }); + }); + + describe('resolveRecipientsFromPsbt', function () { + const { walletKeys } = getDefaultWasmWalletKeys(); + const IRONWOOD_HEIGHT = 4200000; // after the NU6.3 testnet activation (4134000) + const IRONWOOD_RECEIVER = Buffer.from(TESTNET_UA.ironwoodReceiverHex as string, 'hex'); + + function buildShieldedV6Psbt( + unifiedAddress = fixedScriptWallet.ZcashUnifiedAddress.encodeOrchardReceiver( + new Uint8Array(IRONWOOD_RECEIVER), + 'tzec' + ) + ): fixedScriptWallet.ZcashIronwoodBitGoPsbt { + const psbt = fixedScriptWallet.ZcashIronwoodBitGoPsbt.createEmpty('tzec', walletKeys, { + blockHeight: IRONWOOD_HEIGHT, + }); + psbt.addWalletInput({ txid: '11'.repeat(32), vout: 0, value: 100000n }, walletKeys, { + scriptId: { chain: 0, index: 0 }, + }); + psbt.addWalletOutput(walletKeys, { chain: 1, index: 0, value: 90000n }); + psbt.addShieldedOutputs( + [{ recipient: new Uint8Array(IRONWOOD_RECEIVER), amount: 5000n, unifiedAddress }], + new Uint8Array(32) // all-zero anchor, as in the utxo-core shielded build tests + ); + return psbt; + } + + function buildTransparentV4Psbt(unifiedAddress?: string): fixedScriptWallet.ZcashBitGoPsbt { + const psbt = fixedScriptWallet.ZcashBitGoPsbt.createEmpty('tzec', walletKeys, { blockHeight: 3146400 }); + psbt.addWalletInput({ txid: '22'.repeat(32), vout: 0, value: 200000n }, walletKeys, { + scriptId: { chain: 0, index: 1 }, + }); + psbt.addWalletOutput(walletKeys, { chain: 1, index: 0, value: 100000n }); + const externalScript = new ZcashAddressCodec('tzec').decode(TESTNET_UA.transparentAddress as string); + psbt.addTransparentOutput(externalScript, 12345n, unifiedAddress); + return psbt; + } + + /** Decode a UA back to its receivers and assert they match the testnet fixture. */ + function assertDecodesBackToFixtureRecipients(unifiedAddress: string, zec: Zec): void { + const parsed = fixedScriptWallet.ZcashUnifiedAddress.parse(unifiedAddress, 'tzec'); + assert.strictEqual(parsed.hasOrchardReceiver, true); + assert.ok(parsed.orchardReceiver); + assert.strictEqual(Buffer.from(parsed.orchardReceiver).toString('hex'), TESTNET_UA.ironwoodReceiverHex); + assert.strictEqual(parsed.hasTransparentReceiver, true); + assert.ok(parsed.transparentScript); + assert.strictEqual( + Buffer.from(parsed.transparentScript).toString('hex'), + Buffer.from(new ZcashAddressCodec('tzec').decode(TESTNET_UA.transparentAddress as string)).toString('hex') + ); + } + const tzecCoin = tzec as Zec; + + it('resolves a shielded v6 (Ironwood) output to its Orchard Unified Address recipient', function () { + const recipients = tzecCoin.resolveRecipientsFromPsbt(Buffer.from(buildShieldedV6Psbt().serialize()), walletKeys); + assert.strictEqual(recipients.length, 1); + const recipient = recipients[0]; + assert.strictEqual(recipient.destination.kind, 'zcashShielded'); + assert.strictEqual(recipient.amount, 5000n); + assert.ok(recipient.address.startsWith('utest1')); + assert.strictEqual(recipient.address, recipient.destination.unifiedAddress); + assert.strictEqual(Buffer.from(recipient.script).toString('hex'), TESTNET_UA.ironwoodReceiverHex); + }); + + it('resolves transparent external outputs and excludes change', function () { + const recipients = tzecCoin.resolveRecipientsFromPsbt( + Buffer.from(buildTransparentV4Psbt().serialize()), + walletKeys + ); + assert.strictEqual(recipients.length, 1); + const recipient = recipients[0]; + assert.strictEqual(recipient.destination.kind, 'transparent'); + assert.strictEqual(recipient.address, TESTNET_UA.transparentAddress); + assert.strictEqual(recipient.amount, 12345n); + }); + + it('reports the original multi-receiver UA for a shielded output and decodes it back', function () { + // The client passed the full multi-receiver UA; the v6 PCZT stores it verbatim in the + // PSBT's key-value pairs, so the resolved recipient must report that same string. + const recipients = tzecCoin.resolveRecipientsFromPsbt( + Buffer.from(buildShieldedV6Psbt(TESTNET_UA.unified).serialize()), + walletKeys + ); + assert.strictEqual(recipients.length, 1); + const recipient = recipients[0]; + assert.strictEqual(recipient.destination.kind, 'zcashShielded'); + assert.strictEqual(recipient.address, TESTNET_UA.unified); + assert.strictEqual(recipient.unifiedAddress, TESTNET_UA.unified); + assert.strictEqual(recipient.destination.unifiedAddress, TESTNET_UA.unified); + assert.strictEqual(Buffer.from(recipient.script).toString('hex'), TESTNET_UA.ironwoodReceiverHex); + assertDecodesBackToFixtureRecipients(recipient.unifiedAddress as string, tzecCoin); + }); + + it('reports the original multi-receiver UA for a transparent v4 output and decodes it back', function () { + // The original UA is stored in the transparent-output proprietary key-value map and read + // back via transparentOutputUnifiedAddress. + const recipients = tzecCoin.resolveRecipientsFromPsbt( + Buffer.from(buildTransparentV4Psbt(TESTNET_UA.unified).serialize()), + walletKeys + ); + assert.strictEqual(recipients.length, 1); + const recipient = recipients[0]; + assert.strictEqual(recipient.destination.kind, 'zcashUnifiedTransparent'); + assert.strictEqual(recipient.address, TESTNET_UA.unified); + assert.strictEqual(recipient.unifiedAddress, TESTNET_UA.unified); + assert.strictEqual(recipient.destination.unifiedAddress, TESTNET_UA.unified); + assertDecodesBackToFixtureRecipients(recipient.unifiedAddress as string, tzecCoin); + }); + + it('resolves recipients from a hex PSBT string', function () { + const hex = Buffer.from(buildShieldedV6Psbt().serialize()).toString('hex'); + const recipients = tzecCoin.resolveRecipientsFromPsbt(hex, walletKeys); + assert.strictEqual(recipients.length, 1); + assert.strictEqual(recipients[0].destination.kind, 'zcashShielded'); + }); + + it('throws for a non-Zcash PSBT', function () { + const psbt = fixedScriptWallet.BitGoPsbt.createEmpty('btc', walletKeys, {}); + psbt.addWalletInput({ txid: '33'.repeat(32), vout: 0, value: 1000n }, walletKeys, { + scriptId: { chain: 0, index: 0 }, + }); + // Zec.decodeTransaction attempts ZcashBitGoPsbt.fromBytes, which rejects a btc PSBT for + // its missing Zcash consensus branch ID before the Zcash-type guard is ever reached. + assert.throws(() => tzecCoin.resolveRecipientsFromPsbt(Buffer.from(psbt.serialize()), walletKeys)); + }); + }); +}); diff --git a/modules/abstract-utxo/test/unit/parseTransaction.ts b/modules/abstract-utxo/test/unit/parseTransaction.ts index c34b74acbd..3097dca554 100644 --- a/modules/abstract-utxo/test/unit/parseTransaction.ts +++ b/modules/abstract-utxo/test/unit/parseTransaction.ts @@ -1,4 +1,6 @@ import assert from 'assert'; +import * as fs from 'fs'; +import * as path from 'path'; import * as sinon from 'sinon'; import { Wallet, UnexpectedAddressError, VerificationOptions } from '@bitgo/sdk-core'; @@ -11,6 +13,10 @@ import { getUtxoCoin } from './util'; describe('Parse Transaction', function () { const coin = getUtxoCoin('tbtc'); + const zec = getUtxoCoin('tzec'); + const testnetUnifiedAddress = JSON.parse( + fs.readFileSync(path.join(__dirname, 'fixtures/tzec/unified_address.json'), 'utf8') + ).unified as string; /* * mock objects which get passed into parse transaction. @@ -125,6 +131,58 @@ describe('Parse Transaction', function () { }); }); + it('preserves script recipients through the transaction path', async function () { + const scriptRecipient = 'scriptPubKey:6a0c3230323651312d6175646974'; + stubExplainTransaction = sinon.stub(coin, 'explainTransaction').resolves({ + outputs: [{ address: scriptRecipient, amount: '0', external: false }], + changeOutputs: [], + } as unknown as TransactionExplanation); + + const parsedTransaction = await coin.parseTransaction({ + txParams: { recipients: [{ address: scriptRecipient, amount: '0' }] }, + txPrebuild: { txHex: '' }, + wallet: wallet as unknown as UtxoWallet, + verification, + }); + + assert.deepStrictEqual(parsedTransaction.outputs[0], { + address: scriptRecipient, + amount: '0', + external: false, + }); + }); + + for (const unifiedRecipientPreference of ['transparent', 'shielded'] as const) { + it(`uses the ${unifiedRecipientPreference} Zcash address codec in the transaction path`, async function () { + stubExplainTransaction = sinon.stub(zec, 'explainTransaction').resolves({ + outputs: [ + { + address: testnetUnifiedAddress, + amount: outputAmount, + external: false, + }, + ], + changeOutputs: [], + } as unknown as TransactionExplanation); + + const parsedTransaction = await zec.parseTransaction({ + txParams: { + recipients: [{ address: testnetUnifiedAddress, amount: outputAmount }], + unifiedRecipientPreference, + }, + txPrebuild: { txHex: '' }, + wallet: wallet as unknown as UtxoWallet, + verification, + }); + + assert.deepStrictEqual(parsedTransaction.outputs[0], { + address: testnetUnifiedAddress, + amount: outputAmount, + external: false, + }); + }); + } + describe('txHexPsbt (pending approval flow)', function () { it('should pass txHexPsbt to explainTransaction when both txHex and txHexPsbt are present', async function () { stubExplainTransaction = sinon.stub(coin, 'explainTransaction').resolves({ diff --git a/modules/abstract-utxo/test/unit/transaction/recipient.ts b/modules/abstract-utxo/test/unit/transaction/recipient.ts index 2729b250ca..adc0d00533 100644 --- a/modules/abstract-utxo/test/unit/transaction/recipient.ts +++ b/modules/abstract-utxo/test/unit/transaction/recipient.ts @@ -1,7 +1,15 @@ import assert from 'assert'; +import * as fs from 'fs'; +import * as path from 'path'; +import { AddressCodec } from '../../../src/transaction/recipient'; +import { ZcashAddressCodec } from '../../../src/impl/zec/addressCodec'; import { getUtxoCoin } from '../util/utxoCoins'; +const TESTNET_UA = JSON.parse( + fs.readFileSync(path.join(__dirname, '../fixtures/tzec/unified_address.json'), 'utf8') +) as { unified: string; ironwoodReceiverHex: string; transparentPubkeyHashHex: string }; + describe('AbstractUtxoCoin.preprocessBuildParams', function () { const coin = getUtxoCoin('btc'); @@ -53,3 +61,99 @@ describe('AbstractUtxoCoin.checkRecipient', function () { }, /Only zero amounts allowed for non-encodeable scriptPubkeys/); }); }); + +describe('transaction-scoped address codec', function () { + const coin = getUtxoCoin('btc'); + const address = '1A1zP1eP5QGefi2DMPTfTL5SLmv7DivfNa'; + const addressCodec = new AddressCodec(coin.name); + const defaultScript = addressCodec.fromExtendedAddressFormatToScript(address); + + it('decodes ordinary addresses with the transaction codec', function () { + assert.deepStrictEqual(addressCodec.fromExtendedAddressFormatToScript(address), defaultScript); + }); + + it('uses the codec policy for addresses', function () { + const fakeScript = Buffer.from('deadbeef', 'hex'); + let calledWith: string | undefined; + const codec = new AddressCodec(coin.name); + codec.decode = (a: string) => { + calledWith = a; + return fakeScript; + }; + const script = codec.fromExtendedAddressFormatToScript(address); + assert.deepStrictEqual(script, fakeScript); + assert.strictEqual(calledWith, address); + }); + + it('never invokes the codec for a scriptPubKey recipient', function () { + let called = false; + const codec = new AddressCodec(coin.name); + codec.decode = () => { + called = true; + return Buffer.from(''); + }; + const script = codec.fromExtendedAddressFormatToScript('scriptPubKey:deadbeef'); + assert.strictEqual(called, false); + assert.deepStrictEqual(script, Buffer.from('deadbeef', 'hex')); + }); + + it('forwards the codec through toOutputScript for an address string', function () { + const fakeScript = Buffer.from('cafebabe', 'hex'); + const codec = new AddressCodec(coin.name); + codec.decode = () => fakeScript; + const script = codec.toOutputScript(address); + assert.deepStrictEqual(script, fakeScript); + }); + + it('forwards the codec through toOutputScript for an { address } object', function () { + const fakeScript = Buffer.from('cafebabe', 'hex'); + const codec = new AddressCodec(coin.name); + codec.decode = () => fakeScript; + const script = codec.toOutputScript({ address }); + assert.deepStrictEqual(script, fakeScript); + }); + + it('never invokes the codec for a { script } object', function () { + let called = false; + const codec = new AddressCodec(coin.name); + codec.decode = () => { + called = true; + return Buffer.from(''); + }; + const script = codec.toOutputScript({ script: 'deadbeef' }); + assert.strictEqual(called, false); + assert.deepStrictEqual(script, Buffer.from('deadbeef', 'hex')); + }); +}); + +describe('Zcash transaction-scoped address codec', function () { + it('resolves a transparent Unified Address with the Zcash transparent receiver', function () { + const script = new ZcashAddressCodec('tzec', 'transparent').fromExtendedAddressFormatToScript(TESTNET_UA.unified); + assert.strictEqual(script.toString('hex'), `76a914${TESTNET_UA.transparentPubkeyHashHex}88ac`); + }); + + it('resolves a shielded Unified Address with the Zcash shielded receiver', function () { + const script = new ZcashAddressCodec('tzec', 'shielded').fromExtendedAddressFormatToScript(TESTNET_UA.unified); + assert.strictEqual(script.toString('hex'), TESTNET_UA.ironwoodReceiverHex); + }); +}); + +describe('AddressCodec', function () { + it('defaults to the coin-agnostic wasm-utxo address codec', function () { + const coin = getUtxoCoin('btc'); + const address = '1A1zP1eP5QGefi2DMPTfTL5SLmv7DivfNa'; + assert.deepStrictEqual( + Buffer.from(new AddressCodec(coin.name).decode(address)), + new AddressCodec(coin.name).fromExtendedAddressFormatToScript(address) + ); + }); + + it('ignores an unrecognized unifiedRecipientPreference for a non-Zcash coin', function () { + const coin = getUtxoCoin('btc'); + const address = '1A1zP1eP5QGefi2DMPTfTL5SLmv7DivfNa'; + assert.deepStrictEqual( + Buffer.from(new AddressCodec(coin.name).decode(address)), + new AddressCodec(coin.name).fromExtendedAddressFormatToScript(address) + ); + }); +}); diff --git a/modules/sdk-core/src/bitgo/wallet/BuildParams.ts b/modules/sdk-core/src/bitgo/wallet/BuildParams.ts index 1935a05d2c..3ece9a8707 100644 --- a/modules/sdk-core/src/bitgo/wallet/BuildParams.ts +++ b/modules/sdk-core/src/bitgo/wallet/BuildParams.ts @@ -39,6 +39,8 @@ export const BuildParamsUTXO = t.partial({ isReplaceableByFee: t.boolean, messages: t.array(Bip322Message), qr: t.boolean, + /* Zcash-only: how to resolve a Unified Address recipient ('shielded' or transparent) */ + unifiedRecipientPreference: t.union([t.literal('transparent'), t.literal('shielded')]), }); export const BuildParamsStacks = t.partial({ diff --git a/modules/sdk-core/src/bitgo/wallet/iWallet.ts b/modules/sdk-core/src/bitgo/wallet/iWallet.ts index 9397bc0b24..514f03f212 100644 --- a/modules/sdk-core/src/bitgo/wallet/iWallet.ts +++ b/modules/sdk-core/src/bitgo/wallet/iWallet.ts @@ -230,6 +230,12 @@ export interface PrebuildTransactionOptions { * the legacy format defined by bitcoinjs-lib, or the 'psbt' format, which follows the BIP-174. */ txFormat?: 'legacy' | 'psbt' | 'psbt-lite'; + /** + * Zcash-only: how to resolve a Unified Address recipient. `'shielded'` resolves it to its + * Orchard/Ironwood receiver (a shielded output); any other value (or omission) resolves it to + * its transparent receiver. + */ + unifiedRecipientPreference?: 'transparent' | 'shielded'; /** * Custom Solana instructions to include in the transaction. * Each instruction contains a program ID, accounts array, and data buffer.