diff --git a/modules/abstract-utxo/src/abstractUtxoCoin.ts b/modules/abstract-utxo/src/abstractUtxoCoin.ts index abc7dbf590..5b9da49d41 100644 --- a/modules/abstract-utxo/src/abstractUtxoCoin.ts +++ b/modules/abstract-utxo/src/abstractUtxoCoin.ts @@ -262,6 +262,17 @@ export interface TransactionParams extends BaseTransactionParams { unifiedRecipientPreference?: string; } +/** + * The slice of transaction params that Unified-Address preference inference (see + * AbstractUtxoCoin.getUnifiedRecipientPreference) needs. Deliberately wider than + * `ITransactionRecipient`: recipients may carry `script` instead of `address` (OP_RETURN / raw + * script recipients), and UTXO amounts may be bigint. + */ +export interface UnifiedRecipientPreferenceTxParams { + recipients?: { address?: string; script?: string; amount: number | bigint | string }[]; + unifiedRecipientPreference?: string; +} + export interface ParseTransactionOptions extends BaseParseTransactionOptions { txParams: TransactionParams; txPrebuild: TransactionPrebuild; @@ -560,6 +571,15 @@ export abstract class AbstractUtxoCoin extends BaseCoin implements Musig2Partici return wasmAddress.toOutputScriptWithCoin(address, this.name); } + /** + * The effective Unified-Address recipient preference for a transaction. Coins without + * Unified Addresses just pass the caller's value through; coins that accept Unified + * Addresses may infer it from the recipients (see Zec). + */ + getUnifiedRecipientPreference(txParams: UnifiedRecipientPreferenceTxParams): string | undefined { + return txParams.unifiedRecipientPreference; + } + /** * Run custom coin logic after a transaction prebuild has been received from BitGo * @param prebuild diff --git a/modules/abstract-utxo/src/impl/zec/zec.ts b/modules/abstract-utxo/src/impl/zec/zec.ts index f091d3e30e..4548f929f6 100644 --- a/modules/abstract-utxo/src/impl/zec/zec.ts +++ b/modules/abstract-utxo/src/impl/zec/zec.ts @@ -1,16 +1,10 @@ /** * @prettier */ -import { - address as wasmAddress, - fixedScriptWallet, - hasPsbtMagic, - isWasmUtxoError, - zcashAddress as wasmZcashAddress, -} from '@bitgo/wasm-utxo'; +import { fixedScriptWallet, hasPsbtMagic, isWasmUtxoError, zcashAddress as wasmZcashAddress } from '@bitgo/wasm-utxo'; import { BitGoBase, ExtraPrebuildParamsOptions, Wallet } from '@bitgo/sdk-core'; -import { AbstractUtxoCoin } from '../../abstractUtxoCoin'; +import { AbstractUtxoCoin, UnifiedRecipientPreferenceTxParams } from '../../abstractUtxoCoin'; import { stringToBufferTryFormats } from '../../transaction/decode'; import { UtxoCoinName } from '../../names'; @@ -77,15 +71,81 @@ export class Zec extends AbstractUtxoCoin { /** * Resolve `address` to an output script. For a Unified Address, `unifiedRecipientPreference === * 'shielded'` resolves to the raw 43-byte Orchard/Ironwood receiver (a shielded output, no - * scriptPubKey) instead of the default transparent scriptPubKey. Non-Unified addresses and any - * other `unifiedRecipientPreference` value are unaffected and resolve exactly as the base - * implementation would. + * scriptPubKey); any other value resolves the Unified Address's transparent receiver (a plain + * transparent address decodes exactly as the base implementation would). A Unified Address + * without a transparent receiver cannot resolve transparently and throws. */ override resolveOutputScript(address: string, unifiedRecipientPreference?: string): Uint8Array { if (unifiedRecipientPreference === 'shielded') { return wasmZcashAddress.toShieldedReceiverWithCoin(address, this.name); } - return wasmAddress.toOutputScriptWithCoin(address, this.name); + return wasmZcashAddress.toTransparentReceiverWithCoin(address, this.name); + } + + /** + * Infer the Unified-Address recipient preference from the recipients when the caller did not + * pass one — mirroring wallet-platform's utxo-core `buildTransaction` (`inferIsShielded` + + * `classifyRecipientShieldedness`): a Unified Address carrying only an Orchard receiver can + * only be spent shielded, one carrying only a transparent receiver only transparently, one + * carrying both is ambiguous, and a mix of shielded and transparent recipients is rejected. + */ + override getUnifiedRecipientPreference(txParams: UnifiedRecipientPreferenceTxParams): string | undefined { + const preference = txParams.unifiedRecipientPreference; + if (preference !== undefined) { + // Indexer parity (utxo-core buildTransaction): a shielded build requires every recipient + // to be shielded-capable — a plain transparent address mixed in is rejected rather than + // silently routed through the transparent builder. + if (preference === 'shielded') { + for (const recipient of txParams.recipients ?? []) { + if (!this.isShieldedCapable(recipient.address)) { + throw new Error('Mixed shielded and transparent recipients are not supported'); + } + } + } + return preference; + } + const shieldedness = (txParams.recipients ?? []).map((recipient) => { + if (recipient.address === undefined) { + // Raw script and OP_RETURN recipients are inherently transparent. + return 'transparent' as const; + } + const unified = tryParseUnifiedAddress(recipient.address, this.name as 'zec' | 'tzec'); + if (!unified) { + // Not a unified address: the ordinary transparent address-decoding path handles it. + return 'transparent' as const; + } + if (unified.hasOrchardReceiver && unified.hasTransparentReceiver) { + throw new Error( + `Unified address ${recipient.address} carries both transparent and Orchard receivers; specify unifiedRecipientPreference: "shielded" or "transparent"` + ); + } + if (unified.hasTransparentReceiver) { + return 'transparent' as const; + } + if (unified.hasOrchardReceiver) { + return 'shielded' as const; + } + throw new Error(`Unified address ${recipient.address} carries no transparent or Orchard receiver`); + }); + const hasShielded = shieldedness.includes('shielded'); + const hasTransparent = shieldedness.includes('transparent'); + if (hasShielded && hasTransparent) { + throw new Error('Mixed shielded and transparent recipients are not supported'); + } + return hasShielded ? 'shielded' : undefined; + } + + /** + * Whether `address` can be spent through the shielded (Orchard PCZT) path: a Unified Address + * carrying an Orchard/Ironwood receiver. Raw scripts, plain transparent addresses, and + * transparent-only Unified Addresses cannot. + */ + private isShieldedCapable(address?: string): boolean { + if (address === undefined) { + return false; + } + const unified = tryParseUnifiedAddress(address, this.name as 'zec' | 'tzec'); + return unified !== undefined && unified.hasOrchardReceiver; } /** diff --git a/modules/abstract-utxo/src/names.ts b/modules/abstract-utxo/src/names.ts index 71f37f1318..81de7eca92 100644 --- a/modules/abstract-utxo/src/names.ts +++ b/modules/abstract-utxo/src/names.ts @@ -96,3 +96,7 @@ export function isTestnetCoin(coinName: UtxoCoinName): boolean { export function isMainnetCoin(coinName: UtxoCoinName): boolean { return isUtxoCoinNameMainnet(coinName); } + +export function isZcashCoin(coinName: UtxoCoinName): coinName is 'zec' | 'tzec' { + return coinName === 'zec' || coinName === 'tzec'; +} diff --git a/modules/abstract-utxo/src/transaction/fixedScript/parseTransaction.ts b/modules/abstract-utxo/src/transaction/fixedScript/parseTransaction.ts index 8e7e9aa759..d055cdedf2 100644 --- a/modules/abstract-utxo/src/transaction/fixedScript/parseTransaction.ts +++ b/modules/abstract-utxo/src/transaction/fixedScript/parseTransaction.ts @@ -173,7 +173,13 @@ export async function parseTransaction( throw new Error('missing required txPrebuild property txHex'); } - const expectedOutputs = toExpectedOutputs(coin, txParams); + // Coins with Unified Addresses may infer the recipient preference from the recipients when + // the caller did not pass one (see Zec.getUnifiedRecipientPreference). + const effectiveTxParams = { + ...txParams, + unifiedRecipientPreference: coin.getUnifiedRecipientPreference(txParams), + }; + const expectedOutputs = toExpectedOutputs(coin, effectiveTxParams); // get the keychains from the custom change wallet if needed let customChange: CustomChangeOptions | undefined; @@ -235,7 +241,7 @@ export async function parseTransaction( txParams: { recipients: txParams.recipients ?? [], changeAddress: txParams.changeAddress, - unifiedRecipientPreference: txParams.unifiedRecipientPreference, + unifiedRecipientPreference: effectiveTxParams.unifiedRecipientPreference, }, customChange, reqId, @@ -252,7 +258,7 @@ export async function parseTransaction( function toComparableOutputsWithExternal(outputs: Output[]): ComparableOutputWithExternal[] { return outputs.map((output) => ({ script: fromExtendedAddressFormatToScript(output.address, coin.name, (address) => - coin.resolveOutputScript(address, txParams.unifiedRecipientPreference) + coin.resolveOutputScript(address, effectiveTxParams.unifiedRecipientPreference) ), value: output.amount === 'max' ? 'max' : (BigInt(output.amount) as bigint | 'max'), external: output.external, diff --git a/modules/abstract-utxo/src/transaction/recipient.ts b/modules/abstract-utxo/src/transaction/recipient.ts index 20440e601b..2a23496f41 100644 --- a/modules/abstract-utxo/src/transaction/recipient.ts +++ b/modules/abstract-utxo/src/transaction/recipient.ts @@ -1,6 +1,6 @@ -import { address } from '@bitgo/wasm-utxo'; +import { address, fixedScriptWallet } from '@bitgo/wasm-utxo'; -import { UtxoCoinName } from '../names'; +import { isZcashCoin, UtxoCoinName } from '../names'; const ScriptRecipientPrefix = 'scriptPubKey:'; @@ -58,16 +58,45 @@ export function toOutputScript( const OP_RETURN = 0x6a; +/** + * Encode raw Orchard/Ironwood shielded-receiver bytes as a single-receiver ZIP-316 Unified + * Address, or return `undefined` when the bytes are not a valid receiver — `encodeOrchardReceiver` + * throws for anything that is not one, so the try/catch doubles as the receiver-validity check. + */ +function encodeShieldedReceiver(script: Buffer, coinName: 'zec' | 'tzec'): string | undefined { + try { + return fixedScriptWallet.ZcashUnifiedAddress.encodeOrchardReceiver(new Uint8Array(script), coinName); + } catch { + return undefined; + } +} + +/** + * Zcash extended-address format: a script is either a raw Orchard/Ironwood shielded receiver — + * which is not a scriptPubKey at all and can only be represented as a Unified Address — or an + * ordinary transparent scriptPubKey. + */ +function zcashToExtendedAddressFormat(script: Buffer, coinName: 'zec' | 'tzec'): string { + return encodeShieldedReceiver(script, coinName) ?? address.fromOutputScriptWithCoin(script, coinName); +} + /** * Convert a script or address to the extended address format. * @param script * @param coinName - * @returns if the script is an OP_RETURN script, then it will be prefixed with `scriptPubKey:`, otherwise it will be converted to an address. + * @returns if the script is an OP_RETURN script, then it will be prefixed with `scriptPubKey:`; if + * it is a Zcash shielded receiver (a raw Orchard/Ironwood receiver, which is not a scriptPubKey at + * all), it will be encoded as a single-receiver ZIP-316 Unified Address; otherwise it will be + * converted to an address. */ export function toExtendedAddressFormat(script: Buffer, coinName: UtxoCoinName): string { - return script[0] === OP_RETURN - ? `${ScriptRecipientPrefix}${script.toString('hex')}` - : address.fromOutputScriptWithCoin(script, coinName); + if (script[0] === OP_RETURN) { + return `${ScriptRecipientPrefix}${script.toString('hex')}`; + } + if (isZcashCoin(coinName)) { + return zcashToExtendedAddressFormat(script, coinName); + } + return address.fromOutputScriptWithCoin(script, coinName); } export function assertValidTransactionRecipient(output: { amount: bigint | number | string; address?: string }): void { diff --git a/modules/abstract-utxo/test/unit/impl/zec/shieldedPrebuildAndSign.ts b/modules/abstract-utxo/test/unit/impl/zec/shieldedPrebuildAndSign.ts index d9cd945615..60856afa2d 100644 --- a/modules/abstract-utxo/test/unit/impl/zec/shieldedPrebuildAndSign.ts +++ b/modules/abstract-utxo/test/unit/impl/zec/shieldedPrebuildAndSign.ts @@ -1,21 +1,28 @@ import * as assert from 'assert'; +import * as fs from 'fs'; +import * as path from 'path'; 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 { getUtxoCoin, defaultBitGo, getUtxoWallet } from '../../util'; import { getDefaultWasmWalletKeys, keychainsBase58 } from '../../util/keychains'; import { Zec } from '../../../../src/impl/zec'; +import type { TransactionParams, VerifyTransactionOptions } from '../../../../src/abstractUtxoCoin'; +import type { UtxoWallet } from '../../../../src/wallet'; + /** - * 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. + * Client-side flows for a shielded (v6 Ironwood) prebuild: prebuild post-processing, + * explanation, recipient resolution, and client-side verification. The PSBTs are built the way + * wallet-platform's utxo-core builds them (buildTransaction/createShieldedPsbt), and the verify + * scenarios mirror utxo-core's buildTransaction.spec.ts UA/preference-inference blocks. Signing + * is out of scope. */ -describe('Zec shielded pre-verify flows (v6 Ironwood PSBT)', function () { +describe('Zec shielded client flows (v6 Ironwood PSBT)', function () { const zec = getUtxoCoin('tzec'); + const zecTyped = zec as Zec; const bgUrl = common.Environments[defaultBitGo.getEnv()].uri; const { walletKeys } = getDefaultWasmWalletKeys(); @@ -27,11 +34,24 @@ describe('Zec shielded pre-verify flows (v6 Ironwood PSBT)', function () { coinSpecific: {}, }; }); + + const zecWallet = getUtxoWallet(zec, { + id: 'walletId', + keys: keyDocumentObjects.map((k) => k.id), + coinSpecific: { addressVersion: 'base58' }, + }); + const IRONWOOD_RECEIVER = Buffer.from( 'd632c28aa0831d671be17709a42c9627e2eb687a1b2a55768ea470c9bae7499cd0bd3d0eb0484e307236b5', 'hex' ); - let unifiedAddress: string; + // utxo-core's DUAL_RECEIVER_UA: a ZIP-316 UA with BOTH transparent and Orchard receivers. + const DUAL_RECEIVER_UA = JSON.parse( + fs.readFileSync(path.join(__dirname, '../../fixtures/tzec/unified_address.json'), 'utf8') + ).unified as string; + // utxo-core's plain t-address (the fixture UA's transparent receiver; not wallet-derived). + const PLAIN_T_ADDRESS = 'tmM4DvLVJKXZt5ydn1tqYTHvahpKSwgjuRk'; + let unifiedAddress: string; // orchard-only single-receiver UA before(function () { unifiedAddress = fixedScriptWallet.ZcashUnifiedAddress.encodeOrchardReceiver( @@ -40,19 +60,60 @@ describe('Zec shielded pre-verify flows (v6 Ironwood PSBT)', function () { ); }); - function buildShieldedV6PrebuildHex(): string { + /** Build a v6 (Ironwood) prebuild with wallet change and shielded outputs, as utxo-core's + * `createShieldedPsbt` does. */ + function buildShieldedV6PrebuildHex(recipients: { amount: bigint }[] = [{ amount: 5000n }]): 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 }], + recipients.map((r) => ({ + recipient: new Uint8Array(IRONWOOD_RECEIVER), + amount: r.amount, + unifiedAddress, + })), new Uint8Array(32) ); return Buffer.from(psbt.serialize()).toString('hex'); } + /** Build a legacy v4 (Sapling) prebuild with wallet change and transparent outputs, as + * utxo-core's transparent builder does. `unifiedAddress` stores the original UA verbatim in + * the PSBT's proprietary key-value map, exactly like the build path's recipient resolver. */ + function buildTransparentV4PrebuildHex( + recipients: { address: string; amount: bigint; unifiedAddress?: string }[] + ): string { + 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 }); + for (const recipient of recipients) { + psbt.addTransparentOutput( + new Uint8Array(zecTyped.resolveOutputScript(recipient.address)), + recipient.amount, + recipient.unifiedAddress + ); + } + return Buffer.from(psbt.serialize()).toString('hex'); + } + + function nockVerifyFlow(): nock.Scope[] { + const nocks: nock.Scope[] = []; + keyDocumentObjects.forEach((keyDocument) => { + nocks.push(nock(bgUrl).get(`/api/v2/tzec/key/${keyDocument.id}`).times(4).reply(200, keyDocument)); + }); + // addresses not derivable from the wallet keys are external; the 404 classifies them as such + nocks.push( + nock(bgUrl) + .get(/\/api\/v2\/tzec\/wallet\/walletId\/address\//) + .reply(404) + ); + return nocks; + } + afterEach(function () { nock.cleanAll(); }); @@ -82,10 +143,233 @@ describe('Zec shielded pre-verify flows (v6 Ironwood PSBT)', function () { }); it('resolveRecipientsFromPsbt resolves the shielded recipient with its original UA', function () { - const recipients = (zec as Zec).resolveRecipientsFromPsbt(buildShieldedV6PrebuildHex(), walletKeys); + const recipients = zecTyped.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')); }); + + describe('client-side verification', function () { + /** Build verify params. `unifiedRecipientPreference` is omitted when not given, so the + * coin-side inference runs — mirroring a client that did not pass the preference. */ + function shieldedVerifyParams( + overrides: { + recipients?: { address: string; amount: string }[]; + txHex?: string; + unifiedRecipientPreference?: string; + } = {} + ): VerifyTransactionOptions { + const txParams: TransactionParams = { + recipients: overrides.recipients ?? [{ address: unifiedAddress, amount: '5000' }], + }; + if (overrides.unifiedRecipientPreference !== undefined) { + txParams.unifiedRecipientPreference = overrides.unifiedRecipientPreference; + } + return { + txParams, + txPrebuild: { txHex: overrides.txHex ?? buildShieldedV6PrebuildHex(), txInfo: {} }, + // getUtxoWallet returns the loosely-typed sdk-core Wallet; the coin's verify path + // only reads the wallet's id and keys through it. + wallet: zecWallet as unknown as UtxoWallet, + verification: {}, + }; + } + + it('verifies recipients, amounts, and wallet change on a shielded prebuild', async function () { + nockVerifyFlow(); + assert.strictEqual( + await zec.verifyTransaction(shieldedVerifyParams({ unifiedRecipientPreference: 'shielded' })), + true + ); + }); + + it('rejects when a recipient amount does not match the prebuild', async function () { + nockVerifyFlow(); + await assert.rejects( + zec.verifyTransaction( + shieldedVerifyParams({ + unifiedRecipientPreference: 'shielded', + recipients: [{ address: unifiedAddress, amount: '6000' }], + }) + ), + /expected outputs missing in transaction prebuild/ + ); + }); + + it('rejects when a recipient address does not match the prebuild', async function () { + // A different, valid orchard-only UA: shielded-capable, so it passes the capability + // check but pays a different receiver than the prebuild does. + const otherReceiver = Buffer.from(IRONWOOD_RECEIVER); + otherReceiver[0] ^= 0xff; + const otherUa = fixedScriptWallet.ZcashUnifiedAddress.encodeOrchardReceiver( + new Uint8Array(otherReceiver), + 'tzec' + ); + nockVerifyFlow(); + await assert.rejects( + zec.verifyTransaction( + shieldedVerifyParams({ + unifiedRecipientPreference: 'shielded', + recipients: [{ address: otherUa, amount: '5000' }], + }) + ), + /expected outputs missing in transaction prebuild/ + ); + }); + + it('rejects when change does not go back to the wallet', async function () { + // Replace the wallet change output with an output to an unrelated external address; the + // same recipient is still paid, so only the tampered change should fail verification. + 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.addTransparentOutput(new Uint8Array(zecTyped.resolveOutputScript(PLAIN_T_ADDRESS)), 90000n); + psbt.addShieldedOutputs( + [{ recipient: new Uint8Array(IRONWOOD_RECEIVER), amount: 5000n, unifiedAddress }], + new Uint8Array(32) + ); + nockVerifyFlow(); + await assert.rejects( + zec.verifyTransaction(shieldedVerifyParams({ txHex: Buffer.from(psbt.serialize()).toString('hex') })), + /prebuild attempts to spend to unintended external recipients/ + ); + }); + + it('infers the shielded preference from an Orchard-only recipient when the caller omits it', async function () { + nockVerifyFlow(); + assert.strictEqual(await zec.verifyTransaction(shieldedVerifyParams()), true); + }); + + it('rejects an ambiguous multi-receiver recipient with no explicit preference', async function () { + nockVerifyFlow(); + await assert.rejects( + zec.verifyTransaction(shieldedVerifyParams({ recipients: [{ address: DUAL_RECEIVER_UA, amount: '5000' }] })), + /carries both transparent and Orchard receivers; specify unifiedRecipientPreference/ + ); + }); + + it('rejects mixed shielded and transparent recipients with no explicit preference', async function () { + nockVerifyFlow(); + await assert.rejects( + zec.verifyTransaction( + shieldedVerifyParams({ + recipients: [ + { address: unifiedAddress, amount: '5000' }, + { address: PLAIN_T_ADDRESS, amount: '1000' }, + ], + }) + ), + /Mixed shielded and transparent recipients are not supported/ + ); + }); + + // -- scenarios mirroring utxo-core buildTransaction.spec.ts (shielded end-to-end and + // preference-inference blocks): build the PSBT like the indexer, then verify -- + + it('verifies an explicit transparent preference where the dual-receiver UA pays its transparent receiver', async function () { + // utxo-core: 'builds transparently with an explicit transparent preference for a + // dual-receiver UA alongside a transparent address' + nockVerifyFlow(); + assert.strictEqual( + await zec.verifyTransaction( + shieldedVerifyParams({ + unifiedRecipientPreference: 'transparent', + recipients: [ + { address: DUAL_RECEIVER_UA, amount: '5000' }, + { address: PLAIN_T_ADDRESS, amount: '2500' }, + ], + txHex: buildTransparentV4PrebuildHex([ + { address: DUAL_RECEIVER_UA, amount: 5000n, unifiedAddress: DUAL_RECEIVER_UA }, + { address: PLAIN_T_ADDRESS, amount: 2500n }, + ]), + }) + ), + true + ); + }); + + it('handles several unified-address recipients alongside a plain t-address', async function () { + nockVerifyFlow(); + assert.strictEqual( + await zec.verifyTransaction( + shieldedVerifyParams({ + unifiedRecipientPreference: 'transparent', + recipients: [ + { address: DUAL_RECEIVER_UA, amount: '5000' }, + { address: DUAL_RECEIVER_UA, amount: '2500' }, + { address: PLAIN_T_ADDRESS, amount: '1250' }, + ], + txHex: buildTransparentV4PrebuildHex([ + { address: DUAL_RECEIVER_UA, amount: 5000n, unifiedAddress: DUAL_RECEIVER_UA }, + { address: DUAL_RECEIVER_UA, amount: 2500n, unifiedAddress: DUAL_RECEIVER_UA }, + { address: PLAIN_T_ADDRESS, amount: 1250n }, + ]), + }) + ), + true + ); + }); + + it('rejects an explicit transparent preference on an orchard-only UA', async function () { + // utxo-core: 'honors an explicit transparent preference, failing on an orchard-only UA' + nockVerifyFlow(); + await assert.rejects( + zec.verifyTransaction(shieldedVerifyParams({ unifiedRecipientPreference: 'transparent' })), + /unified address has no transparent receiver/ + ); + }); + + it('rejects an explicit shielded preference when a plain t-address is mixed in', async function () { + // utxo-core: 'honors an explicit shielded preference for a dual-receiver UA alongside a + // transparent address, rejecting the mix' + nockVerifyFlow(); + await assert.rejects( + zec.verifyTransaction( + shieldedVerifyParams({ + unifiedRecipientPreference: 'shielded', + recipients: [ + { address: DUAL_RECEIVER_UA, amount: '5000' }, + { address: PLAIN_T_ADDRESS, amount: '2500' }, + ], + }) + ), + /Mixed shielded and transparent recipients are not supported/ + ); + }); + + it('infers shielded with no preference when every recipient is an orchard-only UA', async function () { + // utxo-core: 'infers shielded when no preference is set and every recipient is an + // orchard-only UA' + nockVerifyFlow(); + assert.strictEqual( + await zec.verifyTransaction( + shieldedVerifyParams({ + recipients: [ + { address: unifiedAddress, amount: '2500' }, + { address: unifiedAddress, amount: '2500' }, + ], + txHex: buildShieldedV6PrebuildHex([{ amount: 2500n }, { amount: 2500n }]), + }) + ), + true + ); + }); + + it('infers transparent with no preference when recipients are plain transparent addresses', async function () { + // utxo-core: 'infers transparent when no preference is set and recipients are plain + // transparent addresses' + nockVerifyFlow(); + assert.strictEqual( + await zec.verifyTransaction( + shieldedVerifyParams({ + recipients: [{ address: PLAIN_T_ADDRESS, amount: '5000' }], + txHex: buildTransparentV4PrebuildHex([{ address: PLAIN_T_ADDRESS, amount: 5000n }]), + }) + ), + true + ); + }); + }); }); diff --git a/modules/abstract-utxo/test/unit/impl/zec/unifiedAddress.ts b/modules/abstract-utxo/test/unit/impl/zec/unifiedAddress.ts index 2c989e102c..ca2cfce1b8 100644 --- a/modules/abstract-utxo/test/unit/impl/zec/unifiedAddress.ts +++ b/modules/abstract-utxo/test/unit/impl/zec/unifiedAddress.ts @@ -73,9 +73,22 @@ describe('Zec Unified Address support', function () { assert.strictEqual(Buffer.from(script).toString('hex'), MAINNET_UA.orchardReceiverHex); }); - it('throws for a unified address when preference is not shielded (transparent UA resolution is not supported)', function () { - assert.throws(() => tzec.resolveOutputScript(TESTNET_UA.unified)); - assert.throws(() => tzec.resolveOutputScript(TESTNET_UA.unified, 'transparent')); + it("resolves a unified address's transparent receiver when preference is not shielded", function () { + const expectedScript = `76a914${TESTNET_UA.transparentPubkeyHashHex}88ac`; + assert.strictEqual(Buffer.from(tzec.resolveOutputScript(TESTNET_UA.unified)).toString('hex'), expectedScript); + assert.strictEqual( + Buffer.from(tzec.resolveOutputScript(TESTNET_UA.unified, 'transparent')).toString('hex'), + expectedScript + ); + }); + + it('throws when a shielded-only unified address is resolved without the shielded preference', function () { + const orchardOnlyUa = fixedScriptWallet.ZcashUnifiedAddress.encodeOrchardReceiver( + Buffer.from(TESTNET_UA.ironwoodReceiverHex as string, 'hex'), + 'tzec' + ); + assert.throws(() => tzec.resolveOutputScript(orchardOnlyUa), /Could not decode|no transparent receiver/); + assert.throws(() => tzec.resolveOutputScript(orchardOnlyUa, 'transparent')); }); it('resolves an ordinary transparent address regardless of preference', function () { diff --git a/modules/abstract-utxo/test/unit/transaction/recipient.ts b/modules/abstract-utxo/test/unit/transaction/recipient.ts index 26b7e2fa48..edf4223906 100644 --- a/modules/abstract-utxo/test/unit/transaction/recipient.ts +++ b/modules/abstract-utxo/test/unit/transaction/recipient.ts @@ -1,6 +1,10 @@ import assert from 'assert'; -import { toOutputScript, fromExtendedAddressFormatToScript } from '../../../src/transaction/recipient'; +import { + toOutputScript, + fromExtendedAddressFormatToScript, + toExtendedAddressFormat, +} from '../../../src/transaction/recipient'; import { getUtxoCoin } from '../util/utxoCoins'; describe('AbstractUtxoCoin.preprocessBuildParams', function () { @@ -127,3 +131,34 @@ describe('AbstractUtxoCoin.resolveOutputScript', function () { ); }); }); + +describe('toExtendedAddressFormat', function () { + const zec = getUtxoCoin('zec'); + const orchardReceiver = Buffer.from( + 'd632c28aa0831d671be17709a42c9627e2eb687a1b2a55768ea470c9bae7499cd0bd3d0eb0484e307236b5', + 'hex' + ); + + it('encodes a shielded receiver as a single-receiver unified address for zec', function () { + const extendedAddress = toExtendedAddressFormat(orchardReceiver, 'zec'); + assert.strictEqual( + Buffer.from(zec.resolveOutputScript(extendedAddress, 'shielded')).toString('hex'), + orchardReceiver.toString('hex') + ); + }); + + it('decodes ordinary transparent scripts for tzec without the unified-address path', function () { + const tzec = getUtxoCoin('tzec'); + const p2pkhScript = tzec.resolveOutputScript('tmM4DvLVJKXZt5ydn1tqYTHvahpKSwgjuRk'); + assert.strictEqual( + toExtendedAddressFormat(Buffer.from(p2pkhScript), 'tzec'), + 'tmM4DvLVJKXZt5ydn1tqYTHvahpKSwgjuRk' + ); + }); + + it('never takes the unified-address path for non-zcash coins', function () { + // a 43-byte script is not a valid transparent scriptPubKey anywhere — for non-zcash coins + // the UA encoder must not run, so decoding throws the ordinary decoder error + assert.throws(() => toExtendedAddressFormat(orchardReceiver, 'btc'), /Invalid address|script/i); + }); +});