From aa3a6a06e3b03dd55866e38652e6bc17fd3554c9 Mon Sep 17 00:00:00 2001 From: Otto Allmendinger Date: Mon, 7 Sep 2026 13:24:01 +0200 Subject: [PATCH] refactor(abstract-utxo): introduce address codec Encapsulate coin-aware address encoding and decoding in a reusable codec. Generic transaction parsing no longer passes coin context or callbacks through recipient helpers. Keep the legacy module-level recipient helpers as compatibility wrappers. Refs: CSHLD-1639 --- modules/abstract-utxo/src/abstractUtxoCoin.ts | 34 +++-- modules/abstract-utxo/src/impl/bch/bch.ts | 4 +- .../src/transaction/descriptor/parse.ts | 44 +++--- .../descriptor/parseToAmountType.ts | 6 +- .../descriptor/verifyTransaction.ts | 13 +- .../transaction/fixedScript/parseOutput.ts | 10 +- .../fixedScript/parseTransaction.ts | 53 ++++---- .../src/transaction/parseTransaction.ts | 8 +- .../src/transaction/recipient.ts | 125 +++++++++++------- .../src/transaction/verifyTransaction.ts | 7 +- .../test/unit/transaction/descriptor/parse.ts | 4 +- .../descriptor/verifyTransactionQr.ts | 14 +- 12 files changed, 186 insertions(+), 136 deletions(-) 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/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..03435e5310 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 = new AddressCodec(coin.name) ): 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..1642b7de98 100644 --- a/modules/abstract-utxo/src/transaction/recipient.ts +++ b/modules/abstract-utxo/src/transaction/recipient.ts @@ -1,72 +1,97 @@ -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; + +/** Address/network-aware recipient conversion with overridable address decoding. */ +export class AddressCodec { + constructor(public readonly coinName: UtxoCoinName) {} + + /** Check if the address is a script recipient (starts with `scriptPubKey:`). */ + static isScriptRecipient(address: string): boolean { + return address.toLowerCase().startsWith(ScriptRecipientPrefix.toLowerCase()); + } + + /** 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 }; + } + + 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}` + ); + } + } + } + + decode(address: string): Uint8Array { + return wasmAddress.toOutputScriptWithCoin(address, this.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)); + } + + 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'); + } -/** - * Check if the address is a script recipient (starts with `scriptPubKey:`). - * @param address - */ + toExtendedAddressFormat(script: Buffer): string { + return script[0] === OP_RETURN + ? `${ScriptRecipientPrefix}${script.toString('hex')}` + : wasmAddress.fromOutputScriptWithCoin(script, this.coinName); + } +} + +/** Legacy helper retained for consumers that use the module-level recipient API. */ export function isScriptRecipient(address: string): boolean { - return address.toLowerCase().startsWith(ScriptRecipientPrefix.toLowerCase()); + return AddressCodec.isScriptRecipient(address); } -/** - * 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 - */ +/** Legacy helper retained for consumers that use the module-level recipient API. */ export function fromExtendedAddressFormat(extendedAddress: string): { address: string } | { script: string } { - if (isScriptRecipient(extendedAddress)) { - return { script: extendedAddress.slice(ScriptRecipientPrefix.length) }; - } - return { address: extendedAddress }; + return AddressCodec.fromExtendedAddressFormat(extendedAddress); } +/** Legacy helper retained for consumers that use the module-level recipient API. */ export function fromExtendedAddressFormatToScript(extendedAddress: string, coinName: UtxoCoinName): Buffer { - const result = fromExtendedAddressFormat(extendedAddress); - if ('script' in result) { - return Buffer.from(result.script, 'hex'); - } - return Buffer.from(address.toOutputScriptWithCoin(result.address, coinName)); + return new AddressCodec(coinName).fromExtendedAddressFormatToScript(extendedAddress); } +/** Legacy helper retained for consumers that use the module-level recipient API. */ 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'); - } - if ('address' in v) { - return fromExtendedAddressFormatToScript(v.address, coinName); - } - throw new Error('invalid input'); + return new AddressCodec(coinName).toOutputScript(v); } -const OP_RETURN = 0x6a; - -/** - * 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. - */ +/** Legacy helper retained for consumers that use the module-level recipient API. */ export function toExtendedAddressFormat(script: Buffer, coinName: UtxoCoinName): string { - return script[0] === OP_RETURN - ? `${ScriptRecipientPrefix}${script.toString('hex')}` - : address.fromOutputScriptWithCoin(script, coinName); + return new AddressCodec(coinName).toExtendedAddressFormat(script); } +/** Legacy helper retained for consumers that use the module-level recipient API. */ 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}` - ); - } - } + AddressCodec.assertValidTransactionRecipient(output); } 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/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);