From feed271ec7ae1ae099adf3eb100a532b12d03228 Mon Sep 17 00:00:00 2001 From: Daniel Peng Date: Thu, 20 Nov 2025 07:33:12 -0500 Subject: [PATCH 1/3] feat: add tx explanation for TxIntentMismatch errors Ticket: WP-6608 --- .../src/abstractEthLikeNewCoins.ts | 53 +++++- modules/abstract-utxo/src/abstractUtxoCoin.ts | 17 +- .../descriptor/verifyTransaction.ts | 6 +- .../fixedScript/verifyTransaction.ts | 5 +- .../abstract-utxo/src/transaction/index.ts | 1 + .../src/transaction/txExplanation.ts | 31 ++++ modules/sdk-core/src/bitgo/errors.ts | 25 ++- modules/sdk-core/test/unit/bitgo/errors.ts | 164 +++++++++++++++++- 8 files changed, 284 insertions(+), 18 deletions(-) create mode 100644 modules/abstract-utxo/src/transaction/txExplanation.ts diff --git a/modules/abstract-eth/src/abstractEthLikeNewCoins.ts b/modules/abstract-eth/src/abstractEthLikeNewCoins.ts index 8d2bcbfcc4..a9ea367b3d 100644 --- a/modules/abstract-eth/src/abstractEthLikeNewCoins.ts +++ b/modules/abstract-eth/src/abstractEthLikeNewCoins.ts @@ -3031,6 +3031,34 @@ export abstract class AbstractEthLikeNewCoins extends AbstractEthLikeCoin { return txPrebuild.coin === nativeCoin; } + /** + * Generate transaction explanation for error reporting + * @param txPrebuild - Transaction prebuild containing txHex and fee info + * @returns Stringified JSON explanation + */ + private async getTxExplanation(txPrebuild?: TransactionPrebuild): Promise { + if (!txPrebuild?.txHex || !txPrebuild?.gasPrice) { + return undefined; + } + + try { + const explanation = await this.explainTransaction({ + txHex: txPrebuild.txHex, + feeInfo: { + fee: txPrebuild.gasPrice.toString(), + }, + }); + return JSON.stringify(explanation, null, 2); + } catch (e) { + const errorDetails = { + error: 'Failed to parse transaction explanation', + txHex: txPrebuild.txHex, + details: e instanceof Error ? e.message : String(e), + }; + return JSON.stringify(errorDetails, null, 2); + } + } + /** * Verify if a tss transaction is valid * @@ -3044,9 +3072,18 @@ export abstract class AbstractEthLikeNewCoins extends AbstractEthLikeCoin { async verifyTssTransaction(params: VerifyEthTransactionOptions): Promise { const { txParams, txPrebuild, wallet } = params; + const txExplanation = await this.getTxExplanation(txPrebuild); + // Helper to throw TxIntentMismatchRecipientError with recipient details const throwRecipientMismatch = (message: string, mismatchedRecipients: Recipient[]): never => { - throw new TxIntentMismatchRecipientError(message, undefined, [txParams], txPrebuild?.txHex, mismatchedRecipients); + throw new TxIntentMismatchRecipientError( + message, + undefined, + [txParams], + txPrebuild?.txHex, + mismatchedRecipients, + txExplanation + ); }; if ( @@ -3148,9 +3185,18 @@ export abstract class AbstractEthLikeNewCoins extends AbstractEthLikeCoin { return this.verifyTssTransaction(params); } + const txExplanation = await this.getTxExplanation(txPrebuild); + // Helper to throw TxIntentMismatchRecipientError with recipient details const throwRecipientMismatch = (message: string, mismatchedRecipients: Recipient[]): never => { - throw new TxIntentMismatchRecipientError(message, undefined, [txParams], txPrebuild?.txHex, mismatchedRecipients); + throw new TxIntentMismatchRecipientError( + message, + undefined, + [txParams], + txPrebuild?.txHex, + mismatchedRecipients, + txExplanation + ); }; if (!txParams?.recipients || !txPrebuild?.recipients || !wallet) { @@ -3252,7 +3298,8 @@ export abstract class AbstractEthLikeNewCoins extends AbstractEthLikeCoin { 'coin in txPrebuild did not match that in txParams supplied by client', undefined, [txParams], - txPrebuild?.txHex + txPrebuild?.txHex, + txExplanation ); } return true; diff --git a/modules/abstract-utxo/src/abstractUtxoCoin.ts b/modules/abstract-utxo/src/abstractUtxoCoin.ts index dccb1d666c..0e414fd62b 100644 --- a/modules/abstract-utxo/src/abstractUtxoCoin.ts +++ b/modules/abstract-utxo/src/abstractUtxoCoin.ts @@ -61,6 +61,7 @@ import { assertValidTransactionRecipient, explainTx, fromExtendedAddressFormat, + getTxExplanation, isScriptRecipient, parseTransaction, verifyTransaction, @@ -143,7 +144,8 @@ function convertValidationErrorToTxIntentMismatch( error: AggregateValidationError, reqId: string | IRequestTracer | undefined, txParams: BaseTransactionParams, - txHex: string | undefined + txHex: string | undefined, + txExplanation?: string ): TxIntentMismatchRecipientError { const mismatchedRecipients: MismatchedRecipient[] = []; @@ -170,7 +172,8 @@ function convertValidationErrorToTxIntentMismatch( reqId, [txParams], txHex, - mismatchedRecipients + mismatchedRecipients, + txExplanation ); // 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 @@ -616,11 +619,19 @@ export abstract class AbstractUtxoCoin extends BaseCoin { async verifyTransaction( params: VerifyTransactionOptions ): Promise { + const txExplanation = await getTxExplanation(this, params.txPrebuild); + 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 convertValidationErrorToTxIntentMismatch( + error, + params.reqId, + params.txParams, + params.txPrebuild.txHex, + txExplanation + ); } throw error; } diff --git a/modules/abstract-utxo/src/transaction/descriptor/verifyTransaction.ts b/modules/abstract-utxo/src/transaction/descriptor/verifyTransaction.ts index 97d4fcb136..d02ee26deb 100644 --- a/modules/abstract-utxo/src/transaction/descriptor/verifyTransaction.ts +++ b/modules/abstract-utxo/src/transaction/descriptor/verifyTransaction.ts @@ -4,6 +4,7 @@ import { DescriptorMap } from '@bitgo/utxo-core/descriptor'; import { AbstractUtxoCoin, VerifyTransactionOptions } from '../../abstractUtxoCoin'; import { BaseOutput, BaseParsedTransactionOutputs } from '../types'; +import { getTxExplanation } from '../txExplanation'; import { toBaseParsedTransactionOutputsFromPsbt } from './parse'; @@ -75,13 +76,16 @@ export async function verifyTransaction( params: VerifyTransactionOptions, descriptorMap: DescriptorMap ): Promise { + const txExplanation = await getTxExplanation(coin, params.txPrebuild); + const tx = coin.decodeTransactionFromPrebuild(params.txPrebuild); if (!(tx instanceof utxolib.bitgo.UtxoPsbt)) { throw new TxIntentMismatchError( 'unexpected transaction type', params.reqId, [params.txParams], - params.txPrebuild.txHex + params.txPrebuild.txHex, + txExplanation ); } diff --git a/modules/abstract-utxo/src/transaction/fixedScript/verifyTransaction.ts b/modules/abstract-utxo/src/transaction/fixedScript/verifyTransaction.ts index fdbb294805..2cc915a378 100644 --- a/modules/abstract-utxo/src/transaction/fixedScript/verifyTransaction.ts +++ b/modules/abstract-utxo/src/transaction/fixedScript/verifyTransaction.ts @@ -8,6 +8,7 @@ import { AbstractUtxoCoin, VerifyTransactionOptions } from '../../abstractUtxoCo import { Output, ParsedTransaction } from '../types'; import { verifyCustomChangeKeySignatures, verifyKeySignature, verifyUserPublicKey } from '../../verifyKey'; import { getPsbtTxInputs, getTxInputs } from '../fetchInputs'; +import { getTxExplanation } from '../txExplanation'; const debug = buildDebug('bitgo:abstract-utxo:verifyTransaction'); @@ -50,9 +51,11 @@ export async function verifyTransaction( ): Promise { const { txParams, txPrebuild, wallet, verification = {}, reqId } = params; + const txExplanation = await getTxExplanation(coin, txPrebuild); + // 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, txExplanation); }; if (!_.isUndefined(verification.disableNetworking) && !_.isBoolean(verification.disableNetworking)) { diff --git a/modules/abstract-utxo/src/transaction/index.ts b/modules/abstract-utxo/src/transaction/index.ts index 4bacd7522e..c143e99051 100644 --- a/modules/abstract-utxo/src/transaction/index.ts +++ b/modules/abstract-utxo/src/transaction/index.ts @@ -3,5 +3,6 @@ export * from './recipient'; export { explainTx } from './explainTransaction'; export { parseTransaction } from './parseTransaction'; export { verifyTransaction } from './verifyTransaction'; +export { getTxExplanation } from './txExplanation'; export * from './fetchInputs'; export * as bip322 from './bip322'; diff --git a/modules/abstract-utxo/src/transaction/txExplanation.ts b/modules/abstract-utxo/src/transaction/txExplanation.ts new file mode 100644 index 0000000000..abf805f6ef --- /dev/null +++ b/modules/abstract-utxo/src/transaction/txExplanation.ts @@ -0,0 +1,31 @@ +import { AbstractUtxoCoin, TransactionPrebuild } from '../abstractUtxoCoin'; + +/** + * Generate a stringified transaction explanation for error reporting + * @param coin - The UTXO coin instance + * @param txPrebuild - Transaction prebuild containing txHex and txInfo + * @returns Stringified JSON explanation + */ +export async function getTxExplanation( + coin: AbstractUtxoCoin, + txPrebuild: TransactionPrebuild +): Promise { + if (!txPrebuild.txHex) { + return undefined; + } + + try { + const explanation = await coin.explainTransaction({ + txHex: txPrebuild.txHex, + txInfo: txPrebuild.txInfo, + }); + return JSON.stringify(explanation, null, 2); + } catch (e) { + const errorDetails = { + error: 'Failed to parse transaction explanation', + txHex: txPrebuild.txHex, + details: e instanceof Error ? e.message : String(e), + }; + return JSON.stringify(errorDetails, null, 2); + } +} diff --git a/modules/sdk-core/src/bitgo/errors.ts b/modules/sdk-core/src/bitgo/errors.ts index 08c87c7aab..33b4ef6a54 100644 --- a/modules/sdk-core/src/bitgo/errors.ts +++ b/modules/sdk-core/src/bitgo/errors.ts @@ -256,11 +256,13 @@ export interface ContractDataPayload { * @property {string | IRequestTracer | undefined} id - Transaction ID or request tracer for tracking * @property {TransactionParams[]} txParams - Array of transaction parameters that were analyzed * @property {string | undefined} txHex - The raw transaction in hexadecimal format + * @property {string | undefined} txExplanation - Stringified transaction explanation */ export class TxIntentMismatchError extends BitGoJsError { public readonly id: string | IRequestTracer | undefined; public readonly txParams: TransactionParams[]; public readonly txHex: string | undefined; + public readonly txExplanation: string | undefined; /** * Creates an instance of TxIntentMismatchError @@ -269,17 +271,20 @@ export class TxIntentMismatchError extends BitGoJsError { * @param {string | IRequestTracer | undefined} id - Transaction ID or request tracer * @param {TransactionParams[]} txParams - Transaction parameters that were analyzed * @param {string | undefined} txHex - Raw transaction hex string + * @param {string | undefined} txExplanation - Stringified transaction explanation */ public constructor( message: string, id: string | IRequestTracer | undefined, txParams: TransactionParams[], - txHex: string | undefined + txHex: string | undefined, + txExplanation?: string ) { super(message); this.id = id; this.txParams = txParams; this.txHex = txHex; + this.txExplanation = txExplanation; } } @@ -304,15 +309,17 @@ export class TxIntentMismatchRecipientError extends TxIntentMismatchError { * @param {TransactionParams[]} txParams - Transaction parameters that were analyzed * @param {string | undefined} txHex - Raw transaction hex string * @param {MismatchedRecipient[]} mismatchedRecipients - Array of recipients that don't match user intent + * @param {string | undefined} txExplanation - Stringified transaction explanation */ public constructor( message: string, id: string | IRequestTracer | undefined, txParams: TransactionParams[], txHex: string | undefined, - mismatchedRecipients: MismatchedRecipient[] + mismatchedRecipients: MismatchedRecipient[], + txExplanation?: string ) { - super(message, id, txParams, txHex); + super(message, id, txParams, txHex, txExplanation); this.mismatchedRecipients = mismatchedRecipients; } } @@ -338,15 +345,17 @@ export class TxIntentMismatchContractError extends TxIntentMismatchError { * @param {TransactionParams[]} txParams - Transaction parameters that were analyzed * @param {string | undefined} txHex - Raw transaction hex string * @param {ContractDataPayload} mismatchedDataPayload - The contract interaction data that doesn't match user intent + * @param {string | undefined} txExplanation - Stringified transaction explanation */ public constructor( message: string, id: string | IRequestTracer | undefined, txParams: TransactionParams[], txHex: string | undefined, - mismatchedDataPayload: ContractDataPayload + mismatchedDataPayload: ContractDataPayload, + txExplanation?: string ) { - super(message, id, txParams, txHex); + super(message, id, txParams, txHex, txExplanation); this.mismatchedDataPayload = mismatchedDataPayload; } } @@ -372,15 +381,17 @@ export class TxIntentMismatchApprovalError extends TxIntentMismatchError { * @param {TransactionParams[]} txParams - Transaction parameters that were analyzed * @param {string | undefined} txHex - Raw transaction hex string * @param {TokenApproval} tokenApproval - Details of the token approval that doesn't match user intent + * @param {string | undefined} txExplanation - Stringified transaction explanation */ public constructor( message: string, id: string | IRequestTracer | undefined, txParams: TransactionParams[], txHex: string | undefined, - tokenApproval: TokenApproval + tokenApproval: TokenApproval, + txExplanation?: string ) { - super(message, id, txParams, txHex); + super(message, id, txParams, txHex, txExplanation); this.tokenApproval = tokenApproval; } } diff --git a/modules/sdk-core/test/unit/bitgo/errors.ts b/modules/sdk-core/test/unit/bitgo/errors.ts index bf57684f50..b67f0cab59 100644 --- a/modules/sdk-core/test/unit/bitgo/errors.ts +++ b/modules/sdk-core/test/unit/bitgo/errors.ts @@ -8,12 +8,13 @@ import { ContractDataPayload, TokenApproval, } from '../../../src/bitgo/errors'; +import { TransactionParams } from '../../../src/bitgo/baseCoin'; describe('Transaction Intent Mismatch Errors', () => { const mockTransactionId = '0x1234567890abcdef'; - const mockTxParams: any[] = [ - { address: '0xrecipient1', amount: '1000000000000000000' }, - { address: '0xrecipient2', amount: '2000000000000000000' }, + const mockTxParams: TransactionParams[] = [ + { recipients: [{ address: '0xrecipient1', amount: '1000000000000000000' }] }, + { recipients: [{ address: '0xrecipient2', amount: '2000000000000000000' }] }, ]; const mockTxHex = '0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef'; @@ -28,6 +29,21 @@ describe('Transaction Intent Mismatch Errors', () => { should.equal(error.id, mockTransactionId); should.deepEqual(error.txParams, mockTxParams); should.equal(error.txHex, mockTxHex); + should.equal(error.txExplanation, undefined); // txExplanation is optional + }); + + it('should create error with txExplanation when provided', () => { + const message = 'Transaction does not match user intent'; + const txExplanation = JSON.stringify({ + id: '0xtxid', + outputAmount: '1000000', + outputs: [{ address: '0xrecipient', amount: '1000000' }], + fee: { fee: '21000' }, + }); + const error = new TxIntentMismatchError(message, mockTransactionId, mockTxParams, mockTxHex, txExplanation); + + should.exist(error); + should.equal(error.txExplanation, txExplanation); }); it('should be an instance of Error', () => { @@ -60,6 +76,28 @@ describe('Transaction Intent Mismatch Errors', () => { should.deepEqual(error.txParams, mockTxParams); should.equal(error.txHex, mockTxHex); should.deepEqual(error.mismatchedRecipients, mismatchedRecipients); + should.equal(error.txExplanation, undefined); // txExplanation is optional + }); + + it('should create recipient error with txExplanation when provided', () => { + const message = 'Transaction recipients do not match user intent'; + const mismatchedRecipients: MismatchedRecipient[] = [{ address: '0xexpected1', amount: '1000' }]; + const txExplanation = JSON.stringify({ + id: '0xtxid', + outputs: [{ address: '0xactual', amount: '1000' }], + }); + + const error = new TxIntentMismatchRecipientError( + message, + mockTransactionId, + mockTxParams, + mockTxHex, + mismatchedRecipients, + txExplanation + ); + + should.equal(error.txExplanation, txExplanation); + should.deepEqual(error.mismatchedRecipients, mismatchedRecipients); }); it('should be an instance of TxIntentMismatchError', () => { @@ -94,6 +132,33 @@ describe('Transaction Intent Mismatch Errors', () => { should.deepEqual(error.txParams, mockTxParams); should.equal(error.txHex, mockTxHex); should.deepEqual(error.mismatchedDataPayload, mismatchedDataPayload); + should.equal(error.txExplanation, undefined); // txExplanation is optional + }); + + it('should create contract error with txExplanation when provided', () => { + const message = 'Contract interaction does not match user intent'; + const mismatchedDataPayload: ContractDataPayload = { + address: '0xcontract123', + rawContractPayload: '0xabcdef', + decodedContractPayload: { method: 'approve', params: ['0xspender', 'unlimited'] }, + }; + const txExplanation = JSON.stringify({ + id: '0xtxid', + outputs: [{ address: '0xcontract123', amount: '0' }], + contractCall: { method: 'approve', params: ['0xspender', 'unlimited'] }, + }); + + const error = new TxIntentMismatchContractError( + message, + mockTransactionId, + mockTxParams, + mockTxHex, + mismatchedDataPayload, + txExplanation + ); + + should.equal(error.txExplanation, txExplanation); + should.deepEqual(error.mismatchedDataPayload, mismatchedDataPayload); }); it('should be an instance of TxIntentMismatchError', () => { @@ -133,6 +198,38 @@ describe('Transaction Intent Mismatch Errors', () => { should.deepEqual(error.txParams, mockTxParams); should.equal(error.txHex, mockTxHex); should.deepEqual(error.tokenApproval, tokenApproval); + should.equal(error.txExplanation, undefined); // txExplanation is optional + }); + + it('should create approval error with txExplanation when provided', () => { + const message = 'Token approval does not match user intent'; + const tokenApproval: TokenApproval = { + tokenName: 'USDC', + tokenAddress: '0xusdc', + authorizingAmount: { type: 'unlimited' }, + authorizingAddress: '0xmalicious', + }; + const txExplanation = JSON.stringify({ + id: '0xtxid', + outputs: [{ address: '0xusdc', amount: '0' }], + tokenApproval: { + token: 'USDC', + spender: '0xmalicious', + amount: 'unlimited', + }, + }); + + const error = new TxIntentMismatchApprovalError( + message, + mockTransactionId, + mockTxParams, + mockTxHex, + tokenApproval, + txExplanation + ); + + should.equal(error.txExplanation, txExplanation); + should.deepEqual(error.tokenApproval, tokenApproval); }); it('should be an instance of TxIntentMismatchError', () => { @@ -196,4 +293,65 @@ describe('Transaction Intent Mismatch Errors', () => { should(error.stack).containEql('TxIntentMismatchError'); }); }); + + describe('Transaction explanation property', () => { + it('should handle valid JSON transaction explanations', () => { + const txExplanation = JSON.stringify( + { + id: '0x123abc', + outputAmount: '1000000000000000000', + outputs: [ + { address: '0xrecipient1', amount: '500000000000000000' }, + { address: '0xrecipient2', amount: '500000000000000000' }, + ], + fee: { fee: '21000', gasLimit: '21000' }, + type: 'send', + }, + null, + 2 + ); + + const error = new TxIntentMismatchError('Test', mockTransactionId, mockTxParams, mockTxHex, txExplanation); + + should.equal(error.txExplanation, txExplanation); + // Verify it can be parsed back + const parsed = JSON.parse(error.txExplanation!); + should.equal(parsed.id, '0x123abc'); + should.equal(parsed.outputs.length, 2); + }); + + it('should handle empty transaction explanation', () => { + const error = new TxIntentMismatchError('Test', mockTransactionId, mockTxParams, mockTxHex, ''); + + should.equal(error.txExplanation, ''); + }); + + it('should work with all error subclasses', () => { + const txExplanation = JSON.stringify({ id: '0x123', outputs: [] }); + + const errors = [ + new TxIntentMismatchRecipientError('Test', mockTransactionId, mockTxParams, mockTxHex, [], txExplanation), + new TxIntentMismatchContractError( + 'Test', + mockTransactionId, + mockTxParams, + mockTxHex, + { address: '0x', rawContractPayload: '0x', decodedContractPayload: {} }, + txExplanation + ), + new TxIntentMismatchApprovalError( + 'Test', + mockTransactionId, + mockTxParams, + mockTxHex, + { tokenAddress: '0x', authorizingAmount: { type: 'limited', amount: 0 }, authorizingAddress: '0x' }, + txExplanation + ), + ]; + + errors.forEach((error) => { + should.equal(error.txExplanation, txExplanation); + }); + }); + }); }); From b81a5e51b1b352ede048a75fc0189f877dc41ebc Mon Sep 17 00:00:00 2001 From: Daniel Peng Date: Fri, 28 Nov 2025 09:21:38 -0500 Subject: [PATCH 2/3] refactor: update txExplanation for TxIntentMismatchError Ticket: WP-6608 --- .../src/abstractEthLikeNewCoins.ts | 46 ++++---- modules/abstract-utxo/src/abstractUtxoCoin.ts | 10 +- .../descriptor/verifyTransaction.ts | 9 +- .../fixedScript/verifyTransaction.ts | 5 +- .../abstract-utxo/src/transaction/index.ts | 1 - .../src/transaction/txExplanation.ts | 31 ------ modules/sdk-core/src/bitgo/errors.ts | 57 ++++++++-- modules/sdk-core/test/unit/bitgo/errors.ts | 104 +++++++++++------- 8 files changed, 150 insertions(+), 113 deletions(-) delete mode 100644 modules/abstract-utxo/src/transaction/txExplanation.ts diff --git a/modules/abstract-eth/src/abstractEthLikeNewCoins.ts b/modules/abstract-eth/src/abstractEthLikeNewCoins.ts index a9ea367b3d..b1a392fd5a 100644 --- a/modules/abstract-eth/src/abstractEthLikeNewCoins.ts +++ b/modules/abstract-eth/src/abstractEthLikeNewCoins.ts @@ -3072,10 +3072,9 @@ export abstract class AbstractEthLikeNewCoins extends AbstractEthLikeCoin { async verifyTssTransaction(params: VerifyEthTransactionOptions): Promise { const { txParams, txPrebuild, wallet } = params; - const txExplanation = await this.getTxExplanation(txPrebuild); - // Helper to throw TxIntentMismatchRecipientError with recipient details - const throwRecipientMismatch = (message: string, mismatchedRecipients: Recipient[]): never => { + const throwRecipientMismatch = async (message: string, mismatchedRecipients: Recipient[]): Promise => { + const txExplanation = await this.getTxExplanation(txPrebuild); throw new TxIntentMismatchRecipientError( message, undefined, @@ -3114,12 +3113,13 @@ export abstract class AbstractEthLikeNewCoins extends AbstractEthLikeCoin { const txJson = tx.toJson(); if (txJson.data === '0x') { if (expectedAmount !== txJson.value) { - throwRecipientMismatch('the transaction amount in txPrebuild does not match the value given by client', [ - { address: txJson.to, amount: txJson.value }, - ]); + await 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()) { - throwRecipientMismatch('destination address does not match with the recipient address', [ + await throwRecipientMismatch('destination address does not match with the recipient address', [ { address: txJson.to, amount: txJson.value }, ]); } @@ -3149,13 +3149,14 @@ export abstract class AbstractEthLikeNewCoins extends AbstractEthLikeCoin { } if (expectedTokenAmount !== amount.toString()) { - throwRecipientMismatch('the transaction amount in txPrebuild does not match the value given by client', [ - { address: addHexPrefix(recipientAddress.toString()), amount: amount.toString() }, - ]); + await throwRecipientMismatch( + 'the transaction amount in txPrebuild does not match the value given by client', + [{ address: addHexPrefix(recipientAddress.toString()), amount: amount.toString() }] + ); } if (expectedRecipientAddress !== addHexPrefix(recipientAddress.toString()).toLowerCase()) { - throwRecipientMismatch('destination address does not match with the recipient address', [ + await throwRecipientMismatch('destination address does not match with the recipient address', [ { address: addHexPrefix(recipientAddress.toString()), amount: amount.toString() }, ]); } @@ -3185,10 +3186,9 @@ export abstract class AbstractEthLikeNewCoins extends AbstractEthLikeCoin { return this.verifyTssTransaction(params); } - const txExplanation = await this.getTxExplanation(txPrebuild); - // Helper to throw TxIntentMismatchRecipientError with recipient details - const throwRecipientMismatch = (message: string, mismatchedRecipients: Recipient[]): never => { + const throwRecipientMismatch = async (message: string, mismatchedRecipients: Recipient[]): Promise => { + const txExplanation = await this.getTxExplanation(txPrebuild); throw new TxIntentMismatchRecipientError( message, undefined, @@ -3226,7 +3226,7 @@ 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()) { - throwRecipientMismatch('recipient address of txPrebuild does not match hop address', [ + await throwRecipientMismatch('recipient address of txPrebuild does not match hop address', [ { address: txPrebuild.recipients[0].address, amount: txPrebuild.recipients[0].amount.toString() }, ]); } @@ -3246,9 +3246,10 @@ export abstract class AbstractEthLikeNewCoins extends AbstractEthLikeCoin { if (txParams.tokenName) { const expectedTotalAmount = new BigNumber(0); if (!expectedTotalAmount.isEqualTo(txPrebuild.recipients[0].amount)) { - throwRecipientMismatch('batch token transaction amount in txPrebuild should be zero for token transfers', [ - { address: txPrebuild.recipients[0].address, amount: txPrebuild.recipients[0].amount.toString() }, - ]); + await 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); @@ -3256,7 +3257,7 @@ export abstract class AbstractEthLikeNewCoins extends AbstractEthLikeCoin { expectedTotalAmount = expectedTotalAmount.plus(recipients[i].amount); } if (!expectedTotalAmount.isEqualTo(txPrebuild.recipients[0].amount)) { - throwRecipientMismatch( + await 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() }] ); @@ -3269,7 +3270,7 @@ export abstract class AbstractEthLikeNewCoins extends AbstractEthLikeCoin { !batcherContractAddress || batcherContractAddress.toLowerCase() !== txPrebuild.recipients[0].address.toLowerCase() ) { - throwRecipientMismatch('recipient address of txPrebuild does not match batcher address', [ + await throwRecipientMismatch('recipient address of txPrebuild does not match batcher address', [ { address: txPrebuild.recipients[0].address, amount: txPrebuild.recipients[0].amount.toString() }, ]); } @@ -3280,13 +3281,13 @@ export abstract class AbstractEthLikeNewCoins extends AbstractEthLikeCoin { } const expectedAmount = new BigNumber(recipients[0].amount); if (!expectedAmount.isEqualTo(txPrebuild.recipients[0].amount)) { - throwRecipientMismatch( + await 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) { - throwRecipientMismatch( + await 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() }] ); @@ -3294,6 +3295,7 @@ export abstract class AbstractEthLikeNewCoins extends AbstractEthLikeCoin { } // Check coin is correct for all transaction types if (!this.verifyCoin(txPrebuild)) { + const txExplanation = await this.getTxExplanation(txPrebuild); throw new TxIntentMismatchError( 'coin in txPrebuild did not match that in txParams supplied by client', undefined, diff --git a/modules/abstract-utxo/src/abstractUtxoCoin.ts b/modules/abstract-utxo/src/abstractUtxoCoin.ts index 0e414fd62b..d6741008dd 100644 --- a/modules/abstract-utxo/src/abstractUtxoCoin.ts +++ b/modules/abstract-utxo/src/abstractUtxoCoin.ts @@ -28,6 +28,7 @@ import { PresignTransactionOptions, RequestTracer, SignedTransaction, + TxIntentMismatchError, SignTransactionOptions as BaseSignTransactionOptions, SupplementGenerateWalletOptions, TransactionParams as BaseTransactionParams, @@ -61,7 +62,6 @@ import { assertValidTransactionRecipient, explainTx, fromExtendedAddressFormat, - getTxExplanation, isScriptRecipient, parseTransaction, verifyTransaction, @@ -145,7 +145,7 @@ function convertValidationErrorToTxIntentMismatch( reqId: string | IRequestTracer | undefined, txParams: BaseTransactionParams, txHex: string | undefined, - txExplanation?: string + txExplanation?: unknown ): TxIntentMismatchRecipientError { const mismatchedRecipients: MismatchedRecipient[] = []; @@ -619,12 +619,14 @@ export abstract class AbstractUtxoCoin extends BaseCoin { async verifyTransaction( params: VerifyTransactionOptions ): Promise { - const txExplanation = await getTxExplanation(this, params.txPrebuild); - try { return await verifyTransaction(this, this.bitgo, params); } catch (error) { if (error instanceof AggregateValidationError) { + const txExplanation = await TxIntentMismatchError.tryGetTxExplanation( + this as unknown as IBaseCoin, + params.txPrebuild + ); throw convertValidationErrorToTxIntentMismatch( error, params.reqId, diff --git a/modules/abstract-utxo/src/transaction/descriptor/verifyTransaction.ts b/modules/abstract-utxo/src/transaction/descriptor/verifyTransaction.ts index d02ee26deb..ca163570be 100644 --- a/modules/abstract-utxo/src/transaction/descriptor/verifyTransaction.ts +++ b/modules/abstract-utxo/src/transaction/descriptor/verifyTransaction.ts @@ -1,10 +1,9 @@ import * as utxolib from '@bitgo/utxo-lib'; -import { ITransactionRecipient, TxIntentMismatchError } from '@bitgo/sdk-core'; +import { ITransactionRecipient, TxIntentMismatchError, IBaseCoin } from '@bitgo/sdk-core'; import { DescriptorMap } from '@bitgo/utxo-core/descriptor'; import { AbstractUtxoCoin, VerifyTransactionOptions } from '../../abstractUtxoCoin'; import { BaseOutput, BaseParsedTransactionOutputs } from '../types'; -import { getTxExplanation } from '../txExplanation'; import { toBaseParsedTransactionOutputsFromPsbt } from './parse'; @@ -76,10 +75,12 @@ export async function verifyTransaction( params: VerifyTransactionOptions, descriptorMap: DescriptorMap ): Promise { - const txExplanation = await getTxExplanation(coin, params.txPrebuild); - const tx = coin.decodeTransactionFromPrebuild(params.txPrebuild); if (!(tx instanceof utxolib.bitgo.UtxoPsbt)) { + const txExplanation = await TxIntentMismatchError.tryGetTxExplanation( + coin as unknown as IBaseCoin, + params.txPrebuild + ); throw new TxIntentMismatchError( 'unexpected transaction type', params.reqId, diff --git a/modules/abstract-utxo/src/transaction/fixedScript/verifyTransaction.ts b/modules/abstract-utxo/src/transaction/fixedScript/verifyTransaction.ts index 2cc915a378..a99dfee178 100644 --- a/modules/abstract-utxo/src/transaction/fixedScript/verifyTransaction.ts +++ b/modules/abstract-utxo/src/transaction/fixedScript/verifyTransaction.ts @@ -1,14 +1,13 @@ import buildDebug from 'debug'; import _ from 'lodash'; import BigNumber from 'bignumber.js'; -import { BitGoBase, TxIntentMismatchError } from '@bitgo/sdk-core'; +import { BitGoBase, TxIntentMismatchError, IBaseCoin } from '@bitgo/sdk-core'; import * as utxolib from '@bitgo/utxo-lib'; import { AbstractUtxoCoin, VerifyTransactionOptions } from '../../abstractUtxoCoin'; import { Output, ParsedTransaction } from '../types'; import { verifyCustomChangeKeySignatures, verifyKeySignature, verifyUserPublicKey } from '../../verifyKey'; import { getPsbtTxInputs, getTxInputs } from '../fetchInputs'; -import { getTxExplanation } from '../txExplanation'; const debug = buildDebug('bitgo:abstract-utxo:verifyTransaction'); @@ -51,7 +50,7 @@ export async function verifyTransaction( ): Promise { const { txParams, txPrebuild, wallet, verification = {}, reqId } = params; - const txExplanation = await getTxExplanation(coin, txPrebuild); + const txExplanation = await TxIntentMismatchError.tryGetTxExplanation(coin as unknown as IBaseCoin, txPrebuild); // Helper to throw TxIntentMismatchError with consistent context const throwTxMismatch = (message: string): never => { diff --git a/modules/abstract-utxo/src/transaction/index.ts b/modules/abstract-utxo/src/transaction/index.ts index c143e99051..4bacd7522e 100644 --- a/modules/abstract-utxo/src/transaction/index.ts +++ b/modules/abstract-utxo/src/transaction/index.ts @@ -3,6 +3,5 @@ export * from './recipient'; export { explainTx } from './explainTransaction'; export { parseTransaction } from './parseTransaction'; export { verifyTransaction } from './verifyTransaction'; -export { getTxExplanation } from './txExplanation'; export * from './fetchInputs'; export * as bip322 from './bip322'; diff --git a/modules/abstract-utxo/src/transaction/txExplanation.ts b/modules/abstract-utxo/src/transaction/txExplanation.ts deleted file mode 100644 index abf805f6ef..0000000000 --- a/modules/abstract-utxo/src/transaction/txExplanation.ts +++ /dev/null @@ -1,31 +0,0 @@ -import { AbstractUtxoCoin, TransactionPrebuild } from '../abstractUtxoCoin'; - -/** - * Generate a stringified transaction explanation for error reporting - * @param coin - The UTXO coin instance - * @param txPrebuild - Transaction prebuild containing txHex and txInfo - * @returns Stringified JSON explanation - */ -export async function getTxExplanation( - coin: AbstractUtxoCoin, - txPrebuild: TransactionPrebuild -): Promise { - if (!txPrebuild.txHex) { - return undefined; - } - - try { - const explanation = await coin.explainTransaction({ - txHex: txPrebuild.txHex, - txInfo: txPrebuild.txInfo, - }); - return JSON.stringify(explanation, null, 2); - } catch (e) { - const errorDetails = { - error: 'Failed to parse transaction explanation', - txHex: txPrebuild.txHex, - details: e instanceof Error ? e.message : String(e), - }; - return JSON.stringify(errorDetails, null, 2); - } -} diff --git a/modules/sdk-core/src/bitgo/errors.ts b/modules/sdk-core/src/bitgo/errors.ts index 33b4ef6a54..79c1ab94f9 100644 --- a/modules/sdk-core/src/bitgo/errors.ts +++ b/modules/sdk-core/src/bitgo/errors.ts @@ -3,6 +3,7 @@ import { BitGoJsError } from '../bitgojsError'; import { IRequestTracer } from '../api/types'; import { TransactionParams } from './baseCoin'; +import { IBaseCoin } from './baseCoin/iBaseCoin'; import { SendManyOptions } from './wallet'; // re-export for backwards compat @@ -271,20 +272,58 @@ export class TxIntentMismatchError extends BitGoJsError { * @param {string | IRequestTracer | undefined} id - Transaction ID or request tracer * @param {TransactionParams[]} txParams - Transaction parameters that were analyzed * @param {string | undefined} txHex - Raw transaction hex string - * @param {string | undefined} txExplanation - Stringified transaction explanation + * @param {unknown} txExplanation - Transaction explanation */ public constructor( message: string, id: string | IRequestTracer | undefined, txParams: TransactionParams[], txHex: string | undefined, - txExplanation?: string + txExplanation?: unknown ) { super(message); this.id = id; this.txParams = txParams; this.txHex = txHex; - this.txExplanation = txExplanation; + this.txExplanation = txExplanation ? this.safeStringify(txExplanation) : undefined; + } + + /** + * Safely stringify a value with BigInt support + * @param value - Value to stringify + * @returns JSON string with BigInts converted to strings + */ + private safeStringify(value: unknown): string { + return JSON.stringify(value, (_, v) => (typeof v === 'bigint' ? v.toString() : v), 2); + } + + /** + * Try to get transaction explanation from a coin's explainTransaction method. + * + * @param coin - Coin instance implementing IBaseCoin + * @param txPrebuild - Transaction prebuild containing txHex and txInfo + * @returns Transaction explanation object or undefined + */ + static async tryGetTxExplanation( + coin: IBaseCoin, + txPrebuild: { txHex?: string; txInfo?: unknown } + ): Promise { + if (!txPrebuild.txHex) { + return undefined; + } + + try { + return await coin.explainTransaction({ + txHex: txPrebuild.txHex, + txInfo: txPrebuild.txInfo, + }); + } catch (e) { + return { + error: 'Failed to parse transaction explanation', + txHex: txPrebuild.txHex, + details: e instanceof Error ? e.message : String(e), + }; + } } } @@ -309,7 +348,7 @@ export class TxIntentMismatchRecipientError extends TxIntentMismatchError { * @param {TransactionParams[]} txParams - Transaction parameters that were analyzed * @param {string | undefined} txHex - Raw transaction hex string * @param {MismatchedRecipient[]} mismatchedRecipients - Array of recipients that don't match user intent - * @param {string | undefined} txExplanation - Stringified transaction explanation + * @param {unknown} txExplanation - Transaction explanation */ public constructor( message: string, @@ -317,7 +356,7 @@ export class TxIntentMismatchRecipientError extends TxIntentMismatchError { txParams: TransactionParams[], txHex: string | undefined, mismatchedRecipients: MismatchedRecipient[], - txExplanation?: string + txExplanation?: unknown ) { super(message, id, txParams, txHex, txExplanation); this.mismatchedRecipients = mismatchedRecipients; @@ -345,7 +384,7 @@ export class TxIntentMismatchContractError extends TxIntentMismatchError { * @param {TransactionParams[]} txParams - Transaction parameters that were analyzed * @param {string | undefined} txHex - Raw transaction hex string * @param {ContractDataPayload} mismatchedDataPayload - The contract interaction data that doesn't match user intent - * @param {string | undefined} txExplanation - Stringified transaction explanation + * @param {unknown} txExplanation - Transaction explanation */ public constructor( message: string, @@ -353,7 +392,7 @@ export class TxIntentMismatchContractError extends TxIntentMismatchError { txParams: TransactionParams[], txHex: string | undefined, mismatchedDataPayload: ContractDataPayload, - txExplanation?: string + txExplanation?: unknown ) { super(message, id, txParams, txHex, txExplanation); this.mismatchedDataPayload = mismatchedDataPayload; @@ -381,7 +420,7 @@ export class TxIntentMismatchApprovalError extends TxIntentMismatchError { * @param {TransactionParams[]} txParams - Transaction parameters that were analyzed * @param {string | undefined} txHex - Raw transaction hex string * @param {TokenApproval} tokenApproval - Details of the token approval that doesn't match user intent - * @param {string | undefined} txExplanation - Stringified transaction explanation + * @param {unknown} txExplanation - Transaction explanation */ public constructor( message: string, @@ -389,7 +428,7 @@ export class TxIntentMismatchApprovalError extends TxIntentMismatchError { txParams: TransactionParams[], txHex: string | undefined, tokenApproval: TokenApproval, - txExplanation?: string + txExplanation?: unknown ) { super(message, id, txParams, txHex, txExplanation); this.tokenApproval = tokenApproval; diff --git a/modules/sdk-core/test/unit/bitgo/errors.ts b/modules/sdk-core/test/unit/bitgo/errors.ts index b67f0cab59..3dbfb04ff1 100644 --- a/modules/sdk-core/test/unit/bitgo/errors.ts +++ b/modules/sdk-core/test/unit/bitgo/errors.ts @@ -34,16 +34,20 @@ describe('Transaction Intent Mismatch Errors', () => { it('should create error with txExplanation when provided', () => { const message = 'Transaction does not match user intent'; - const txExplanation = JSON.stringify({ + const txExplanationObj = { id: '0xtxid', outputAmount: '1000000', outputs: [{ address: '0xrecipient', amount: '1000000' }], fee: { fee: '21000' }, - }); - const error = new TxIntentMismatchError(message, mockTransactionId, mockTxParams, mockTxHex, txExplanation); + }; + const error = new TxIntentMismatchError(message, mockTransactionId, mockTxParams, mockTxHex, txExplanationObj); should.exist(error); - should.equal(error.txExplanation, txExplanation); + // Should be stringified + should.equal(typeof error.txExplanation, 'string'); + const parsed = JSON.parse(error.txExplanation!); + should.equal(parsed.id, '0xtxid'); + should.equal(parsed.outputAmount, '1000000'); }); it('should be an instance of Error', () => { @@ -82,10 +86,10 @@ describe('Transaction Intent Mismatch Errors', () => { it('should create recipient error with txExplanation when provided', () => { const message = 'Transaction recipients do not match user intent'; const mismatchedRecipients: MismatchedRecipient[] = [{ address: '0xexpected1', amount: '1000' }]; - const txExplanation = JSON.stringify({ + const txExplanationObj = { id: '0xtxid', outputs: [{ address: '0xactual', amount: '1000' }], - }); + }; const error = new TxIntentMismatchRecipientError( message, @@ -93,10 +97,12 @@ describe('Transaction Intent Mismatch Errors', () => { mockTxParams, mockTxHex, mismatchedRecipients, - txExplanation + txExplanationObj ); - should.equal(error.txExplanation, txExplanation); + should.equal(typeof error.txExplanation, 'string'); + const parsed = JSON.parse(error.txExplanation!); + should.equal(parsed.id, '0xtxid'); should.deepEqual(error.mismatchedRecipients, mismatchedRecipients); }); @@ -142,11 +148,11 @@ describe('Transaction Intent Mismatch Errors', () => { rawContractPayload: '0xabcdef', decodedContractPayload: { method: 'approve', params: ['0xspender', 'unlimited'] }, }; - const txExplanation = JSON.stringify({ + const txExplanationObj = { id: '0xtxid', outputs: [{ address: '0xcontract123', amount: '0' }], contractCall: { method: 'approve', params: ['0xspender', 'unlimited'] }, - }); + }; const error = new TxIntentMismatchContractError( message, @@ -154,10 +160,12 @@ describe('Transaction Intent Mismatch Errors', () => { mockTxParams, mockTxHex, mismatchedDataPayload, - txExplanation + txExplanationObj ); - should.equal(error.txExplanation, txExplanation); + should.equal(typeof error.txExplanation, 'string'); + const parsed = JSON.parse(error.txExplanation!); + should.equal(parsed.id, '0xtxid'); should.deepEqual(error.mismatchedDataPayload, mismatchedDataPayload); }); @@ -209,7 +217,7 @@ describe('Transaction Intent Mismatch Errors', () => { authorizingAmount: { type: 'unlimited' }, authorizingAddress: '0xmalicious', }; - const txExplanation = JSON.stringify({ + const txExplanationObj = { id: '0xtxid', outputs: [{ address: '0xusdc', amount: '0' }], tokenApproval: { @@ -217,7 +225,7 @@ describe('Transaction Intent Mismatch Errors', () => { spender: '0xmalicious', amount: 'unlimited', }, - }); + }; const error = new TxIntentMismatchApprovalError( message, @@ -225,10 +233,12 @@ describe('Transaction Intent Mismatch Errors', () => { mockTxParams, mockTxHex, tokenApproval, - txExplanation + txExplanationObj ); - should.equal(error.txExplanation, txExplanation); + should.equal(typeof error.txExplanation, 'string'); + const parsed = JSON.parse(error.txExplanation!); + should.equal(parsed.id, '0xtxid'); should.deepEqual(error.tokenApproval, tokenApproval); }); @@ -296,24 +306,20 @@ describe('Transaction Intent Mismatch Errors', () => { describe('Transaction explanation property', () => { it('should handle valid JSON transaction explanations', () => { - const txExplanation = JSON.stringify( - { - id: '0x123abc', - outputAmount: '1000000000000000000', - outputs: [ - { address: '0xrecipient1', amount: '500000000000000000' }, - { address: '0xrecipient2', amount: '500000000000000000' }, - ], - fee: { fee: '21000', gasLimit: '21000' }, - type: 'send', - }, - null, - 2 - ); + const txExplanationObj = { + id: '0x123abc', + outputAmount: '1000000000000000000', + outputs: [ + { address: '0xrecipient1', amount: '500000000000000000' }, + { address: '0xrecipient2', amount: '500000000000000000' }, + ], + fee: { fee: '21000', gasLimit: '21000' }, + type: 'send', + }; - const error = new TxIntentMismatchError('Test', mockTransactionId, mockTxParams, mockTxHex, txExplanation); + const error = new TxIntentMismatchError('Test', mockTransactionId, mockTxParams, mockTxHex, txExplanationObj); - should.equal(error.txExplanation, txExplanation); + should.equal(typeof error.txExplanation, 'string'); // Verify it can be parsed back const parsed = JSON.parse(error.txExplanation!); should.equal(parsed.id, '0x123abc'); @@ -321,23 +327,41 @@ describe('Transaction Intent Mismatch Errors', () => { }); it('should handle empty transaction explanation', () => { - const error = new TxIntentMismatchError('Test', mockTransactionId, mockTxParams, mockTxHex, ''); + const error = new TxIntentMismatchError('Test', mockTransactionId, mockTxParams, mockTxHex, undefined); - should.equal(error.txExplanation, ''); + should.equal(error.txExplanation, undefined); + }); + + it('should handle BigInt values in transaction explanation', () => { + const txExplanationObj = { + id: '0x123', + outputAmount: BigInt('9007199254740992'), + changeAmount: BigInt('1234567890123456789'), + outputs: [{ address: 'addr1', amount: BigInt('9007199254740992') }], + }; + + const error = new TxIntentMismatchError('Test', mockTransactionId, mockTxParams, mockTxHex, txExplanationObj); + + should.equal(typeof error.txExplanation, 'string'); + const parsed = JSON.parse(error.txExplanation!); + // BigInts should be converted to strings + should.equal(parsed.outputAmount, '9007199254740992'); + should.equal(parsed.changeAmount, '1234567890123456789'); + should.equal(parsed.outputs[0].amount, '9007199254740992'); }); it('should work with all error subclasses', () => { - const txExplanation = JSON.stringify({ id: '0x123', outputs: [] }); + const txExplanationObj = { id: '0x123', outputs: [] }; const errors = [ - new TxIntentMismatchRecipientError('Test', mockTransactionId, mockTxParams, mockTxHex, [], txExplanation), + new TxIntentMismatchRecipientError('Test', mockTransactionId, mockTxParams, mockTxHex, [], txExplanationObj), new TxIntentMismatchContractError( 'Test', mockTransactionId, mockTxParams, mockTxHex, { address: '0x', rawContractPayload: '0x', decodedContractPayload: {} }, - txExplanation + txExplanationObj ), new TxIntentMismatchApprovalError( 'Test', @@ -345,12 +369,14 @@ describe('Transaction Intent Mismatch Errors', () => { mockTxParams, mockTxHex, { tokenAddress: '0x', authorizingAmount: { type: 'limited', amount: 0 }, authorizingAddress: '0x' }, - txExplanation + txExplanationObj ), ]; errors.forEach((error) => { - should.equal(error.txExplanation, txExplanation); + should.equal(typeof error.txExplanation, 'string'); + const parsed = JSON.parse(error.txExplanation!); + should.equal(parsed.id, '0x123'); }); }); }); From b62c789ec0e8a0e245d177584f0e0362aba046fb Mon Sep 17 00:00:00 2001 From: Daniel Peng Date: Fri, 28 Nov 2025 09:41:54 -0500 Subject: [PATCH 3/3] chore: make safeStringify static Ticket: WP-6608 --- modules/sdk-core/src/bitgo/errors.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/modules/sdk-core/src/bitgo/errors.ts b/modules/sdk-core/src/bitgo/errors.ts index 79c1ab94f9..b6d92b71ad 100644 --- a/modules/sdk-core/src/bitgo/errors.ts +++ b/modules/sdk-core/src/bitgo/errors.ts @@ -285,7 +285,7 @@ export class TxIntentMismatchError extends BitGoJsError { this.id = id; this.txParams = txParams; this.txHex = txHex; - this.txExplanation = txExplanation ? this.safeStringify(txExplanation) : undefined; + this.txExplanation = txExplanation ? TxIntentMismatchError.safeStringify(txExplanation) : undefined; } /** @@ -293,7 +293,7 @@ export class TxIntentMismatchError extends BitGoJsError { * @param value - Value to stringify * @returns JSON string with BigInts converted to strings */ - private safeStringify(value: unknown): string { + private static safeStringify(value: unknown): string { return JSON.stringify(value, (_, v) => (typeof v === 'bigint' ? v.toString() : v), 2); }