diff --git a/modules/abstract-eth/src/abstractEthLikeNewCoins.ts b/modules/abstract-eth/src/abstractEthLikeNewCoins.ts index 8d2bcbfcc4..b1a392fd5a 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 * @@ -3045,8 +3073,16 @@ export abstract class AbstractEthLikeNewCoins extends AbstractEthLikeCoin { const { txParams, txPrebuild, wallet } = params; // Helper to throw TxIntentMismatchRecipientError with recipient details - const throwRecipientMismatch = (message: string, mismatchedRecipients: Recipient[]): never => { - throw new TxIntentMismatchRecipientError(message, undefined, [txParams], txPrebuild?.txHex, mismatchedRecipients); + const throwRecipientMismatch = async (message: string, mismatchedRecipients: Recipient[]): Promise => { + const txExplanation = await this.getTxExplanation(txPrebuild); + throw new TxIntentMismatchRecipientError( + message, + undefined, + [txParams], + txPrebuild?.txHex, + mismatchedRecipients, + txExplanation + ); }; if ( @@ -3077,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 }, ]); } @@ -3112,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() }, ]); } @@ -3149,8 +3187,16 @@ export abstract class AbstractEthLikeNewCoins extends AbstractEthLikeCoin { } // Helper to throw TxIntentMismatchRecipientError with recipient details - const throwRecipientMismatch = (message: string, mismatchedRecipients: Recipient[]): never => { - throw new TxIntentMismatchRecipientError(message, undefined, [txParams], txPrebuild?.txHex, mismatchedRecipients); + const throwRecipientMismatch = async (message: string, mismatchedRecipients: Recipient[]): Promise => { + const txExplanation = await this.getTxExplanation(txPrebuild); + throw new TxIntentMismatchRecipientError( + message, + undefined, + [txParams], + txPrebuild?.txHex, + mismatchedRecipients, + txExplanation + ); }; if (!txParams?.recipients || !txPrebuild?.recipients || !wallet) { @@ -3180,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() }, ]); } @@ -3200,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); @@ -3210,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() }] ); @@ -3223,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() }, ]); } @@ -3234,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() }] ); @@ -3248,11 +3295,13 @@ 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, [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..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, @@ -143,7 +144,8 @@ function convertValidationErrorToTxIntentMismatch( error: AggregateValidationError, reqId: string | IRequestTracer | undefined, txParams: BaseTransactionParams, - txHex: string | undefined + txHex: string | undefined, + txExplanation?: unknown ): 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 @@ -620,7 +623,17 @@ export abstract class AbstractUtxoCoin extends BaseCoin { return await verifyTransaction(this, this.bitgo, params); } catch (error) { if (error instanceof AggregateValidationError) { - throw convertValidationErrorToTxIntentMismatch(error, params.reqId, params.txParams, params.txPrebuild.txHex); + const txExplanation = await TxIntentMismatchError.tryGetTxExplanation( + this as unknown as IBaseCoin, + params.txPrebuild + ); + 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..ca163570be 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, 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'; @@ -77,11 +77,16 @@ export async function verifyTransaction( ): Promise { 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, [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..a99dfee178 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, 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'; @@ -50,9 +50,11 @@ export async function verifyTransaction( ): Promise { const { txParams, txPrebuild, wallet, verification = {}, reqId } = params; + const txExplanation = await TxIntentMismatchError.tryGetTxExplanation(coin as unknown as IBaseCoin, 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/sdk-core/src/bitgo/errors.ts b/modules/sdk-core/src/bitgo/errors.ts index 08c87c7aab..b6d92b71ad 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 @@ -256,11 +257,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 +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 {unknown} txExplanation - Transaction explanation */ public constructor( message: string, id: string | IRequestTracer | undefined, txParams: TransactionParams[], - txHex: string | undefined + txHex: string | undefined, + txExplanation?: unknown ) { super(message); this.id = id; this.txParams = txParams; this.txHex = txHex; + this.txExplanation = txExplanation ? TxIntentMismatchError.safeStringify(txExplanation) : undefined; + } + + /** + * Safely stringify a value with BigInt support + * @param value - Value to stringify + * @returns JSON string with BigInts converted to strings + */ + private static 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), + }; + } } } @@ -304,15 +348,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 {unknown} txExplanation - Transaction explanation */ public constructor( message: string, id: string | IRequestTracer | undefined, txParams: TransactionParams[], txHex: string | undefined, - mismatchedRecipients: MismatchedRecipient[] + mismatchedRecipients: MismatchedRecipient[], + txExplanation?: unknown ) { - super(message, id, txParams, txHex); + super(message, id, txParams, txHex, txExplanation); this.mismatchedRecipients = mismatchedRecipients; } } @@ -338,15 +384,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 {unknown} txExplanation - Transaction explanation */ public constructor( message: string, id: string | IRequestTracer | undefined, txParams: TransactionParams[], txHex: string | undefined, - mismatchedDataPayload: ContractDataPayload + mismatchedDataPayload: ContractDataPayload, + txExplanation?: unknown ) { - super(message, id, txParams, txHex); + super(message, id, txParams, txHex, txExplanation); this.mismatchedDataPayload = mismatchedDataPayload; } } @@ -372,15 +420,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 {unknown} txExplanation - Transaction explanation */ public constructor( message: string, id: string | IRequestTracer | undefined, txParams: TransactionParams[], txHex: string | undefined, - tokenApproval: TokenApproval + tokenApproval: TokenApproval, + txExplanation?: unknown ) { - 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..3dbfb04ff1 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,25 @@ 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 txExplanationObj = { + id: '0xtxid', + outputAmount: '1000000', + outputs: [{ address: '0xrecipient', amount: '1000000' }], + fee: { fee: '21000' }, + }; + const error = new TxIntentMismatchError(message, mockTransactionId, mockTxParams, mockTxHex, txExplanationObj); + + should.exist(error); + // 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', () => { @@ -60,6 +80,30 @@ 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 txExplanationObj = { + id: '0xtxid', + outputs: [{ address: '0xactual', amount: '1000' }], + }; + + const error = new TxIntentMismatchRecipientError( + message, + mockTransactionId, + mockTxParams, + mockTxHex, + mismatchedRecipients, + txExplanationObj + ); + + should.equal(typeof error.txExplanation, 'string'); + const parsed = JSON.parse(error.txExplanation!); + should.equal(parsed.id, '0xtxid'); + should.deepEqual(error.mismatchedRecipients, mismatchedRecipients); }); it('should be an instance of TxIntentMismatchError', () => { @@ -94,6 +138,35 @@ 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 txExplanationObj = { + id: '0xtxid', + outputs: [{ address: '0xcontract123', amount: '0' }], + contractCall: { method: 'approve', params: ['0xspender', 'unlimited'] }, + }; + + const error = new TxIntentMismatchContractError( + message, + mockTransactionId, + mockTxParams, + mockTxHex, + mismatchedDataPayload, + txExplanationObj + ); + + should.equal(typeof error.txExplanation, 'string'); + const parsed = JSON.parse(error.txExplanation!); + should.equal(parsed.id, '0xtxid'); + should.deepEqual(error.mismatchedDataPayload, mismatchedDataPayload); }); it('should be an instance of TxIntentMismatchError', () => { @@ -133,6 +206,40 @@ 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 txExplanationObj = { + id: '0xtxid', + outputs: [{ address: '0xusdc', amount: '0' }], + tokenApproval: { + token: 'USDC', + spender: '0xmalicious', + amount: 'unlimited', + }, + }; + + const error = new TxIntentMismatchApprovalError( + message, + mockTransactionId, + mockTxParams, + mockTxHex, + tokenApproval, + txExplanationObj + ); + + should.equal(typeof error.txExplanation, 'string'); + const parsed = JSON.parse(error.txExplanation!); + should.equal(parsed.id, '0xtxid'); + should.deepEqual(error.tokenApproval, tokenApproval); }); it('should be an instance of TxIntentMismatchError', () => { @@ -196,4 +303,81 @@ describe('Transaction Intent Mismatch Errors', () => { should(error.stack).containEql('TxIntentMismatchError'); }); }); + + describe('Transaction explanation property', () => { + it('should handle valid JSON transaction explanations', () => { + 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, txExplanationObj); + + should.equal(typeof error.txExplanation, 'string'); + // 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, undefined); + + 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 txExplanationObj = { id: '0x123', outputs: [] }; + + const errors = [ + new TxIntentMismatchRecipientError('Test', mockTransactionId, mockTxParams, mockTxHex, [], txExplanationObj), + new TxIntentMismatchContractError( + 'Test', + mockTransactionId, + mockTxParams, + mockTxHex, + { address: '0x', rawContractPayload: '0x', decodedContractPayload: {} }, + txExplanationObj + ), + new TxIntentMismatchApprovalError( + 'Test', + mockTransactionId, + mockTxParams, + mockTxHex, + { tokenAddress: '0x', authorizingAmount: { type: 'limited', amount: 0 }, authorizingAddress: '0x' }, + txExplanationObj + ), + ]; + + errors.forEach((error) => { + should.equal(typeof error.txExplanation, 'string'); + const parsed = JSON.parse(error.txExplanation!); + should.equal(parsed.id, '0x123'); + }); + }); + }); });