From 982f9022f972076d28ac0647088b329fd1ec827a Mon Sep 17 00:00:00 2001 From: Daniel Peng Date: Tue, 14 Oct 2025 13:03:39 -0400 Subject: [PATCH 1/5] feat: replace generic Error with TxIntentMismatchError Ticket: WP-6189 --- .../src/abstractEthLikeNewCoins.ts | 78 +++++++++++-------- modules/abstract-utxo/src/abstractUtxoCoin.ts | 1 + .../descriptor/verifyTransaction.ts | 11 ++- .../fixedScript/verifyTransaction.ts | 44 ++++++++--- 4 files changed, 89 insertions(+), 45 deletions(-) diff --git a/modules/abstract-eth/src/abstractEthLikeNewCoins.ts b/modules/abstract-eth/src/abstractEthLikeNewCoins.ts index b885331f39..bbf8e019e5 100644 --- a/modules/abstract-eth/src/abstractEthLikeNewCoins.ts +++ b/modules/abstract-eth/src/abstractEthLikeNewCoins.ts @@ -28,6 +28,7 @@ import { PresignTransactionOptions as BasePresignTransactionOptions, Recipient, SignTransactionOptions as BaseSignTransactionOptions, + TxIntentMismatchError, TransactionParams, TransactionPrebuild as BaseTransactionPrebuild, TransactionRecipient, @@ -2767,9 +2768,16 @@ export abstract class AbstractEthLikeNewCoins extends AbstractEthLikeCoin { * @param {TransactionPrebuild} params.txPrebuild - prebuild object returned by server * @param {Wallet} params.wallet - Wallet object to obtain keys to verify against * @returns {boolean} + * @throws {TxIntentMismatchError} if transaction validation fails */ async verifyTssTransaction(params: VerifyEthTransactionOptions): Promise { const { txParams, txPrebuild, wallet } = params; + + // Helper to throw TxIntentMismatchError with consistent context + const throwTxMismatch = (message: string): never => { + throw new TxIntentMismatchError(message, '', [txParams], txPrebuild?.txHex || ''); + }; + if ( !txParams?.recipients && !( @@ -2777,13 +2785,13 @@ export abstract class AbstractEthLikeNewCoins extends AbstractEthLikeCoin { (txParams.type && ['acceleration', 'fillNonce', 'transferToken', 'tokenApproval'].includes(txParams.type)) ) ) { - throw new Error(`missing txParams`); + throwTxMismatch(`missing txParams`); } if (!wallet || !txPrebuild) { - throw new Error(`missing params`); + throwTxMismatch(`missing params`); } if (txParams.hop && txParams.recipients && txParams.recipients.length > 1) { - throw new Error(`tx cannot be both a batch and hop transaction`); + throwTxMismatch(`tx cannot be both a batch and hop transaction`); } if (txParams.type && ['transfer'].includes(txParams.type)) { @@ -2798,10 +2806,10 @@ export abstract class AbstractEthLikeNewCoins extends AbstractEthLikeCoin { const txJson = tx.toJson(); if (txJson.data === '0x') { if (expectedAmount !== txJson.value) { - throw new Error('the transaction amount in txPrebuild does not match the value given by client'); + throwTxMismatch('the transaction amount in txPrebuild does not match the value given by client'); } if (expectedDestination.toLowerCase() !== txJson.to.toLowerCase()) { - throw new Error('destination address does not match with the recipient address'); + throwTxMismatch('destination address does not match with the recipient address'); } } else if (txJson.data.startsWith('0xa9059cbb')) { const [recipientAddress, amount] = getRawDecoded( @@ -2809,10 +2817,10 @@ export abstract class AbstractEthLikeNewCoins extends AbstractEthLikeCoin { getBufferedByteCode('0xa9059cbb', txJson.data) ); if (expectedAmount !== amount.toString()) { - throw new Error('the transaction amount in txPrebuild does not match the value given by client'); + throwTxMismatch('the transaction amount in txPrebuild does not match the value given by client'); } if (expectedDestination.toLowerCase() !== addHexPrefix(recipientAddress.toString()).toLowerCase()) { - throw new Error('destination address does not match with the recipient address'); + throwTxMismatch('destination address does not match with the recipient address'); } } } @@ -2829,6 +2837,7 @@ export abstract class AbstractEthLikeNewCoins extends AbstractEthLikeCoin { * @param {TransactionPrebuild} params.txPrebuild - prebuild object returned by server * @param {Wallet} params.wallet - Wallet object to obtain keys to verify against * @returns {boolean} + * @throws {TxIntentMismatchError} if transaction validation fails */ async verifyTransaction(params: VerifyEthTransactionOptions): Promise { const ethNetwork = this.getNetwork(); @@ -2838,21 +2847,29 @@ export abstract class AbstractEthLikeNewCoins extends AbstractEthLikeCoin { return this.verifyTssTransaction(params); } + // Helper to throw TxIntentMismatchError with consistent context + const throwTxMismatch = (message: string): never => { + throw new TxIntentMismatchError(message, '', [txParams], txPrebuild?.txHex || ''); + }; + if (!txParams?.recipients || !txPrebuild?.recipients || !wallet) { - throw new Error(`missing params`); + throwTxMismatch(`missing params`); } - if (txParams.hop && txParams.recipients.length > 1) { - throw new Error(`tx cannot be both a batch and hop transaction`); + + const recipients = txParams.recipients!; + + if (txParams.hop && recipients.length > 1) { + throwTxMismatch(`tx cannot be both a batch and hop transaction`); } if (txPrebuild.recipients.length > 1) { - throw new Error( + throwTxMismatch( `${this.getChain()} doesn't support sending to more than 1 destination address within a single transaction. Try again, using only a single recipient.` ); } if (txParams.hop && txPrebuild.hopTransaction) { // Check recipient amount for hop transaction - if (txParams.recipients.length !== 1) { - throw new Error(`hop transaction only supports 1 recipient but ${txParams.recipients.length} found`); + if (recipients.length !== 1) { + throwTxMismatch(`hop transaction only supports 1 recipient but ${recipients.length} found`); } // Check tx sends to hop address @@ -2862,11 +2879,11 @@ export abstract class AbstractEthLikeNewCoins extends AbstractEthLikeCoin { const expectedHopAddress = optionalDeps.ethUtil.stripHexPrefix(decodedHopTx.getSenderAddress().toString()); const actualHopAddress = optionalDeps.ethUtil.stripHexPrefix(txPrebuild.recipients[0].address); if (expectedHopAddress.toLowerCase() !== actualHopAddress.toLowerCase()) { - throw new Error('recipient address of txPrebuild does not match hop address'); + throwTxMismatch('recipient address of txPrebuild does not match hop address'); } // Convert TransactionRecipient array to Recipient array - const recipients: Recipient[] = txParams.recipients.map((r) => { + const hopRecipients: Recipient[] = recipients.map((r) => { return { address: r.address, amount: typeof r.amount === 'number' ? r.amount.toString() : r.amount, @@ -2874,21 +2891,21 @@ export abstract class AbstractEthLikeNewCoins extends AbstractEthLikeCoin { }); // Check destination address and amount - await this.validateHopPrebuild(wallet, txPrebuild.hopTransaction, { recipients }); - } else if (txParams.recipients.length > 1) { + await this.validateHopPrebuild(wallet, txPrebuild.hopTransaction, { recipients: hopRecipients }); + } else if (recipients.length > 1) { // Check total amount for batch transaction if (txParams.tokenName) { const expectedTotalAmount = new BigNumber(0); if (!expectedTotalAmount.isEqualTo(txPrebuild.recipients[0].amount)) { - throw new Error('batch token transaction amount in txPrebuild should be zero for token transfers'); + throwTxMismatch('batch token transaction amount in txPrebuild should be zero for token transfers'); } } else { let expectedTotalAmount = new BigNumber(0); - for (let i = 0; i < txParams.recipients.length; i++) { - expectedTotalAmount = expectedTotalAmount.plus(txParams.recipients[i].amount); + for (let i = 0; i < recipients.length; i++) { + expectedTotalAmount = expectedTotalAmount.plus(recipients[i].amount); } if (!expectedTotalAmount.isEqualTo(txPrebuild.recipients[0].amount)) { - throw new Error( + throwTxMismatch( 'batch transaction amount in txPrebuild received from BitGo servers does not match txParams supplied by client' ); } @@ -2900,29 +2917,26 @@ export abstract class AbstractEthLikeNewCoins extends AbstractEthLikeCoin { !batcherContractAddress || batcherContractAddress.toLowerCase() !== txPrebuild.recipients[0].address.toLowerCase() ) { - throw new Error('recipient address of txPrebuild does not match batcher address'); + throwTxMismatch('recipient address of txPrebuild does not match batcher address'); } } else { // Check recipient address and amount for normal transaction - if (txParams.recipients.length !== 1) { - throw new Error(`normal transaction only supports 1 recipient but ${txParams.recipients.length} found`); + if (recipients.length !== 1) { + throwTxMismatch(`normal transaction only supports 1 recipient but ${recipients.length} found`); } - const expectedAmount = new BigNumber(txParams.recipients[0].amount); + const expectedAmount = new BigNumber(recipients[0].amount); if (!expectedAmount.isEqualTo(txPrebuild.recipients[0].amount)) { - throw new Error( + throwTxMismatch( 'normal transaction amount in txPrebuild received from BitGo servers does not match txParams supplied by client' ); } - if ( - this.isETHAddress(txParams.recipients[0].address) && - txParams.recipients[0].address !== txPrebuild.recipients[0].address - ) { - throw new Error('destination address in normal txPrebuild does not match that in txParams supplied by client'); + if (this.isETHAddress(recipients[0].address) && recipients[0].address !== txPrebuild.recipients[0].address) { + throwTxMismatch('destination address in normal txPrebuild does not match that in txParams supplied by client'); } } // Check coin is correct for all transaction types if (!this.verifyCoin(txPrebuild)) { - throw new Error(`coin in txPrebuild did not match that in txParams supplied by client`); + throwTxMismatch(`coin in txPrebuild did not match that in txParams supplied by client`); } return true; } diff --git a/modules/abstract-utxo/src/abstractUtxoCoin.ts b/modules/abstract-utxo/src/abstractUtxoCoin.ts index c153a6d318..7a33463874 100644 --- a/modules/abstract-utxo/src/abstractUtxoCoin.ts +++ b/modules/abstract-utxo/src/abstractUtxoCoin.ts @@ -632,6 +632,7 @@ export abstract class AbstractUtxoCoin extends BaseCoin { * @param params.verification.keychains Pass keychains manually rather than fetching them by id * @param params.verification.addresses Address details to pass in for out-of-band verification * @returns {boolean} + * @throws {TxIntentMismatchError} if transaction validation fails */ async verifyTransaction( params: VerifyTransactionOptions diff --git a/modules/abstract-utxo/src/transaction/descriptor/verifyTransaction.ts b/modules/abstract-utxo/src/transaction/descriptor/verifyTransaction.ts index 8b2d6dbab4..917f82b51e 100644 --- a/modules/abstract-utxo/src/transaction/descriptor/verifyTransaction.ts +++ b/modules/abstract-utxo/src/transaction/descriptor/verifyTransaction.ts @@ -1,5 +1,5 @@ import * as utxolib from '@bitgo/utxo-lib'; -import { ITransactionRecipient, VerifyTransactionOptions } from '@bitgo/sdk-core'; +import { ITransactionRecipient, TxIntentMismatchError, VerifyTransactionOptions } from '@bitgo/sdk-core'; import { DescriptorMap } from '@bitgo/utxo-core/descriptor'; import { AbstractUtxoCoin, BaseOutput, BaseParsedTransactionOutputs } from '../../abstractUtxoCoin'; @@ -66,6 +66,8 @@ export function assertValidTransaction( * @param coin * @param params * @param descriptorMap + * @returns {boolean} True if verification passes + * @throws {TxIntentMismatchError} if transaction validation fails */ export async function verifyTransaction( coin: AbstractUtxoCoin, @@ -74,7 +76,12 @@ export async function verifyTransaction( ): Promise { const tx = coin.decodeTransactionFromPrebuild(params.txPrebuild); if (!(tx instanceof utxolib.bitgo.UtxoPsbt)) { - throw new Error('unexpected transaction type'); + throw new TxIntentMismatchError( + 'unexpected transaction type', + params.reqId || '', + [params.txParams], + params.txPrebuild.txHex || '' + ); } assertValidTransaction(tx, descriptorMap, params.txParams.recipients ?? [], tx.network); return true; diff --git a/modules/abstract-utxo/src/transaction/fixedScript/verifyTransaction.ts b/modules/abstract-utxo/src/transaction/fixedScript/verifyTransaction.ts index 0bf0fe31ea..95e12a1337 100644 --- a/modules/abstract-utxo/src/transaction/fixedScript/verifyTransaction.ts +++ b/modules/abstract-utxo/src/transaction/fixedScript/verifyTransaction.ts @@ -1,7 +1,7 @@ import buildDebug from 'debug'; import _ from 'lodash'; import BigNumber from 'bignumber.js'; -import { BitGoBase } from '@bitgo/sdk-core'; +import { BitGoBase, TxIntentMismatchError } from '@bitgo/sdk-core'; import * as utxolib from '@bitgo/utxo-lib'; import { AbstractUtxoCoin, Output, ParsedTransaction, VerifyTransactionOptions } from '../../abstractUtxoCoin'; @@ -25,6 +25,23 @@ function getPayGoLimit(allowPaygoOutput?: boolean): number { return 0.015; } +/** + * Verify that a transaction prebuild complies with the original intention for fixed-script wallets + * + * This implementation handles transaction verification for traditional UTXO coins using fixed scripts + * (non-descriptor wallets). It validates keychains, signatures, outputs, and amounts. + * + * @param coin - The UTXO coin instance + * @param bitgo - BitGo API instance for network calls + * @param params - Verification parameters + * @param params.txParams - Transaction parameters passed to send + * @param params.txPrebuild - Prebuild object returned by server + * @param params.wallet - Wallet object to obtain keys to verify against + * @param params.verification - Verification options (disableNetworking, keychains, addresses) + * @param params.reqId - Optional request ID for logging + * @returns {boolean} True if verification passes + * @throws {TxIntentMismatchError} if transaction validation fails + */ export async function verifyTransaction( coin: AbstractUtxoCoin, bitgo: BitGoBase, @@ -32,12 +49,17 @@ export async function verifyTransaction( ): Promise { const { txParams, txPrebuild, wallet, verification = {}, reqId } = params; + // Helper to throw TxIntentMismatchError with consistent context + const throwTxMismatch = (message: string): never => { + throw new TxIntentMismatchError(message, reqId || '', [txParams], txPrebuild.txHex || ''); + }; + if (!_.isUndefined(verification.disableNetworking) && !_.isBoolean(verification.disableNetworking)) { - throw new Error('verification.disableNetworking must be a boolean'); + throwTxMismatch('verification.disableNetworking must be a boolean'); } const isPsbt = txPrebuild.txHex && utxolib.bitgo.isPsbt(txPrebuild.txHex); if (isPsbt && txPrebuild.txInfo?.unspents) { - throw new Error('should not have unspents in txInfo for psbt'); + throwTxMismatch('should not have unspents in txInfo for psbt'); } const disableNetworking = !!verification.disableNetworking; const parsedTransaction: ParsedTransaction = await coin.parseTransaction({ @@ -64,7 +86,7 @@ export async function verifyTransaction( if (!_.isEmpty(keySignatures)) { const verify = (key, pub) => { if (!keychains.user || !keychains.user.pub) { - throw new Error('missing user keychain'); + throwTxMismatch('missing user keychain'); } return verifyKeySignature({ userKeychain: keychains.user as { pub: string }, @@ -75,7 +97,7 @@ export async function verifyTransaction( const isBackupKeySignatureValid = verify(keychains.backup, keySignatures.backupPub); const isBitgoKeySignatureValid = verify(keychains.bitgo, keySignatures.bitgoPub); if (!isBackupKeySignatureValid || !isBitgoKeySignatureValid) { - throw new Error('secondary public key signatures invalid'); + throwTxMismatch('secondary public key signatures invalid'); } debug('successfully verified backup and bitgo key signatures'); } else if (!disableNetworking) { @@ -86,11 +108,11 @@ export async function verifyTransaction( if (parsedTransaction.needsCustomChangeKeySignatureVerification) { if (!keychains.user || !userPublicKeyVerified) { - throw new Error('transaction requires verification of user public key, but it was unable to be verified'); + throwTxMismatch('transaction requires verification of user public key, but it was unable to be verified'); } const customChangeKeySignaturesVerified = verifyCustomChangeKeySignatures(parsedTransaction, keychains.user); if (!customChangeKeySignaturesVerified) { - throw new Error( + throwTxMismatch( 'transaction requires verification of custom change key signatures, but they were unable to be verified' ); } @@ -100,7 +122,7 @@ export async function verifyTransaction( const missingOutputs = parsedTransaction.missingOutputs; if (missingOutputs.length !== 0) { // there are some outputs in the recipients list that have not made it into the actual transaction - throw new Error('expected outputs missing in transaction prebuild'); + throwTxMismatch('expected outputs missing in transaction prebuild'); } const intendedExternalSpend = parsedTransaction.explicitExternalSpendAmount; @@ -140,13 +162,13 @@ export async function verifyTransaction( } else { // the additional external outputs can only be BitGo's pay-as-you-go fee, but we cannot verify the wallet address // there are some addresses that are outside the scope of intended recipients that are not change addresses - throw new Error('prebuild attempts to spend to unintended external recipients'); + throwTxMismatch('prebuild attempts to spend to unintended external recipients'); } } const allOutputs = parsedTransaction.outputs; if (!txPrebuild.txHex) { - throw new Error(`txPrebuild.txHex not set`); + throw new TxIntentMismatchError(`txPrebuild.txHex not set`, reqId || '', [txParams], ''); } const inputs = isPsbt ? getPsbtTxInputs(txPrebuild.txHex, coin.network).map((v) => ({ @@ -163,7 +185,7 @@ export async function verifyTransaction( const fee = inputAmount - outputAmount; if (fee < 0) { - throw new Error( + throwTxMismatch( `attempting to spend ${outputAmount} satoshis, which exceeds the input amount (${inputAmount} satoshis) by ${-fee}` ); } From 6e7a8b7be383501a09f40c3d289f32614dffaf3c Mon Sep 17 00:00:00 2001 From: Daniel Peng Date: Mon, 20 Oct 2025 13:37:13 -0400 Subject: [PATCH 2/5] refactor: update TxIntentMismatchError to use undefined Ticket: WP-6189 --- .../src/abstractEthLikeNewCoins.ts | 4 +- .../descriptor/verifyTransaction.ts | 4 +- .../fixedScript/verifyTransaction.ts | 4 +- modules/sdk-core/src/bitgo/errors.ts | 43 +++++++++++-------- 4 files changed, 30 insertions(+), 25 deletions(-) diff --git a/modules/abstract-eth/src/abstractEthLikeNewCoins.ts b/modules/abstract-eth/src/abstractEthLikeNewCoins.ts index bbf8e019e5..0e402ec725 100644 --- a/modules/abstract-eth/src/abstractEthLikeNewCoins.ts +++ b/modules/abstract-eth/src/abstractEthLikeNewCoins.ts @@ -2775,7 +2775,7 @@ export abstract class AbstractEthLikeNewCoins extends AbstractEthLikeCoin { // Helper to throw TxIntentMismatchError with consistent context const throwTxMismatch = (message: string): never => { - throw new TxIntentMismatchError(message, '', [txParams], txPrebuild?.txHex || ''); + throw new TxIntentMismatchError(message, undefined, [txParams], txPrebuild?.txHex); }; if ( @@ -2849,7 +2849,7 @@ export abstract class AbstractEthLikeNewCoins extends AbstractEthLikeCoin { // Helper to throw TxIntentMismatchError with consistent context const throwTxMismatch = (message: string): never => { - throw new TxIntentMismatchError(message, '', [txParams], txPrebuild?.txHex || ''); + throw new TxIntentMismatchError(message, undefined, [txParams], txPrebuild?.txHex); }; if (!txParams?.recipients || !txPrebuild?.recipients || !wallet) { diff --git a/modules/abstract-utxo/src/transaction/descriptor/verifyTransaction.ts b/modules/abstract-utxo/src/transaction/descriptor/verifyTransaction.ts index 917f82b51e..3fc77d28cb 100644 --- a/modules/abstract-utxo/src/transaction/descriptor/verifyTransaction.ts +++ b/modules/abstract-utxo/src/transaction/descriptor/verifyTransaction.ts @@ -78,9 +78,9 @@ export async function verifyTransaction( if (!(tx instanceof utxolib.bitgo.UtxoPsbt)) { throw new TxIntentMismatchError( 'unexpected transaction type', - params.reqId || '', + params.reqId, [params.txParams], - params.txPrebuild.txHex || '' + params.txPrebuild.txHex ); } assertValidTransaction(tx, descriptorMap, params.txParams.recipients ?? [], tx.network); diff --git a/modules/abstract-utxo/src/transaction/fixedScript/verifyTransaction.ts b/modules/abstract-utxo/src/transaction/fixedScript/verifyTransaction.ts index 95e12a1337..13d01ee2f6 100644 --- a/modules/abstract-utxo/src/transaction/fixedScript/verifyTransaction.ts +++ b/modules/abstract-utxo/src/transaction/fixedScript/verifyTransaction.ts @@ -51,7 +51,7 @@ export async function verifyTransaction( // Helper to throw TxIntentMismatchError with consistent context const throwTxMismatch = (message: string): never => { - throw new TxIntentMismatchError(message, reqId || '', [txParams], txPrebuild.txHex || ''); + throw new TxIntentMismatchError(message, reqId, [txParams], txPrebuild.txHex); }; if (!_.isUndefined(verification.disableNetworking) && !_.isBoolean(verification.disableNetworking)) { @@ -168,7 +168,7 @@ export async function verifyTransaction( const allOutputs = parsedTransaction.outputs; if (!txPrebuild.txHex) { - throw new TxIntentMismatchError(`txPrebuild.txHex not set`, reqId || '', [txParams], ''); + throw new TxIntentMismatchError(`txPrebuild.txHex not set`, reqId, [txParams], undefined); } const inputs = isPsbt ? getPsbtTxInputs(txPrebuild.txHex, coin.network).map((v) => ({ diff --git a/modules/sdk-core/src/bitgo/errors.ts b/modules/sdk-core/src/bitgo/errors.ts index 862a01c5ab..08c87c7aab 100644 --- a/modules/sdk-core/src/bitgo/errors.ts +++ b/modules/sdk-core/src/bitgo/errors.ts @@ -253,24 +253,29 @@ export interface ContractDataPayload { * * @class TxIntentMismatchError * @extends {BitGoJsError} - * @property {string | IRequestTracer} id - Transaction ID or request tracer for tracking + * @property {string | IRequestTracer | undefined} id - Transaction ID or request tracer for tracking * @property {TransactionParams[]} txParams - Array of transaction parameters that were analyzed - * @property {string} txHex - The raw transaction in hexadecimal format + * @property {string | undefined} txHex - The raw transaction in hexadecimal format */ export class TxIntentMismatchError extends BitGoJsError { - public readonly id: string | IRequestTracer; + public readonly id: string | IRequestTracer | undefined; public readonly txParams: TransactionParams[]; - public readonly txHex: string; + public readonly txHex: string | undefined; /** * Creates an instance of TxIntentMismatchError * * @param {string} message - Error message describing the intent mismatch - * @param {string | IRequestTracer} id - Transaction ID or request tracer + * @param {string | IRequestTracer | undefined} id - Transaction ID or request tracer * @param {TransactionParams[]} txParams - Transaction parameters that were analyzed - * @param {string} txHex - Raw transaction hex string + * @param {string | undefined} txHex - Raw transaction hex string */ - public constructor(message: string, id: string | IRequestTracer, txParams: TransactionParams[], txHex: string) { + public constructor( + message: string, + id: string | IRequestTracer | undefined, + txParams: TransactionParams[], + txHex: string | undefined + ) { super(message); this.id = id; this.txParams = txParams; @@ -295,16 +300,16 @@ export class TxIntentMismatchRecipientError extends TxIntentMismatchError { * Creates an instance of TxIntentMismatchRecipientError * * @param {string} message - Error message describing the recipient intent mismatch - * @param {string | IRequestTracer} id - Transaction ID or request tracer + * @param {string | IRequestTracer | undefined} id - Transaction ID or request tracer * @param {TransactionParams[]} txParams - Transaction parameters that were analyzed - * @param {string} txHex - Raw transaction hex string + * @param {string | undefined} txHex - Raw transaction hex string * @param {MismatchedRecipient[]} mismatchedRecipients - Array of recipients that don't match user intent */ public constructor( message: string, - id: string | IRequestTracer, + id: string | IRequestTracer | undefined, txParams: TransactionParams[], - txHex: string, + txHex: string | undefined, mismatchedRecipients: MismatchedRecipient[] ) { super(message, id, txParams, txHex); @@ -329,16 +334,16 @@ export class TxIntentMismatchContractError extends TxIntentMismatchError { * Creates an instance of TxIntentMismatchContractError * * @param {string} message - Error message describing the contract intent mismatch - * @param {string | IRequestTracer} id - Transaction ID or request tracer + * @param {string | IRequestTracer | undefined} id - Transaction ID or request tracer * @param {TransactionParams[]} txParams - Transaction parameters that were analyzed - * @param {string} txHex - Raw transaction hex string + * @param {string | undefined} txHex - Raw transaction hex string * @param {ContractDataPayload} mismatchedDataPayload - The contract interaction data that doesn't match user intent */ public constructor( message: string, - id: string | IRequestTracer, + id: string | IRequestTracer | undefined, txParams: TransactionParams[], - txHex: string, + txHex: string | undefined, mismatchedDataPayload: ContractDataPayload ) { super(message, id, txParams, txHex); @@ -363,16 +368,16 @@ export class TxIntentMismatchApprovalError extends TxIntentMismatchError { * Creates an instance of TxIntentMismatchApprovalError * * @param {string} message - Error message describing the approval intent mismatch - * @param {string | IRequestTracer} id - Transaction ID or request tracer + * @param {string | IRequestTracer | undefined} id - Transaction ID or request tracer * @param {TransactionParams[]} txParams - Transaction parameters that were analyzed - * @param {string} txHex - Raw transaction hex string + * @param {string | undefined} txHex - Raw transaction hex string * @param {TokenApproval} tokenApproval - Details of the token approval that doesn't match user intent */ public constructor( message: string, - id: string | IRequestTracer, + id: string | IRequestTracer | undefined, txParams: TransactionParams[], - txHex: string, + txHex: string | undefined, tokenApproval: TokenApproval ) { super(message, id, txParams, txHex); From 641406fa3d9e9a9380664f545e0ab80aafffac20 Mon Sep 17 00:00:00 2001 From: Luis Covarrubias Date: Mon, 20 Oct 2025 15:48:41 -0700 Subject: [PATCH 3/5] feat: improve transaction verification error handling Replace custom validation errors with SDK's TxIntentMismatchRecipientError to provide more detailed information about mismatched transaction outputs. This enhances error reporting by including specific details about missing or unexpected outputs, helping users understand what differs between their intent and the actual transaction. Co-authored-by: llm-git Ticket: BTC-2579 TICKET: WP-6189 --- .../descriptor/verifyTransaction.ts | 67 ++++++++++++++++++- .../fixedScript/verifyTransaction.ts | 14 ++-- 2 files changed, 72 insertions(+), 9 deletions(-) diff --git a/modules/abstract-utxo/src/transaction/descriptor/verifyTransaction.ts b/modules/abstract-utxo/src/transaction/descriptor/verifyTransaction.ts index 3fc77d28cb..087019c4f0 100644 --- a/modules/abstract-utxo/src/transaction/descriptor/verifyTransaction.ts +++ b/modules/abstract-utxo/src/transaction/descriptor/verifyTransaction.ts @@ -1,5 +1,12 @@ import * as utxolib from '@bitgo/utxo-lib'; -import { ITransactionRecipient, TxIntentMismatchError, VerifyTransactionOptions } from '@bitgo/sdk-core'; +import { + IRequestTracer, + ITransactionRecipient, + MismatchedRecipient, + TxIntentMismatchError, + TxIntentMismatchRecipientError, + VerifyTransactionOptions, +} from '@bitgo/sdk-core'; import { DescriptorMap } from '@bitgo/utxo-core/descriptor'; import { AbstractUtxoCoin, BaseOutput, BaseParsedTransactionOutputs } from '../../abstractUtxoCoin'; @@ -57,6 +64,52 @@ export function assertValidTransaction( assertExpectedOutputDifference(toBaseParsedTransactionOutputsFromPsbt(psbt, descriptors, recipients, network)); } +/** + * Convert ValidationError to TxIntentMismatchRecipientError with structured data + * + * This preserves the structured error information from the original ValidationError + * by extracting the mismatched outputs and converting them to the standardized format. + * The original error is preserved as the `cause` for debugging purposes. + */ +function convertValidationErrorToTxIntentMismatch( + error: AggregateValidationError, + reqId: string | IRequestTracer | undefined, + txParams: VerifyTransactionOptions['txParams'], + txHex: string | undefined +): TxIntentMismatchRecipientError { + const mismatchedRecipients: MismatchedRecipient[] = []; + + for (const err of error.errors) { + if (err instanceof ErrorMissingOutputs) { + mismatchedRecipients.push( + ...err.missingOutputs.map((output) => ({ + address: output.address, + amount: output.amount.toString(), + })) + ); + } else if (err instanceof ErrorImplicitExternalOutputs) { + mismatchedRecipients.push( + ...err.implicitExternalOutputs.map((output) => ({ + address: output.address, + amount: output.amount.toString(), + })) + ); + } + } + + const txIntentError = new TxIntentMismatchRecipientError( + error.message, + reqId, + [txParams], + txHex, + mismatchedRecipients + ); + // Preserve the original structured error as the cause for debugging + // See: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Error/cause + (txIntentError as Error & { cause?: Error }).cause = error; + return txIntentError; +} + /** * Wrapper around assertValidTransaction that returns a boolean instead of throwing. * @@ -68,6 +121,7 @@ export function assertValidTransaction( * @param descriptorMap * @returns {boolean} True if verification passes * @throws {TxIntentMismatchError} if transaction validation fails + * @throws {TxIntentMismatchRecipientError} if transaction recipients don't match user intent */ export async function verifyTransaction( coin: AbstractUtxoCoin, @@ -83,6 +137,15 @@ export async function verifyTransaction( params.txPrebuild.txHex ); } - assertValidTransaction(tx, descriptorMap, params.txParams.recipients ?? [], tx.network); + + try { + assertValidTransaction(tx, descriptorMap, params.txParams.recipients ?? [], tx.network); + } catch (error) { + if (error instanceof AggregateValidationError) { + throw convertValidationErrorToTxIntentMismatch(error, params.reqId, params.txParams, params.txPrebuild.txHex); + } + throw error; + } + return true; } diff --git a/modules/abstract-utxo/src/transaction/fixedScript/verifyTransaction.ts b/modules/abstract-utxo/src/transaction/fixedScript/verifyTransaction.ts index 13d01ee2f6..4e54d51cd9 100644 --- a/modules/abstract-utxo/src/transaction/fixedScript/verifyTransaction.ts +++ b/modules/abstract-utxo/src/transaction/fixedScript/verifyTransaction.ts @@ -55,11 +55,11 @@ export async function verifyTransaction( }; if (!_.isUndefined(verification.disableNetworking) && !_.isBoolean(verification.disableNetworking)) { - throwTxMismatch('verification.disableNetworking must be a boolean'); + throw new TypeError('verification.disableNetworking must be a boolean'); } const isPsbt = txPrebuild.txHex && utxolib.bitgo.isPsbt(txPrebuild.txHex); if (isPsbt && txPrebuild.txInfo?.unspents) { - throwTxMismatch('should not have unspents in txInfo for psbt'); + throw new Error('should not have unspents in txInfo for psbt'); } const disableNetworking = !!verification.disableNetworking; const parsedTransaction: ParsedTransaction = await coin.parseTransaction({ @@ -97,7 +97,7 @@ export async function verifyTransaction( const isBackupKeySignatureValid = verify(keychains.backup, keySignatures.backupPub); const isBitgoKeySignatureValid = verify(keychains.bitgo, keySignatures.bitgoPub); if (!isBackupKeySignatureValid || !isBitgoKeySignatureValid) { - throwTxMismatch('secondary public key signatures invalid'); + throw new Error('secondary public key signatures invalid'); } debug('successfully verified backup and bitgo key signatures'); } else if (!disableNetworking) { @@ -108,11 +108,11 @@ export async function verifyTransaction( if (parsedTransaction.needsCustomChangeKeySignatureVerification) { if (!keychains.user || !userPublicKeyVerified) { - throwTxMismatch('transaction requires verification of user public key, but it was unable to be verified'); + throw new Error('transaction requires verification of user public key, but it was unable to be verified'); } const customChangeKeySignaturesVerified = verifyCustomChangeKeySignatures(parsedTransaction, keychains.user); if (!customChangeKeySignaturesVerified) { - throwTxMismatch( + throw new Error( 'transaction requires verification of custom change key signatures, but they were unable to be verified' ); } @@ -168,7 +168,7 @@ export async function verifyTransaction( const allOutputs = parsedTransaction.outputs; if (!txPrebuild.txHex) { - throw new TxIntentMismatchError(`txPrebuild.txHex not set`, reqId, [txParams], undefined); + throw new Error(`txPrebuild.txHex not set`); } const inputs = isPsbt ? getPsbtTxInputs(txPrebuild.txHex, coin.network).map((v) => ({ @@ -185,7 +185,7 @@ export async function verifyTransaction( const fee = inputAmount - outputAmount; if (fee < 0) { - throwTxMismatch( + throw new Error( `attempting to spend ${outputAmount} satoshis, which exceeds the input amount (${inputAmount} satoshis) by ${-fee}` ); } From 3a9d500a8b55e626621dd5d5f1eb9fe689c60c9f Mon Sep 17 00:00:00 2001 From: Daniel Peng Date: Tue, 21 Oct 2025 12:12:58 -0400 Subject: [PATCH 4/5] refactor: enhance eth-like tx verification with recipient mismatch errors Ticket: WP-6189 --- .../src/abstractEthLikeNewCoins.ts | 82 ++++++++++++------- 1 file changed, 54 insertions(+), 28 deletions(-) diff --git a/modules/abstract-eth/src/abstractEthLikeNewCoins.ts b/modules/abstract-eth/src/abstractEthLikeNewCoins.ts index 0e402ec725..c6bb2bc5b6 100644 --- a/modules/abstract-eth/src/abstractEthLikeNewCoins.ts +++ b/modules/abstract-eth/src/abstractEthLikeNewCoins.ts @@ -29,6 +29,7 @@ import { Recipient, SignTransactionOptions as BaseSignTransactionOptions, TxIntentMismatchError, + TxIntentMismatchRecipientError, TransactionParams, TransactionPrebuild as BaseTransactionPrebuild, TransactionRecipient, @@ -2768,14 +2769,14 @@ export abstract class AbstractEthLikeNewCoins extends AbstractEthLikeCoin { * @param {TransactionPrebuild} params.txPrebuild - prebuild object returned by server * @param {Wallet} params.wallet - Wallet object to obtain keys to verify against * @returns {boolean} - * @throws {TxIntentMismatchError} if transaction validation fails + * @throws {TxIntentMismatchRecipientError} if transaction recipients don't match user intent */ async verifyTssTransaction(params: VerifyEthTransactionOptions): Promise { const { txParams, txPrebuild, wallet } = params; - // Helper to throw TxIntentMismatchError with consistent context - const throwTxMismatch = (message: string): never => { - throw new TxIntentMismatchError(message, undefined, [txParams], txPrebuild?.txHex); + // Helper to throw TxIntentMismatchRecipientError with recipient details + const throwRecipientMismatch = (message: string, mismatchedRecipients: Recipient[]): never => { + throw new TxIntentMismatchRecipientError(message, undefined, [txParams], txPrebuild?.txHex, mismatchedRecipients); }; if ( @@ -2785,13 +2786,13 @@ export abstract class AbstractEthLikeNewCoins extends AbstractEthLikeCoin { (txParams.type && ['acceleration', 'fillNonce', 'transferToken', 'tokenApproval'].includes(txParams.type)) ) ) { - throwTxMismatch(`missing txParams`); + throw new Error('missing txParams'); } if (!wallet || !txPrebuild) { - throwTxMismatch(`missing params`); + throw new Error('missing params'); } if (txParams.hop && txParams.recipients && txParams.recipients.length > 1) { - throwTxMismatch(`tx cannot be both a batch and hop transaction`); + throw new Error('tx cannot be both a batch and hop transaction'); } if (txParams.type && ['transfer'].includes(txParams.type)) { @@ -2806,10 +2807,14 @@ export abstract class AbstractEthLikeNewCoins extends AbstractEthLikeCoin { const txJson = tx.toJson(); if (txJson.data === '0x') { if (expectedAmount !== txJson.value) { - throwTxMismatch('the transaction amount in txPrebuild does not match the value given by client'); + throwRecipientMismatch('the transaction amount in txPrebuild does not match the value given by client', [ + { address: txJson.to, amount: txJson.value }, + ]); } if (expectedDestination.toLowerCase() !== txJson.to.toLowerCase()) { - throwTxMismatch('destination address does not match with the recipient address'); + throwRecipientMismatch('destination address does not match with the recipient address', [ + { address: txJson.to, amount: txJson.value }, + ]); } } else if (txJson.data.startsWith('0xa9059cbb')) { const [recipientAddress, amount] = getRawDecoded( @@ -2817,10 +2822,14 @@ export abstract class AbstractEthLikeNewCoins extends AbstractEthLikeCoin { getBufferedByteCode('0xa9059cbb', txJson.data) ); if (expectedAmount !== amount.toString()) { - throwTxMismatch('the transaction amount in txPrebuild does not match the value given by client'); + throwRecipientMismatch('the transaction amount in txPrebuild does not match the value given by client', [ + { address: addHexPrefix(recipientAddress.toString()), amount: amount.toString() }, + ]); } if (expectedDestination.toLowerCase() !== addHexPrefix(recipientAddress.toString()).toLowerCase()) { - throwTxMismatch('destination address does not match with the recipient address'); + throwRecipientMismatch('destination address does not match with the recipient address', [ + { address: addHexPrefix(recipientAddress.toString()), amount: amount.toString() }, + ]); } } } @@ -2838,6 +2847,7 @@ export abstract class AbstractEthLikeNewCoins extends AbstractEthLikeCoin { * @param {Wallet} params.wallet - Wallet object to obtain keys to verify against * @returns {boolean} * @throws {TxIntentMismatchError} if transaction validation fails + * @throws {TxIntentMismatchRecipientError} if transaction recipients don't match user intent */ async verifyTransaction(params: VerifyEthTransactionOptions): Promise { const ethNetwork = this.getNetwork(); @@ -2847,29 +2857,29 @@ export abstract class AbstractEthLikeNewCoins extends AbstractEthLikeCoin { return this.verifyTssTransaction(params); } - // Helper to throw TxIntentMismatchError with consistent context - const throwTxMismatch = (message: string): never => { - throw new TxIntentMismatchError(message, undefined, [txParams], txPrebuild?.txHex); + // Helper to throw TxIntentMismatchRecipientError with recipient details + const throwRecipientMismatch = (message: string, mismatchedRecipients: Recipient[]): never => { + throw new TxIntentMismatchRecipientError(message, undefined, [txParams], txPrebuild?.txHex, mismatchedRecipients); }; if (!txParams?.recipients || !txPrebuild?.recipients || !wallet) { - throwTxMismatch(`missing params`); + throw new Error('missing params'); } const recipients = txParams.recipients!; if (txParams.hop && recipients.length > 1) { - throwTxMismatch(`tx cannot be both a batch and hop transaction`); + throw new Error('tx cannot be both a batch and hop transaction'); } if (txPrebuild.recipients.length > 1) { - throwTxMismatch( + throw new Error( `${this.getChain()} doesn't support sending to more than 1 destination address within a single transaction. Try again, using only a single recipient.` ); } if (txParams.hop && txPrebuild.hopTransaction) { // Check recipient amount for hop transaction if (recipients.length !== 1) { - throwTxMismatch(`hop transaction only supports 1 recipient but ${recipients.length} found`); + throw new Error(`hop transaction only supports 1 recipient but ${recipients.length} found`); } // Check tx sends to hop address @@ -2879,7 +2889,9 @@ export abstract class AbstractEthLikeNewCoins extends AbstractEthLikeCoin { const expectedHopAddress = optionalDeps.ethUtil.stripHexPrefix(decodedHopTx.getSenderAddress().toString()); const actualHopAddress = optionalDeps.ethUtil.stripHexPrefix(txPrebuild.recipients[0].address); if (expectedHopAddress.toLowerCase() !== actualHopAddress.toLowerCase()) { - throwTxMismatch('recipient address of txPrebuild does not match hop address'); + throwRecipientMismatch('recipient address of txPrebuild does not match hop address', [ + { address: txPrebuild.recipients[0].address, amount: txPrebuild.recipients[0].amount.toString() }, + ]); } // Convert TransactionRecipient array to Recipient array @@ -2897,7 +2909,9 @@ export abstract class AbstractEthLikeNewCoins extends AbstractEthLikeCoin { if (txParams.tokenName) { const expectedTotalAmount = new BigNumber(0); if (!expectedTotalAmount.isEqualTo(txPrebuild.recipients[0].amount)) { - throwTxMismatch('batch token transaction amount in txPrebuild should be zero for token transfers'); + throwRecipientMismatch('batch token transaction amount in txPrebuild should be zero for token transfers', [ + { address: txPrebuild.recipients[0].address, amount: txPrebuild.recipients[0].amount.toString() }, + ]); } } else { let expectedTotalAmount = new BigNumber(0); @@ -2905,8 +2919,9 @@ export abstract class AbstractEthLikeNewCoins extends AbstractEthLikeCoin { expectedTotalAmount = expectedTotalAmount.plus(recipients[i].amount); } if (!expectedTotalAmount.isEqualTo(txPrebuild.recipients[0].amount)) { - throwTxMismatch( - 'batch transaction amount in txPrebuild received from BitGo servers does not match txParams supplied by client' + throwRecipientMismatch( + 'batch transaction amount in txPrebuild received from BitGo servers does not match txParams supplied by client', + [{ address: txPrebuild.recipients[0].address, amount: txPrebuild.recipients[0].amount.toString() }] ); } } @@ -2917,26 +2932,37 @@ export abstract class AbstractEthLikeNewCoins extends AbstractEthLikeCoin { !batcherContractAddress || batcherContractAddress.toLowerCase() !== txPrebuild.recipients[0].address.toLowerCase() ) { - throwTxMismatch('recipient address of txPrebuild does not match batcher address'); + throwRecipientMismatch('recipient address of txPrebuild does not match batcher address', [ + { address: txPrebuild.recipients[0].address, amount: txPrebuild.recipients[0].amount.toString() }, + ]); } } else { // Check recipient address and amount for normal transaction if (recipients.length !== 1) { - throwTxMismatch(`normal transaction only supports 1 recipient but ${recipients.length} found`); + throw new Error(`normal transaction only supports 1 recipient but ${recipients.length} found`); } const expectedAmount = new BigNumber(recipients[0].amount); if (!expectedAmount.isEqualTo(txPrebuild.recipients[0].amount)) { - throwTxMismatch( - 'normal transaction amount in txPrebuild received from BitGo servers does not match txParams supplied by client' + throwRecipientMismatch( + 'normal transaction amount in txPrebuild received from BitGo servers does not match txParams supplied by client', + [{ address: txPrebuild.recipients[0].address, amount: txPrebuild.recipients[0].amount.toString() }] ); } if (this.isETHAddress(recipients[0].address) && recipients[0].address !== txPrebuild.recipients[0].address) { - throwTxMismatch('destination address in normal txPrebuild does not match that in txParams supplied by client'); + throwRecipientMismatch( + 'destination address in normal txPrebuild does not match that in txParams supplied by client', + [{ address: txPrebuild.recipients[0].address, amount: txPrebuild.recipients[0].amount.toString() }] + ); } } // Check coin is correct for all transaction types if (!this.verifyCoin(txPrebuild)) { - throwTxMismatch(`coin in txPrebuild did not match that in txParams supplied by client`); + throw new TxIntentMismatchError( + 'coin in txPrebuild did not match that in txParams supplied by client', + undefined, + [txParams], + txPrebuild?.txHex + ); } return true; } From a3513a35c1cde4f7575500a49adc50ee412bcad5 Mon Sep 17 00:00:00 2001 From: Daniel Peng Date: Wed, 22 Oct 2025 11:30:28 -0400 Subject: [PATCH 5/5] feat: refactoring error handling for transaction verification Ticket: WP-6189 --- modules/abstract-utxo/src/abstractUtxoCoin.ts | 65 +++++++++++++++- .../descriptor/verifyTransaction.ts | 76 +++---------------- 2 files changed, 73 insertions(+), 68 deletions(-) diff --git a/modules/abstract-utxo/src/abstractUtxoCoin.ts b/modules/abstract-utxo/src/abstractUtxoCoin.ts index 7a33463874..00f67ab928 100644 --- a/modules/abstract-utxo/src/abstractUtxoCoin.ts +++ b/modules/abstract-utxo/src/abstractUtxoCoin.ts @@ -22,6 +22,7 @@ import { IWallet, KeychainsTriplet, KeyIndices, + MismatchedRecipient, MultisigType, multisigTypes, P2shP2wshUnsupportedError, @@ -39,6 +40,7 @@ import { TransactionParams as BaseTransactionParams, TransactionPrebuild as BaseTransactionPrebuild, Triple, + TxIntentMismatchRecipientError, UnexpectedAddressError, UnsupportedAddressTypeError, VerificationOptions, @@ -72,6 +74,11 @@ import { parseTransaction, verifyTransaction, } from './transaction'; +import { + AggregateValidationError, + ErrorMissingOutputs, + ErrorImplicitExternalOutputs, +} from './transaction/descriptor/verifyTransaction'; import { assertDescriptorWalletAddress, getDescriptorMapFromWallet, isDescriptorWallet } from './descriptor'; import { getChainFromNetwork, getFamilyFromNetwork, getFullNameFromNetwork } from './names'; import { CustomChangeOptions } from './transaction/fixedScript'; @@ -113,6 +120,52 @@ const { getExternalChainCode, isChainCode, scriptTypeForChain, outputScripts } = type Unspent = bitgo.Unspent; +/** + * Convert ValidationError to TxIntentMismatchRecipientError with structured data + * + * This preserves the structured error information from the original ValidationError + * by extracting the mismatched outputs and converting them to the standardized format. + * The original error is preserved as the `cause` for debugging purposes. + */ +function convertValidationErrorToTxIntentMismatch( + error: AggregateValidationError, + reqId: string | IRequestTracer | undefined, + txParams: BaseTransactionParams, + txHex: string | undefined +): TxIntentMismatchRecipientError { + const mismatchedRecipients: MismatchedRecipient[] = []; + + for (const err of error.errors) { + if (err instanceof ErrorMissingOutputs) { + mismatchedRecipients.push( + ...err.missingOutputs.map((output) => ({ + address: output.address, + amount: output.amount.toString(), + })) + ); + } else if (err instanceof ErrorImplicitExternalOutputs) { + mismatchedRecipients.push( + ...err.implicitExternalOutputs.map((output) => ({ + address: output.address, + amount: output.amount.toString(), + })) + ); + } + } + + const txIntentError = new TxIntentMismatchRecipientError( + error.message, + reqId, + [txParams], + txHex, + mismatchedRecipients + ); + // Preserve the original structured error as the cause for debugging + // See: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Error/cause + (txIntentError as Error & { cause?: Error }).cause = error; + return txIntentError; +} + export type DecodedTransaction = | utxolib.bitgo.UtxoTransaction | utxolib.bitgo.UtxoPsbt; @@ -631,13 +684,21 @@ export abstract class AbstractUtxoCoin extends BaseCoin { * @param params.verification.disableNetworking Disallow fetching any data from the internet for verification purposes * @param params.verification.keychains Pass keychains manually rather than fetching them by id * @param params.verification.addresses Address details to pass in for out-of-band verification - * @returns {boolean} + * @returns {boolean} True if verification passes * @throws {TxIntentMismatchError} if transaction validation fails + * @throws {TxIntentMismatchRecipientError} if transaction recipients don't match user intent */ async verifyTransaction( params: VerifyTransactionOptions ): Promise { - return verifyTransaction(this, this.bitgo, params); + try { + return await verifyTransaction(this, this.bitgo, params); + } catch (error) { + if (error instanceof AggregateValidationError) { + throw convertValidationErrorToTxIntentMismatch(error, params.reqId, params.txParams, params.txPrebuild.txHex); + } + throw error; + } } /** diff --git a/modules/abstract-utxo/src/transaction/descriptor/verifyTransaction.ts b/modules/abstract-utxo/src/transaction/descriptor/verifyTransaction.ts index 087019c4f0..939accb62d 100644 --- a/modules/abstract-utxo/src/transaction/descriptor/verifyTransaction.ts +++ b/modules/abstract-utxo/src/transaction/descriptor/verifyTransaction.ts @@ -1,15 +1,13 @@ import * as utxolib from '@bitgo/utxo-lib'; -import { - IRequestTracer, - ITransactionRecipient, - MismatchedRecipient, - TxIntentMismatchError, - TxIntentMismatchRecipientError, - VerifyTransactionOptions, -} from '@bitgo/sdk-core'; +import { ITransactionRecipient, TxIntentMismatchError } from '@bitgo/sdk-core'; import { DescriptorMap } from '@bitgo/utxo-core/descriptor'; -import { AbstractUtxoCoin, BaseOutput, BaseParsedTransactionOutputs } from '../../abstractUtxoCoin'; +import { + AbstractUtxoCoin, + BaseOutput, + BaseParsedTransactionOutputs, + VerifyTransactionOptions, +} from '../../abstractUtxoCoin'; import { toBaseParsedTransactionOutputsFromPsbt } from './parse'; @@ -64,52 +62,6 @@ export function assertValidTransaction( assertExpectedOutputDifference(toBaseParsedTransactionOutputsFromPsbt(psbt, descriptors, recipients, network)); } -/** - * Convert ValidationError to TxIntentMismatchRecipientError with structured data - * - * This preserves the structured error information from the original ValidationError - * by extracting the mismatched outputs and converting them to the standardized format. - * The original error is preserved as the `cause` for debugging purposes. - */ -function convertValidationErrorToTxIntentMismatch( - error: AggregateValidationError, - reqId: string | IRequestTracer | undefined, - txParams: VerifyTransactionOptions['txParams'], - txHex: string | undefined -): TxIntentMismatchRecipientError { - const mismatchedRecipients: MismatchedRecipient[] = []; - - for (const err of error.errors) { - if (err instanceof ErrorMissingOutputs) { - mismatchedRecipients.push( - ...err.missingOutputs.map((output) => ({ - address: output.address, - amount: output.amount.toString(), - })) - ); - } else if (err instanceof ErrorImplicitExternalOutputs) { - mismatchedRecipients.push( - ...err.implicitExternalOutputs.map((output) => ({ - address: output.address, - amount: output.amount.toString(), - })) - ); - } - } - - const txIntentError = new TxIntentMismatchRecipientError( - error.message, - reqId, - [txParams], - txHex, - mismatchedRecipients - ); - // Preserve the original structured error as the cause for debugging - // See: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Error/cause - (txIntentError as Error & { cause?: Error }).cause = error; - return txIntentError; -} - /** * Wrapper around assertValidTransaction that returns a boolean instead of throwing. * @@ -121,11 +73,10 @@ function convertValidationErrorToTxIntentMismatch( * @param descriptorMap * @returns {boolean} True if verification passes * @throws {TxIntentMismatchError} if transaction validation fails - * @throws {TxIntentMismatchRecipientError} if transaction recipients don't match user intent */ -export async function verifyTransaction( +export async function verifyTransaction( coin: AbstractUtxoCoin, - params: VerifyTransactionOptions, + params: VerifyTransactionOptions, descriptorMap: DescriptorMap ): Promise { const tx = coin.decodeTransactionFromPrebuild(params.txPrebuild); @@ -138,14 +89,7 @@ export async function verifyTransaction( ); } - try { - assertValidTransaction(tx, descriptorMap, params.txParams.recipients ?? [], tx.network); - } catch (error) { - if (error instanceof AggregateValidationError) { - throw convertValidationErrorToTxIntentMismatch(error, params.reqId, params.txParams, params.txPrebuild.txHex); - } - throw error; - } + assertValidTransaction(tx, descriptorMap, params.txParams.recipients ?? [], tx.network); return true; }