diff --git a/modules/abstract-utxo/src/abstractUtxoCoin.ts b/modules/abstract-utxo/src/abstractUtxoCoin.ts index e4fb3c2333..efd345ce1e 100644 --- a/modules/abstract-utxo/src/abstractUtxoCoin.ts +++ b/modules/abstract-utxo/src/abstractUtxoCoin.ts @@ -62,14 +62,8 @@ import { } from './recovery'; import { getReplayProtectionPubkeys, isReplayProtectionUnspent } from './transaction/fixedScript/replayProtection'; import { supportedCrossChainRecoveries } from './config'; -import { - assertValidTransactionRecipient, - explainTx, - fromExtendedAddressFormat, - isScriptRecipient, - parseTransaction, - verifyTransaction, -} from './transaction'; +import { explainTx, parseTransaction, verifyTransaction } from './transaction'; +import { AddressCodec } from './transaction/recipient'; import type { TransactionExplanation } from './transaction/fixedScript/explainTransaction'; import { Musig2Participant } from './transaction/fixedScript/musig2'; import { @@ -517,7 +511,7 @@ export abstract class AbstractUtxoCoin extends BaseCoin implements Musig2Partici if (address === undefined) { return recipient; // Already { script, amount } — pass through unchanged } - return { ...rest, ...fromExtendedAddressFormat(address) }; + return { ...rest, ...AddressCodec.fromExtendedAddressFormat(address) }; }) : params.recipients; } @@ -538,8 +532,8 @@ export abstract class AbstractUtxoCoin extends BaseCoin implements Musig2Partici } checkRecipient(recipient: { address?: string; amount: number | string }): void { - assertValidTransactionRecipient(recipient); - if (recipient.address && !isScriptRecipient(recipient.address)) { + AddressCodec.assertValidTransactionRecipient(recipient); + if (recipient.address && !AddressCodec.isScriptRecipient(recipient.address)) { super.checkRecipient({ address: recipient.address, amount: recipient.amount }); } } @@ -602,7 +596,14 @@ export abstract class AbstractUtxoCoin extends BaseCoin implements Musig2Partici async parseTransaction( params: ParseTransactionOptions ): Promise> { - return parseTransaction(this, params); + return this.parseTransactionWithAddressCodec(params, new AddressCodec(this.name)); + } + + protected parseTransactionWithAddressCodec( + params: ParseTransactionOptions, + addressCodec: AddressCodec + ): Promise> { + return parseTransaction(this, params, addressCodec); } /** @@ -637,9 +638,16 @@ export abstract class AbstractUtxoCoin extends BaseCoin implements Musig2Partici */ async verifyTransaction( params: VerifyTransactionOptions + ): Promise { + return this.verifyTransactionWithAddressCodec(params, new AddressCodec(this.name)); + } + + protected async verifyTransactionWithAddressCodec( + params: VerifyTransactionOptions, + addressCodec: AddressCodec ): Promise { try { - return await verifyTransaction(this, this.bitgo, params); + return await verifyTransaction(this, this.bitgo, params, addressCodec); } catch (error) { if (error instanceof AggregateValidationError) { const txExplanation = await TxIntentMismatchError.tryGetTxExplanation( diff --git a/modules/abstract-utxo/src/impl/bch/bch.ts b/modules/abstract-utxo/src/impl/bch/bch.ts index 451d282ff0..50fcb966a5 100644 --- a/modules/abstract-utxo/src/impl/bch/bch.ts +++ b/modules/abstract-utxo/src/impl/bch/bch.ts @@ -3,7 +3,7 @@ import { address as wasmAddress } from '@bitgo/wasm-utxo'; import { AbstractUtxoCoin } from '../../abstractUtxoCoin'; import { UtxoCoinName } from '../../names'; -import { isScriptRecipient } from '../../transaction'; +import { AddressCodec } from '../../transaction'; export class Bch extends AbstractUtxoCoin { readonly name: UtxoCoinName = 'bch'; @@ -29,7 +29,7 @@ export class Bch extends AbstractUtxoCoin { * @returns {*} address string */ canonicalAddress(address: string, version: unknown = 'base58'): string { - if (isScriptRecipient(address)) { + if (AddressCodec.isScriptRecipient(address)) { return address; } diff --git a/modules/abstract-utxo/src/impl/zec/addressCodec.ts b/modules/abstract-utxo/src/impl/zec/addressCodec.ts new file mode 100644 index 0000000000..692ed530d8 --- /dev/null +++ b/modules/abstract-utxo/src/impl/zec/addressCodec.ts @@ -0,0 +1,34 @@ +import { fixedScriptWallet, zcashAddress as wasmZcashAddress } from '@bitgo/wasm-utxo'; + +import { AddressCodec, type UnifiedRecipientPreference } from '../../transaction/recipient'; + +/** + * Parse `address` as a ZIP-316 Unified Address for `network`, or return `undefined` if it isn't + * one (malformed, wrong network, or not bech32m-shaped at all). + */ +export function tryParseUnifiedAddress( + address: string, + network: 'zec' | 'tzec' +): fixedScriptWallet.ZcashUnifiedAddress | undefined { + try { + return fixedScriptWallet.ZcashUnifiedAddress.parse(address, network); + } catch (e) { + return undefined; + } +} + +export class ZcashAddressCodec extends AddressCodec { + constructor(coinName: 'zec' | 'tzec', private readonly unifiedRecipientPreference?: UnifiedRecipientPreference) { + super(coinName); + } + + override decode(address: string): Uint8Array { + if ( + this.unifiedRecipientPreference === 'shielded' && + tryParseUnifiedAddress(address, this.coinName as 'zec' | 'tzec') + ) { + return wasmZcashAddress.toShieldedReceiverWithCoin(address, this.coinName); + } + return wasmZcashAddress.toTransparentReceiverWithCoin(address, this.coinName); + } +} diff --git a/modules/abstract-utxo/src/impl/zec/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..31b2df1224 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 { UnifiedRecipientPreference } from '../../transaction/recipient'; +import type { ParsedTransaction } from '../../transaction/types'; import { UtxoCoinName } from '../../names'; +import { ZcashAddressCodec, tryParseUnifiedAddress } from './addressCodec'; +import { resolvePsbtRecipients, ResolvePsbtRecipientsOptions, PsbtRecipient } from './recipients'; + +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/src/transaction/descriptor/parse.ts b/modules/abstract-utxo/src/transaction/descriptor/parse.ts index 82261cb964..72459ce752 100644 --- a/modules/abstract-utxo/src/transaction/descriptor/parse.ts +++ b/modules/abstract-utxo/src/transaction/descriptor/parse.ts @@ -6,9 +6,8 @@ import { BaseOutput, BaseParsedTransaction, BaseParsedTransactionOutputs } from import { getKeySignatures, toBip32Triple, UtxoNamedKeychains } from '../../keychains'; import { getDescriptorMapFromWallet, getPolicyForEnv } from '../../descriptor'; import { IDescriptorWallet } from '../../descriptor/descriptorWallet'; -import { fromExtendedAddressFormatToScript, toExtendedAddressFormat } from '../recipient'; +import { AddressCodec } from '../recipient'; import { outputDifferencesWithExpected, OutputDifferenceWithExpected } from '../outputDifference'; -import { UtxoCoinName } from '../../names'; import { decodeDescriptorPsbt } from '../decode'; function sumValues(arr: { value: bigint }[]): bigint { @@ -21,11 +20,11 @@ export type RecipientOutput = Omit & { value: bigint | 'max'; }; -function toRecipientOutput(recipient: ITransactionRecipient, coinName: UtxoCoinName): RecipientOutput { +function toRecipientOutput(recipient: ITransactionRecipient, addressCodec: AddressCodec): RecipientOutput { return { address: recipient.address, value: recipient.amount === 'max' ? 'max' : BigInt(recipient.amount), - script: fromExtendedAddressFormatToScript(recipient.address, coinName), + script: addressCodec.fromExtendedAddressFormatToScript(recipient.address), scriptId: undefined, // Recipients are external outputs }; } @@ -40,9 +39,9 @@ function parseOutputsWithPsbt( psbt: Psbt, descriptorMap: descriptorWallet.DescriptorMap, recipientOutputs: RecipientOutput[], - coinName: UtxoCoinName + addressCodec: AddressCodec ): ParsedOutputs { - const parsed = descriptorWallet.parse(psbt, descriptorMap, coinName); + const parsed = descriptorWallet.parse(psbt, descriptorMap, addressCodec.coinName); const outputs: ParsedOutput[] = parsed.outputs.map((output) => ({ ...output, script: Buffer.from(output.script), @@ -56,15 +55,15 @@ function parseOutputsWithPsbt( }; } -function toBaseOutputs(outputs: ParsedOutput[], coinName: UtxoCoinName): BaseOutput[]; -function toBaseOutputs(outputs: RecipientOutput[], coinName: UtxoCoinName): BaseOutput[]; +function toBaseOutputs(outputs: ParsedOutput[], addressCodec: AddressCodec): BaseOutput[]; +function toBaseOutputs(outputs: RecipientOutput[], addressCodec: AddressCodec): BaseOutput[]; function toBaseOutputs( outputs: (ParsedOutput | RecipientOutput)[], - coinName: UtxoCoinName + addressCodec: AddressCodec ): BaseOutput[] { return outputs.map( (o): BaseOutput => ({ - address: toExtendedAddressFormat(o.script, coinName), + address: addressCodec.toExtendedAddressFormat(o.script), amount: o.value === 'max' ? 'max' : BigInt(o.value), external: o.scriptId === undefined, }) @@ -75,18 +74,18 @@ export type ParsedOutputsBigInt = BaseParsedTransactionOutputs o.scriptId === undefined); const implicitExternalOutputs = implicitOutputs.filter((o) => o.scriptId === undefined); return { - outputs: toBaseOutputs(outputs, coinName), - changeOutputs: toBaseOutputs(changeOutputs, coinName), - explicitExternalOutputs: toBaseOutputs(explicitExternalOutputs, coinName), + outputs: toBaseOutputs(outputs, addressCodec), + changeOutputs: toBaseOutputs(changeOutputs, addressCodec), + explicitExternalOutputs: toBaseOutputs(explicitExternalOutputs, addressCodec), explicitExternalSpendAmount: sumValues(explicitExternalOutputs), - implicitExternalOutputs: toBaseOutputs(implicitExternalOutputs, coinName), + implicitExternalOutputs: toBaseOutputs(implicitExternalOutputs, addressCodec), implicitExternalSpendAmount: sumValues(implicitExternalOutputs), - missingOutputs: toBaseOutputs(missingOutputs, coinName), + missingOutputs: toBaseOutputs(missingOutputs, addressCodec), }; } @@ -94,17 +93,17 @@ export function toBaseParsedTransactionOutputsFromPsbt( psbt: Psbt | Uint8Array, descriptorMap: descriptorWallet.DescriptorMap, recipients: ITransactionRecipient[], - coinName: UtxoCoinName + addressCodec: AddressCodec ): ParsedOutputsBigInt { const wasmPsbt = psbt instanceof Psbt ? psbt : Psbt.deserialize(psbt); return toBaseParsedTransactionOutputs( parseOutputsWithPsbt( wasmPsbt, descriptorMap, - recipients.map((r) => toRecipientOutput(r, coinName)), - coinName + recipients.map((r) => toRecipientOutput(r, addressCodec)), + addressCodec ), - coinName + addressCodec ); } @@ -116,7 +115,8 @@ export type ParsedDescriptorTransaction = BaseP export function parse( coin: AbstractUtxoCoin, wallet: IDescriptorWallet, - params: ParseTransactionOptions + params: ParseTransactionOptions, + addressCodec: AddressCodec = new AddressCodec(coin.name) ): ParsedDescriptorTransaction { if (params.txParams.allowExternalChangeAddress) { throw new Error('allowExternalChangeAddress is not supported for descriptor wallets'); @@ -136,7 +136,7 @@ export function parse( const walletKeys = toBip32Triple(keychains); const descriptorMap = getDescriptorMapFromWallet(wallet, walletKeys, getPolicyForEnv(params.wallet.bitgo.env)); return { - ...toBaseParsedTransactionOutputsFromPsbt(wasmPsbt, descriptorMap, recipients, coin.name), + ...toBaseParsedTransactionOutputsFromPsbt(wasmPsbt, descriptorMap, recipients, addressCodec), keychains, keySignatures: getKeySignatures(wallet) ?? {}, customChange: undefined, diff --git a/modules/abstract-utxo/src/transaction/descriptor/parseToAmountType.ts b/modules/abstract-utxo/src/transaction/descriptor/parseToAmountType.ts index 9ddac22ffb..1decb0e19f 100644 --- a/modules/abstract-utxo/src/transaction/descriptor/parseToAmountType.ts +++ b/modules/abstract-utxo/src/transaction/descriptor/parseToAmountType.ts @@ -1,6 +1,7 @@ import { AbstractUtxoCoin, ParseTransactionOptions } from '../../abstractUtxoCoin'; import { BaseOutput, BaseParsedTransaction } from '../types'; import { IDescriptorWallet } from '../../descriptor/descriptorWallet'; +import { AddressCodec } from '../recipient'; import { parse, ParsedDescriptorTransaction } from './parse'; @@ -76,9 +77,10 @@ export function parsedDescriptorTransactionToTNumber( coin: AbstractUtxoCoin, wallet: IDescriptorWallet, - params: ParseTransactionOptions + params: ParseTransactionOptions, + addressCodec: AddressCodec = new AddressCodec(coin.name) ): BaseParsedTransaction> { - return parsedDescriptorTransactionToTNumber>(parse(coin, wallet, params), { + return parsedDescriptorTransactionToTNumber>(parse(coin, wallet, params, addressCodec), { amountTypeAggregate: coin.amountType, amountTypeBaseOutput: 'string', }); diff --git a/modules/abstract-utxo/src/transaction/descriptor/verifyTransaction.ts b/modules/abstract-utxo/src/transaction/descriptor/verifyTransaction.ts index 3e836b860e..bef56d839b 100644 --- a/modules/abstract-utxo/src/transaction/descriptor/verifyTransaction.ts +++ b/modules/abstract-utxo/src/transaction/descriptor/verifyTransaction.ts @@ -3,7 +3,7 @@ import type { Psbt, descriptorWallet } from '@bitgo/wasm-utxo'; import { AbstractUtxoCoin, VerifyTransactionOptions } from '../../abstractUtxoCoin'; import { BaseOutput, BaseParsedTransactionOutputs } from '../types'; -import { UtxoCoinName } from '../../names'; +import { AddressCodec } from '../recipient'; import { decodeDescriptorPsbt } from '../decode'; import { toBaseParsedTransactionOutputsFromPsbt } from './parse'; @@ -54,9 +54,9 @@ export function assertValidTransaction( psbt: Psbt | Uint8Array, descriptors: descriptorWallet.DescriptorMap, recipients: ITransactionRecipient[], - coinName: UtxoCoinName + addressCodec: AddressCodec ): void { - assertExpectedOutputDifference(toBaseParsedTransactionOutputsFromPsbt(psbt, descriptors, recipients, coinName)); + assertExpectedOutputDifference(toBaseParsedTransactionOutputsFromPsbt(psbt, descriptors, recipients, addressCodec)); } /** @@ -74,7 +74,8 @@ export function assertValidTransaction( export async function verifyTransaction( coin: AbstractUtxoCoin, params: VerifyTransactionOptions, - descriptorMap: descriptorWallet.DescriptorMap + descriptorMap: descriptorWallet.DescriptorMap, + addressCodec: AddressCodec = new AddressCodec(coin.name) ): Promise { let psbt: Psbt; try { @@ -94,13 +95,13 @@ export async function verifyTransaction( ); } - assertValidTransaction(psbt, descriptorMap, params.txParams.recipients ?? [], coin.name); + assertValidTransaction(psbt, descriptorMap, params.txParams.recipients ?? [], addressCodec); const parsedOutputs = toBaseParsedTransactionOutputsFromPsbt( psbt, descriptorMap, params.txParams.recipients ?? [], - coin.name + addressCodec ); if (params.txParams.qr) { diff --git a/modules/abstract-utxo/src/transaction/fixedScript/parseOutput.ts b/modules/abstract-utxo/src/transaction/fixedScript/parseOutput.ts index c48fc2600a..5a079a42de 100644 --- a/modules/abstract-utxo/src/transaction/fixedScript/parseOutput.ts +++ b/modules/abstract-utxo/src/transaction/fixedScript/parseOutput.ts @@ -14,7 +14,7 @@ import { import { AbstractUtxoCoin } from '../../abstractUtxoCoin'; import { Output, FixedScriptWalletOutput } from '../types'; -import { fromExtendedAddressFormatToScript } from '../recipient'; +import type { AddressCodec } from '../recipient'; const debug = debugLib('bitgo:v2:parseoutput'); @@ -199,6 +199,7 @@ export interface ParseOutputOptions { verification: VerificationOptions; keychainArray: Triple<{ pub: string }>; wallet: IWallet; + addressCodec: AddressCodec; txParams: { recipients: ITransactionRecipient[]; changeAddress?: string; @@ -214,6 +215,7 @@ export async function parseOutput({ verification, keychainArray, wallet, + addressCodec, txParams, customChange, reqId, @@ -280,9 +282,9 @@ export async function parseOutput({ */ if (txParams.recipients !== undefined && txParams.recipients.length > RECIPIENT_THRESHOLD) { const isCurrentAddressInRecipients = txParams.recipients.some((recipient) => - fromExtendedAddressFormatToScript(recipient.address, coin.name).equals( - fromExtendedAddressFormatToScript(currentAddress, coin.name) - ) + addressCodec + .fromExtendedAddressFormatToScript(recipient.address) + .equals(addressCodec.fromExtendedAddressFormatToScript(currentAddress)) ); if (isCurrentAddressInRecipients) { diff --git a/modules/abstract-utxo/src/transaction/fixedScript/parseTransaction.ts b/modules/abstract-utxo/src/transaction/fixedScript/parseTransaction.ts index e28e5525a5..b8c79e2492 100644 --- a/modules/abstract-utxo/src/transaction/fixedScript/parseTransaction.ts +++ b/modules/abstract-utxo/src/transaction/fixedScript/parseTransaction.ts @@ -14,13 +14,7 @@ import { UtxoNamedKeychains, } from '../../keychains'; import { verifyKeySignature } from '../../verifyKey'; -import { - assertValidTransactionRecipient, - fromExtendedAddressFormatToScript, - isScriptRecipient, - toExtendedAddressFormat, - toOutputScript, -} from '../recipient'; +import { AddressCodec } from '../recipient'; import { ComparableOutput, ExpectedOutput, outputDifference } from '../outputDifference'; import { toTNumber } from '../../tnumber'; @@ -39,9 +33,9 @@ function toCanonicalTransactionRecipient( address: string; } { const amount = BigInt(output.valueString); - assertValidTransactionRecipient({ amount, address: output.address }); + AddressCodec.assertValidTransactionRecipient({ amount, address: output.address }); assert(output.address, 'address is required'); - if (isScriptRecipient(output.address)) { + if (AddressCodec.isScriptRecipient(output.address)) { return { amount, address: output.address }; } return { amount, address: coin.canonicalAddress(output.address) }; @@ -49,7 +43,8 @@ function toCanonicalTransactionRecipient( async function parseRbfTransaction( coin: AbstractUtxoCoin, - params: ParseTransactionOptions + params: ParseTransactionOptions, + addressCodec: AddressCodec = new AddressCodec(coin.name) ): Promise> { const { txParams, wallet } = params; @@ -68,18 +63,22 @@ async function parseRbfTransaction( ); // Recurse into parseTransaction with the derived recipients and without rbfTxIds - return parseTransaction(coin, { - ...params, - txParams: { - ...txParams, - recipients, - rbfTxIds: undefined, + return parseTransaction( + coin, + { + ...params, + txParams: { + ...txParams, + recipients, + rbfTxIds: undefined, + }, }, - }); + addressCodec + ); } function toExpectedOutputs( - coin: AbstractUtxoCoin, + addressCodec: AddressCodec, txParams: { recipients?: ITransactionRecipient[]; allowExternalChangeAddress?: boolean; @@ -95,21 +94,21 @@ function toExpectedOutputs( } return [ { - script: toOutputScript(output, coin.name), + script: addressCodec.toOutputScript(output), value: output.amount === 'max' ? 'max' : BigInt(output.amount), }, ]; } return [ { - script: fromExtendedAddressFormatToScript(output.address, coin.name), + script: addressCodec.fromExtendedAddressFormatToScript(output.address), value: output.amount === 'max' ? 'max' : BigInt(output.amount), }, ]; }); if (txParams.allowExternalChangeAddress && txParams.changeAddress) { expectedOutputs.push({ - script: toOutputScript(txParams.changeAddress, coin.name), + script: addressCodec.toOutputScript(txParams.changeAddress), // When an external change address is explicitly specified, count all outputs going towards that // address in the expected outputs (regardless of the output amount) value: 'max', @@ -136,13 +135,14 @@ function verifyCustomChangeKeys(userKeychain: UtxoKeychain, customChange: Custom export async function parseTransaction( coin: AbstractUtxoCoin, - params: ParseTransactionOptions + params: ParseTransactionOptions, + addressCodec: AddressCodec ): Promise> { const { txParams, txPrebuild, wallet, verification = {}, reqId } = params; // Branch off early for RBF transactions if (txParams.rbfTxIds) { - return parseRbfTransaction(coin, params); + return parseRbfTransaction(coin, params, addressCodec); } if (!_.isUndefined(verification.disableNetworking) && !_.isBoolean(verification.disableNetworking)) { @@ -170,7 +170,7 @@ export async function parseTransaction( throw new Error('missing required txPrebuild property txHex'); } - const expectedOutputs = toExpectedOutputs(coin, txParams); + const expectedOutputs = toExpectedOutputs(addressCodec, txParams); // get the keychains from the custom change wallet if needed let customChange: CustomChangeOptions | undefined; @@ -229,6 +229,7 @@ export async function parseTransaction( verification, keychainArray: toKeychainTriple(keychains), wallet, + addressCodec, txParams: { recipients: txParams.recipients ?? [], changeAddress: txParams.changeAddress, @@ -247,7 +248,7 @@ export async function parseTransaction( function toComparableOutputsWithExternal(outputs: Output[]): ComparableOutputWithExternal[] { return outputs.map((output) => ({ - script: fromExtendedAddressFormatToScript(output.address, coin.name), + script: addressCodec.fromExtendedAddressFormatToScript(output.address), value: output.amount === 'max' ? 'max' : (BigInt(output.amount) as bigint | 'max'), external: output.external, })); @@ -287,7 +288,7 @@ export async function parseTransaction( function toOutputs(outputs: ExpectedOutput[] | ComparableOutputWithExternal[]): Output[] { return outputs.map((output) => ({ - address: toExtendedAddressFormat(output.script, coin.name), + address: addressCodec.toExtendedAddressFormat(output.script), amount: output.value.toString(), external: output.external, })); diff --git a/modules/abstract-utxo/src/transaction/parseTransaction.ts b/modules/abstract-utxo/src/transaction/parseTransaction.ts index d597b42a33..965686154d 100644 --- a/modules/abstract-utxo/src/transaction/parseTransaction.ts +++ b/modules/abstract-utxo/src/transaction/parseTransaction.ts @@ -1,17 +1,19 @@ import { AbstractUtxoCoin, ParseTransactionOptions } from '../abstractUtxoCoin'; import { isDescriptorWallet } from '../descriptor'; +import { AddressCodec } from './recipient'; import { ParsedTransaction } from './types'; import * as descriptor from './descriptor'; import * as fixedScript from './fixedScript'; export async function parseTransaction( coin: AbstractUtxoCoin, - params: ParseTransactionOptions + params: ParseTransactionOptions, + addressCodec: AddressCodec = new AddressCodec(coin.name) ): Promise> { if (isDescriptorWallet(params.wallet)) { - return descriptor.parseToAmountType(coin, params.wallet, params); + return descriptor.parseToAmountType(coin, params.wallet, params, addressCodec); } else { - return fixedScript.parseTransaction(coin, params); + return fixedScript.parseTransaction(coin, params, addressCodec); } } diff --git a/modules/abstract-utxo/src/transaction/recipient.ts b/modules/abstract-utxo/src/transaction/recipient.ts index 8dde2fbd43..a8e60794fa 100644 --- a/modules/abstract-utxo/src/transaction/recipient.ts +++ b/modules/abstract-utxo/src/transaction/recipient.ts @@ -1,72 +1,69 @@ -import { address } from '@bitgo/wasm-utxo'; +import { address as wasmAddress } from '@bitgo/wasm-utxo'; import { UtxoCoinName } from '../names'; const ScriptRecipientPrefix = 'scriptPubKey:'; +const OP_RETURN = 0x6a; -/** - * Check if the address is a script recipient (starts with `scriptPubKey:`). - * @param address - */ -export function isScriptRecipient(address: string): boolean { - return address.toLowerCase().startsWith(ScriptRecipientPrefix.toLowerCase()); -} +export type UnifiedRecipientPreference = 'transparent' | 'shielded'; -/** - * An extended address is one that encodes either a regular address or a hex encoded script with the prefix `scriptPubKey:`. - * This function converts the extended address format to either a script or an address. - * @param extendedAddress - */ -export function fromExtendedAddressFormat(extendedAddress: string): { address: string } | { script: string } { - if (isScriptRecipient(extendedAddress)) { - return { script: extendedAddress.slice(ScriptRecipientPrefix.length) }; - } - return { address: extendedAddress }; -} +/** Address/network-aware recipient conversion with overridable address decoding. */ +export class AddressCodec { + constructor(public readonly coinName: UtxoCoinName) {} -export function fromExtendedAddressFormatToScript(extendedAddress: string, coinName: UtxoCoinName): Buffer { - const result = fromExtendedAddressFormat(extendedAddress); - if ('script' in result) { - return Buffer.from(result.script, 'hex'); + /** Check if the address is a script recipient (starts with `scriptPubKey:`). */ + static isScriptRecipient(address: string): boolean { + return address.toLowerCase().startsWith(ScriptRecipientPrefix.toLowerCase()); } - return Buffer.from(address.toOutputScriptWithCoin(result.address, coinName)); -} -export function toOutputScript(v: string | { address: string } | { script: string }, coinName: UtxoCoinName): Buffer { - if (typeof v === 'string') { - return fromExtendedAddressFormatToScript(v, coinName); - } - if ('script' in v) { - return Buffer.from(v.script, 'hex'); + /** Convert an extended address to either a regular address or a raw script. */ + static fromExtendedAddressFormat(extendedAddress: string): { address: string } | { script: string } { + if (AddressCodec.isScriptRecipient(extendedAddress)) { + return { script: extendedAddress.slice(ScriptRecipientPrefix.length) }; + } + return { address: extendedAddress }; } - if ('address' in v) { - return fromExtendedAddressFormatToScript(v.address, coinName); + + static assertValidTransactionRecipient(output: { amount: bigint | number | string; address?: string }): void { + // In the case that this is an OP_RETURN output or another non-encodable scriptPubkey, we dont have an address. + // We will verify that the amount is zero, and if it isnt then we will throw an error. + if (!output.address || AddressCodec.isScriptRecipient(output.address)) { + if (output.amount.toString() !== '0') { + throw new Error( + `Only zero amounts allowed for non-encodeable scriptPubkeys: amount: ${output.amount}, address: ${output.address}` + ); + } + } } - throw new Error('invalid input'); -} -const OP_RETURN = 0x6a; + decode(address: string): Uint8Array { + return wasmAddress.toOutputScriptWithCoin(address, this.coinName); + } -/** - * Convert a script or address to the extended address format. - * @param script - * @param coinName - * @returns if the script is an OP_RETURN script, then it will be prefixed with `scriptPubKey:`, otherwise it will be converted to an address. - */ -export function toExtendedAddressFormat(script: Buffer, coinName: UtxoCoinName): string { - return script[0] === OP_RETURN - ? `${ScriptRecipientPrefix}${script.toString('hex')}` - : address.fromOutputScriptWithCoin(script, coinName); -} + fromExtendedAddressFormatToScript(extendedAddress: string): Buffer { + const result = AddressCodec.fromExtendedAddressFormat(extendedAddress); + if ('script' in result) { + return Buffer.from(result.script, 'hex'); + } + return Buffer.from(this.decode(result.address)); + } -export function assertValidTransactionRecipient(output: { amount: bigint | number | string; address?: string }): void { - // In the case that this is an OP_RETURN output or another non-encodable scriptPubkey, we dont have an address. - // We will verify that the amount is zero, and if it isnt then we will throw an error. - if (!output.address || isScriptRecipient(output.address)) { - if (output.amount.toString() !== '0') { - throw new Error( - `Only zero amounts allowed for non-encodeable scriptPubkeys: amount: ${output.amount}, address: ${output.address}` - ); + toOutputScript(v: string | { address: string } | { script: string }): Buffer { + if (typeof v === 'string') { + return this.fromExtendedAddressFormatToScript(v); } + if ('script' in v) { + return Buffer.from(v.script, 'hex'); + } + if ('address' in v) { + return this.fromExtendedAddressFormatToScript(v.address); + } + throw new Error('invalid input'); + } + + toExtendedAddressFormat(script: Buffer): string { + return script[0] === OP_RETURN + ? `${ScriptRecipientPrefix}${script.toString('hex')}` + : wasmAddress.fromOutputScriptWithCoin(script, this.coinName); } } diff --git a/modules/abstract-utxo/src/transaction/verifyTransaction.ts b/modules/abstract-utxo/src/transaction/verifyTransaction.ts index bda9ac9404..c44915d8fa 100644 --- a/modules/abstract-utxo/src/transaction/verifyTransaction.ts +++ b/modules/abstract-utxo/src/transaction/verifyTransaction.ts @@ -4,20 +4,23 @@ import { AbstractUtxoCoin, VerifyTransactionOptions } from '../abstractUtxoCoin' import { getDescriptorMapFromWallet, isDescriptorWallet, getPolicyForEnv } from '../descriptor'; import { fetchKeychains, toBip32Triple } from '../keychains'; +import { AddressCodec } from './recipient'; import * as fixedScript from './fixedScript'; import * as descriptor from './descriptor'; export async function verifyTransaction( coin: AbstractUtxoCoin, bitgo: BitGoBase, - params: VerifyTransactionOptions + params: VerifyTransactionOptions, + addressCodec: AddressCodec = new AddressCodec(coin.name) ): Promise { if (isDescriptorWallet(params.wallet)) { const walletKeys = toBip32Triple(await fetchKeychains(coin, params.wallet)); return descriptor.verifyTransaction( coin, params, - getDescriptorMapFromWallet(params.wallet, walletKeys, getPolicyForEnv(bitgo.env)) + getDescriptorMapFromWallet(params.wallet, walletKeys, getPolicyForEnv(bitgo.env)), + addressCodec ); } else { return fixedScript.verifyTransaction(coin, bitgo, params); diff --git a/modules/abstract-utxo/test/unit/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/descriptor/parse.ts b/modules/abstract-utxo/test/unit/transaction/descriptor/parse.ts index fdc4957c23..e6439fd6ae 100644 --- a/modules/abstract-utxo/test/unit/transaction/descriptor/parse.ts +++ b/modules/abstract-utxo/test/unit/transaction/descriptor/parse.ts @@ -13,8 +13,10 @@ import { ErrorImplicitExternalOutputs, ErrorMissingOutputs, } from '../../../../src/transaction/descriptor/verifyTransaction'; +import { AddressCodec } from '../../../../src/transaction/recipient'; import { toAmountType } from '../../../../src/transaction/descriptor/parseToAmountType'; import { BaseOutput } from '../../../../src/transaction/types'; +import { getUtxoCoin } from '../../util'; import { getFixtureRoot } from './fixtures.utils'; @@ -72,7 +74,7 @@ describe('parse', function () { psbt, getDescriptorMap('Wsh2Of3', getDefaultXPubs('a')), recipients.map(toBaseOutputString), - 'btc' + new AddressCodec(getUtxoCoin('btc').name) ); } diff --git a/modules/abstract-utxo/test/unit/transaction/descriptor/verifyTransactionQr.ts b/modules/abstract-utxo/test/unit/transaction/descriptor/verifyTransactionQr.ts index 00f86979a0..ae79cc4526 100644 --- a/modules/abstract-utxo/test/unit/transaction/descriptor/verifyTransactionQr.ts +++ b/modules/abstract-utxo/test/unit/transaction/descriptor/verifyTransactionQr.ts @@ -3,13 +3,14 @@ import assert from 'assert'; import * as testutils from '@bitgo/wasm-utxo/testutils'; import { verifyTransaction } from '../../../../src/transaction/descriptor/verifyTransaction'; -import { toExtendedAddressFormat } from '../../../../src/transaction/recipient'; +import { AddressCodec } from '../../../../src/transaction/recipient'; import { getUtxoCoin } from '../../util'; const { getDefaultXPubs, getDescriptor, getDescriptorMap, mockPsbt } = testutils.descriptor; describe('descriptor verifyTransaction - quantum-resistant sweep', function () { const coin = getUtxoCoin('tbtc'); + const addressCodec = new AddressCodec(coin.name); const xpubsSelf = getDefaultXPubs('a'); const xpubsOther = getDefaultXPubs('b'); @@ -46,7 +47,7 @@ describe('descriptor verifyTransaction - quantum-resistant sweep', function () { it('should reject when external outputs exist and qr is true', async function () { const psbt = buildPsbtWithExternal(); const externalScript = Buffer.from(descriptorOther.atDerivationIndex(0).scriptPubkey()); - const externalAddress = toExtendedAddressFormat(externalScript, 'tbtc'); + const externalAddress = new AddressCodec(coin.name).toExtendedAddressFormat(externalScript); await assert.rejects( verifyTransaction( @@ -59,7 +60,8 @@ describe('descriptor verifyTransaction - quantum-resistant sweep', function () { txPrebuild: { txHex: Buffer.from(psbt.serialize()).toString('hex') }, wallet: {} as any, }, - descriptorMap + descriptorMap, + addressCodec ), /quantum-resistant sweep transactions must only contain wallet-internal outputs/ ); @@ -75,7 +77,8 @@ describe('descriptor verifyTransaction - quantum-resistant sweep', function () { txPrebuild: { txHex: Buffer.from(psbt.serialize()).toString('hex') }, wallet: {} as any, }, - descriptorMap + descriptorMap, + addressCodec ); assert.strictEqual(result, true); @@ -91,7 +94,8 @@ describe('descriptor verifyTransaction - quantum-resistant sweep', function () { txPrebuild: { txHex: Buffer.from(psbt.serialize()).toString('hex') }, wallet: {} as any, }, - descriptorMap + descriptorMap, + addressCodec ); assert.strictEqual(result, true); 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.