Skip to content

Commit 391f989

Browse files
authored
Merge pull request #7580 from BitGo/WP-6608/tx-intent-mismatch-explanation
feat: add tx explanation for TxIntentMismatch errors
2 parents eadb629 + b62c789 commit 391f989

File tree

6 files changed

+341
-38
lines changed

6 files changed

+341
-38
lines changed

modules/abstract-eth/src/abstractEthLikeNewCoins.ts

Lines changed: 70 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -3031,6 +3031,34 @@ export abstract class AbstractEthLikeNewCoins extends AbstractEthLikeCoin {
30313031
return txPrebuild.coin === nativeCoin;
30323032
}
30333033

3034+
/**
3035+
* Generate transaction explanation for error reporting
3036+
* @param txPrebuild - Transaction prebuild containing txHex and fee info
3037+
* @returns Stringified JSON explanation
3038+
*/
3039+
private async getTxExplanation(txPrebuild?: TransactionPrebuild): Promise<string | undefined> {
3040+
if (!txPrebuild?.txHex || !txPrebuild?.gasPrice) {
3041+
return undefined;
3042+
}
3043+
3044+
try {
3045+
const explanation = await this.explainTransaction({
3046+
txHex: txPrebuild.txHex,
3047+
feeInfo: {
3048+
fee: txPrebuild.gasPrice.toString(),
3049+
},
3050+
});
3051+
return JSON.stringify(explanation, null, 2);
3052+
} catch (e) {
3053+
const errorDetails = {
3054+
error: 'Failed to parse transaction explanation',
3055+
txHex: txPrebuild.txHex,
3056+
details: e instanceof Error ? e.message : String(e),
3057+
};
3058+
return JSON.stringify(errorDetails, null, 2);
3059+
}
3060+
}
3061+
30343062
/**
30353063
* Verify if a tss transaction is valid
30363064
*
@@ -3045,8 +3073,16 @@ export abstract class AbstractEthLikeNewCoins extends AbstractEthLikeCoin {
30453073
const { txParams, txPrebuild, wallet } = params;
30463074

30473075
// Helper to throw TxIntentMismatchRecipientError with recipient details
3048-
const throwRecipientMismatch = (message: string, mismatchedRecipients: Recipient[]): never => {
3049-
throw new TxIntentMismatchRecipientError(message, undefined, [txParams], txPrebuild?.txHex, mismatchedRecipients);
3076+
const throwRecipientMismatch = async (message: string, mismatchedRecipients: Recipient[]): Promise<never> => {
3077+
const txExplanation = await this.getTxExplanation(txPrebuild);
3078+
throw new TxIntentMismatchRecipientError(
3079+
message,
3080+
undefined,
3081+
[txParams],
3082+
txPrebuild?.txHex,
3083+
mismatchedRecipients,
3084+
txExplanation
3085+
);
30503086
};
30513087

30523088
if (
@@ -3077,12 +3113,13 @@ export abstract class AbstractEthLikeNewCoins extends AbstractEthLikeCoin {
30773113
const txJson = tx.toJson();
30783114
if (txJson.data === '0x') {
30793115
if (expectedAmount !== txJson.value) {
3080-
throwRecipientMismatch('the transaction amount in txPrebuild does not match the value given by client', [
3081-
{ address: txJson.to, amount: txJson.value },
3082-
]);
3116+
await throwRecipientMismatch(
3117+
'the transaction amount in txPrebuild does not match the value given by client',
3118+
[{ address: txJson.to, amount: txJson.value }]
3119+
);
30833120
}
30843121
if (expectedDestination.toLowerCase() !== txJson.to.toLowerCase()) {
3085-
throwRecipientMismatch('destination address does not match with the recipient address', [
3122+
await throwRecipientMismatch('destination address does not match with the recipient address', [
30863123
{ address: txJson.to, amount: txJson.value },
30873124
]);
30883125
}
@@ -3112,13 +3149,14 @@ export abstract class AbstractEthLikeNewCoins extends AbstractEthLikeCoin {
31123149
}
31133150

31143151
if (expectedTokenAmount !== amount.toString()) {
3115-
throwRecipientMismatch('the transaction amount in txPrebuild does not match the value given by client', [
3116-
{ address: addHexPrefix(recipientAddress.toString()), amount: amount.toString() },
3117-
]);
3152+
await throwRecipientMismatch(
3153+
'the transaction amount in txPrebuild does not match the value given by client',
3154+
[{ address: addHexPrefix(recipientAddress.toString()), amount: amount.toString() }]
3155+
);
31183156
}
31193157

31203158
if (expectedRecipientAddress !== addHexPrefix(recipientAddress.toString()).toLowerCase()) {
3121-
throwRecipientMismatch('destination address does not match with the recipient address', [
3159+
await throwRecipientMismatch('destination address does not match with the recipient address', [
31223160
{ address: addHexPrefix(recipientAddress.toString()), amount: amount.toString() },
31233161
]);
31243162
}
@@ -3149,8 +3187,16 @@ export abstract class AbstractEthLikeNewCoins extends AbstractEthLikeCoin {
31493187
}
31503188

31513189
// Helper to throw TxIntentMismatchRecipientError with recipient details
3152-
const throwRecipientMismatch = (message: string, mismatchedRecipients: Recipient[]): never => {
3153-
throw new TxIntentMismatchRecipientError(message, undefined, [txParams], txPrebuild?.txHex, mismatchedRecipients);
3190+
const throwRecipientMismatch = async (message: string, mismatchedRecipients: Recipient[]): Promise<never> => {
3191+
const txExplanation = await this.getTxExplanation(txPrebuild);
3192+
throw new TxIntentMismatchRecipientError(
3193+
message,
3194+
undefined,
3195+
[txParams],
3196+
txPrebuild?.txHex,
3197+
mismatchedRecipients,
3198+
txExplanation
3199+
);
31543200
};
31553201

31563202
if (!txParams?.recipients || !txPrebuild?.recipients || !wallet) {
@@ -3180,7 +3226,7 @@ export abstract class AbstractEthLikeNewCoins extends AbstractEthLikeCoin {
31803226
const expectedHopAddress = optionalDeps.ethUtil.stripHexPrefix(decodedHopTx.getSenderAddress().toString());
31813227
const actualHopAddress = optionalDeps.ethUtil.stripHexPrefix(txPrebuild.recipients[0].address);
31823228
if (expectedHopAddress.toLowerCase() !== actualHopAddress.toLowerCase()) {
3183-
throwRecipientMismatch('recipient address of txPrebuild does not match hop address', [
3229+
await throwRecipientMismatch('recipient address of txPrebuild does not match hop address', [
31843230
{ address: txPrebuild.recipients[0].address, amount: txPrebuild.recipients[0].amount.toString() },
31853231
]);
31863232
}
@@ -3200,17 +3246,18 @@ export abstract class AbstractEthLikeNewCoins extends AbstractEthLikeCoin {
32003246
if (txParams.tokenName) {
32013247
const expectedTotalAmount = new BigNumber(0);
32023248
if (!expectedTotalAmount.isEqualTo(txPrebuild.recipients[0].amount)) {
3203-
throwRecipientMismatch('batch token transaction amount in txPrebuild should be zero for token transfers', [
3204-
{ address: txPrebuild.recipients[0].address, amount: txPrebuild.recipients[0].amount.toString() },
3205-
]);
3249+
await throwRecipientMismatch(
3250+
'batch token transaction amount in txPrebuild should be zero for token transfers',
3251+
[{ address: txPrebuild.recipients[0].address, amount: txPrebuild.recipients[0].amount.toString() }]
3252+
);
32063253
}
32073254
} else {
32083255
let expectedTotalAmount = new BigNumber(0);
32093256
for (let i = 0; i < recipients.length; i++) {
32103257
expectedTotalAmount = expectedTotalAmount.plus(recipients[i].amount);
32113258
}
32123259
if (!expectedTotalAmount.isEqualTo(txPrebuild.recipients[0].amount)) {
3213-
throwRecipientMismatch(
3260+
await throwRecipientMismatch(
32143261
'batch transaction amount in txPrebuild received from BitGo servers does not match txParams supplied by client',
32153262
[{ address: txPrebuild.recipients[0].address, amount: txPrebuild.recipients[0].amount.toString() }]
32163263
);
@@ -3223,7 +3270,7 @@ export abstract class AbstractEthLikeNewCoins extends AbstractEthLikeCoin {
32233270
!batcherContractAddress ||
32243271
batcherContractAddress.toLowerCase() !== txPrebuild.recipients[0].address.toLowerCase()
32253272
) {
3226-
throwRecipientMismatch('recipient address of txPrebuild does not match batcher address', [
3273+
await throwRecipientMismatch('recipient address of txPrebuild does not match batcher address', [
32273274
{ address: txPrebuild.recipients[0].address, amount: txPrebuild.recipients[0].amount.toString() },
32283275
]);
32293276
}
@@ -3234,25 +3281,27 @@ export abstract class AbstractEthLikeNewCoins extends AbstractEthLikeCoin {
32343281
}
32353282
const expectedAmount = new BigNumber(recipients[0].amount);
32363283
if (!expectedAmount.isEqualTo(txPrebuild.recipients[0].amount)) {
3237-
throwRecipientMismatch(
3284+
await throwRecipientMismatch(
32383285
'normal transaction amount in txPrebuild received from BitGo servers does not match txParams supplied by client',
32393286
[{ address: txPrebuild.recipients[0].address, amount: txPrebuild.recipients[0].amount.toString() }]
32403287
);
32413288
}
32423289
if (this.isETHAddress(recipients[0].address) && recipients[0].address !== txPrebuild.recipients[0].address) {
3243-
throwRecipientMismatch(
3290+
await throwRecipientMismatch(
32443291
'destination address in normal txPrebuild does not match that in txParams supplied by client',
32453292
[{ address: txPrebuild.recipients[0].address, amount: txPrebuild.recipients[0].amount.toString() }]
32463293
);
32473294
}
32483295
}
32493296
// Check coin is correct for all transaction types
32503297
if (!this.verifyCoin(txPrebuild)) {
3298+
const txExplanation = await this.getTxExplanation(txPrebuild);
32513299
throw new TxIntentMismatchError(
32523300
'coin in txPrebuild did not match that in txParams supplied by client',
32533301
undefined,
32543302
[txParams],
3255-
txPrebuild?.txHex
3303+
txPrebuild?.txHex,
3304+
txExplanation
32563305
);
32573306
}
32583307
return true;

modules/abstract-utxo/src/abstractUtxoCoin.ts

Lines changed: 16 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ import {
2828
PresignTransactionOptions,
2929
RequestTracer,
3030
SignedTransaction,
31+
TxIntentMismatchError,
3132
SignTransactionOptions as BaseSignTransactionOptions,
3233
SupplementGenerateWalletOptions,
3334
TransactionParams as BaseTransactionParams,
@@ -144,7 +145,8 @@ function convertValidationErrorToTxIntentMismatch(
144145
error: AggregateValidationError,
145146
reqId: string | IRequestTracer | undefined,
146147
txParams: BaseTransactionParams,
147-
txHex: string | undefined
148+
txHex: string | undefined,
149+
txExplanation?: unknown
148150
): TxIntentMismatchRecipientError {
149151
const mismatchedRecipients: MismatchedRecipient[] = [];
150152

@@ -171,7 +173,8 @@ function convertValidationErrorToTxIntentMismatch(
171173
reqId,
172174
[txParams],
173175
txHex,
174-
mismatchedRecipients
176+
mismatchedRecipients,
177+
txExplanation
175178
);
176179
// Preserve the original structured error as the cause for debugging
177180
// See: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Error/cause
@@ -627,7 +630,17 @@ export abstract class AbstractUtxoCoin extends BaseCoin {
627630
return await verifyTransaction(this, this.bitgo, params);
628631
} catch (error) {
629632
if (error instanceof AggregateValidationError) {
630-
throw convertValidationErrorToTxIntentMismatch(error, params.reqId, params.txParams, params.txPrebuild.txHex);
633+
const txExplanation = await TxIntentMismatchError.tryGetTxExplanation(
634+
this as unknown as IBaseCoin,
635+
params.txPrebuild
636+
);
637+
throw convertValidationErrorToTxIntentMismatch(
638+
error,
639+
params.reqId,
640+
params.txParams,
641+
params.txPrebuild.txHex,
642+
txExplanation
643+
);
631644
}
632645
throw error;
633646
}

modules/abstract-utxo/src/transaction/descriptor/verifyTransaction.ts

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import * as utxolib from '@bitgo/utxo-lib';
2-
import { ITransactionRecipient, TxIntentMismatchError } from '@bitgo/sdk-core';
2+
import { ITransactionRecipient, TxIntentMismatchError, IBaseCoin } from '@bitgo/sdk-core';
33
import { DescriptorMap } from '@bitgo/utxo-core/descriptor';
44

55
import { AbstractUtxoCoin, VerifyTransactionOptions } from '../../abstractUtxoCoin';
@@ -77,11 +77,16 @@ export async function verifyTransaction<TNumber extends number | bigint>(
7777
): Promise<boolean> {
7878
const tx = coin.decodeTransactionFromPrebuild(params.txPrebuild);
7979
if (!(tx instanceof utxolib.bitgo.UtxoPsbt)) {
80+
const txExplanation = await TxIntentMismatchError.tryGetTxExplanation(
81+
coin as unknown as IBaseCoin,
82+
params.txPrebuild
83+
);
8084
throw new TxIntentMismatchError(
8185
'unexpected transaction type',
8286
params.reqId,
8387
[params.txParams],
84-
params.txPrebuild.txHex
88+
params.txPrebuild.txHex,
89+
txExplanation
8590
);
8691
}
8792

modules/abstract-utxo/src/transaction/fixedScript/verifyTransaction.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import buildDebug from 'debug';
22
import _ from 'lodash';
33
import BigNumber from 'bignumber.js';
4-
import { BitGoBase, TxIntentMismatchError } from '@bitgo/sdk-core';
4+
import { BitGoBase, TxIntentMismatchError, IBaseCoin } from '@bitgo/sdk-core';
55
import * as utxolib from '@bitgo/utxo-lib';
66

77
import { AbstractUtxoCoin, VerifyTransactionOptions } from '../../abstractUtxoCoin';
@@ -50,9 +50,11 @@ export async function verifyTransaction<TNumber extends bigint | number>(
5050
): Promise<boolean> {
5151
const { txParams, txPrebuild, wallet, verification = {}, reqId } = params;
5252

53+
const txExplanation = await TxIntentMismatchError.tryGetTxExplanation(coin as unknown as IBaseCoin, txPrebuild);
54+
5355
// Helper to throw TxIntentMismatchError with consistent context
5456
const throwTxMismatch = (message: string): never => {
55-
throw new TxIntentMismatchError(message, reqId, [txParams], txPrebuild.txHex);
57+
throw new TxIntentMismatchError(message, reqId, [txParams], txPrebuild.txHex, txExplanation);
5658
};
5759

5860
if (!_.isUndefined(verification.disableNetworking) && !_.isBoolean(verification.disableNetworking)) {

0 commit comments

Comments
 (0)