Skip to content

Commit 1871849

Browse files
authored
Merge pull request #7236 from BitGo/WP-6189/introduce-suspicious-transaction-errors
feat: Replace Generic Errors with Suspicious Transaction Errors for UTXO/EthLike Coins
2 parents 9b564dc + a3513a3 commit 1871849

File tree

5 files changed

+207
-64
lines changed

5 files changed

+207
-64
lines changed

modules/abstract-eth/src/abstractEthLikeNewCoins.ts

Lines changed: 73 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,8 @@ import {
2828
PresignTransactionOptions as BasePresignTransactionOptions,
2929
Recipient,
3030
SignTransactionOptions as BaseSignTransactionOptions,
31+
TxIntentMismatchError,
32+
TxIntentMismatchRecipientError,
3133
TransactionParams,
3234
TransactionPrebuild as BaseTransactionPrebuild,
3335
TransactionRecipient,
@@ -2799,23 +2801,30 @@ export abstract class AbstractEthLikeNewCoins extends AbstractEthLikeCoin {
27992801
* @param {TransactionPrebuild} params.txPrebuild - prebuild object returned by server
28002802
* @param {Wallet} params.wallet - Wallet object to obtain keys to verify against
28012803
* @returns {boolean}
2804+
* @throws {TxIntentMismatchRecipientError} if transaction recipients don't match user intent
28022805
*/
28032806
async verifyTssTransaction(params: VerifyEthTransactionOptions): Promise<boolean> {
28042807
const { txParams, txPrebuild, wallet } = params;
2808+
2809+
// Helper to throw TxIntentMismatchRecipientError with recipient details
2810+
const throwRecipientMismatch = (message: string, mismatchedRecipients: Recipient[]): never => {
2811+
throw new TxIntentMismatchRecipientError(message, undefined, [txParams], txPrebuild?.txHex, mismatchedRecipients);
2812+
};
2813+
28052814
if (
28062815
!txParams?.recipients &&
28072816
!(
28082817
txParams.prebuildTx?.consolidateId ||
28092818
(txParams.type && ['acceleration', 'fillNonce', 'transferToken', 'tokenApproval'].includes(txParams.type))
28102819
)
28112820
) {
2812-
throw new Error(`missing txParams`);
2821+
throw new Error('missing txParams');
28132822
}
28142823
if (!wallet || !txPrebuild) {
2815-
throw new Error(`missing params`);
2824+
throw new Error('missing params');
28162825
}
28172826
if (txParams.hop && txParams.recipients && txParams.recipients.length > 1) {
2818-
throw new Error(`tx cannot be both a batch and hop transaction`);
2827+
throw new Error('tx cannot be both a batch and hop transaction');
28192828
}
28202829

28212830
if (txParams.type && ['transfer'].includes(txParams.type)) {
@@ -2830,21 +2839,29 @@ export abstract class AbstractEthLikeNewCoins extends AbstractEthLikeCoin {
28302839
const txJson = tx.toJson();
28312840
if (txJson.data === '0x') {
28322841
if (expectedAmount !== txJson.value) {
2833-
throw new Error('the transaction amount in txPrebuild does not match the value given by client');
2842+
throwRecipientMismatch('the transaction amount in txPrebuild does not match the value given by client', [
2843+
{ address: txJson.to, amount: txJson.value },
2844+
]);
28342845
}
28352846
if (expectedDestination.toLowerCase() !== txJson.to.toLowerCase()) {
2836-
throw new Error('destination address does not match with the recipient address');
2847+
throwRecipientMismatch('destination address does not match with the recipient address', [
2848+
{ address: txJson.to, amount: txJson.value },
2849+
]);
28372850
}
28382851
} else if (txJson.data.startsWith('0xa9059cbb')) {
28392852
const [recipientAddress, amount] = getRawDecoded(
28402853
['address', 'uint256'],
28412854
getBufferedByteCode('0xa9059cbb', txJson.data)
28422855
);
28432856
if (expectedAmount !== amount.toString()) {
2844-
throw new Error('the transaction amount in txPrebuild does not match the value given by client');
2857+
throwRecipientMismatch('the transaction amount in txPrebuild does not match the value given by client', [
2858+
{ address: addHexPrefix(recipientAddress.toString()), amount: amount.toString() },
2859+
]);
28452860
}
28462861
if (expectedDestination.toLowerCase() !== addHexPrefix(recipientAddress.toString()).toLowerCase()) {
2847-
throw new Error('destination address does not match with the recipient address');
2862+
throwRecipientMismatch('destination address does not match with the recipient address', [
2863+
{ address: addHexPrefix(recipientAddress.toString()), amount: amount.toString() },
2864+
]);
28482865
}
28492866
}
28502867
}
@@ -2861,6 +2878,8 @@ export abstract class AbstractEthLikeNewCoins extends AbstractEthLikeCoin {
28612878
* @param {TransactionPrebuild} params.txPrebuild - prebuild object returned by server
28622879
* @param {Wallet} params.wallet - Wallet object to obtain keys to verify against
28632880
* @returns {boolean}
2881+
* @throws {TxIntentMismatchError} if transaction validation fails
2882+
* @throws {TxIntentMismatchRecipientError} if transaction recipients don't match user intent
28642883
*/
28652884
async verifyTransaction(params: VerifyEthTransactionOptions): Promise<boolean> {
28662885
const ethNetwork = this.getNetwork();
@@ -2870,11 +2889,19 @@ export abstract class AbstractEthLikeNewCoins extends AbstractEthLikeCoin {
28702889
return this.verifyTssTransaction(params);
28712890
}
28722891

2892+
// Helper to throw TxIntentMismatchRecipientError with recipient details
2893+
const throwRecipientMismatch = (message: string, mismatchedRecipients: Recipient[]): never => {
2894+
throw new TxIntentMismatchRecipientError(message, undefined, [txParams], txPrebuild?.txHex, mismatchedRecipients);
2895+
};
2896+
28732897
if (!txParams?.recipients || !txPrebuild?.recipients || !wallet) {
2874-
throw new Error(`missing params`);
2898+
throw new Error('missing params');
28752899
}
2876-
if (txParams.hop && txParams.recipients.length > 1) {
2877-
throw new Error(`tx cannot be both a batch and hop transaction`);
2900+
2901+
const recipients = txParams.recipients!;
2902+
2903+
if (txParams.hop && recipients.length > 1) {
2904+
throw new Error('tx cannot be both a batch and hop transaction');
28782905
}
28792906
if (txPrebuild.recipients.length > 1) {
28802907
throw new Error(
@@ -2883,8 +2910,8 @@ export abstract class AbstractEthLikeNewCoins extends AbstractEthLikeCoin {
28832910
}
28842911
if (txParams.hop && txPrebuild.hopTransaction) {
28852912
// Check recipient amount for hop transaction
2886-
if (txParams.recipients.length !== 1) {
2887-
throw new Error(`hop transaction only supports 1 recipient but ${txParams.recipients.length} found`);
2913+
if (recipients.length !== 1) {
2914+
throw new Error(`hop transaction only supports 1 recipient but ${recipients.length} found`);
28882915
}
28892916

28902917
// Check tx sends to hop address
@@ -2894,34 +2921,39 @@ export abstract class AbstractEthLikeNewCoins extends AbstractEthLikeCoin {
28942921
const expectedHopAddress = optionalDeps.ethUtil.stripHexPrefix(decodedHopTx.getSenderAddress().toString());
28952922
const actualHopAddress = optionalDeps.ethUtil.stripHexPrefix(txPrebuild.recipients[0].address);
28962923
if (expectedHopAddress.toLowerCase() !== actualHopAddress.toLowerCase()) {
2897-
throw new Error('recipient address of txPrebuild does not match hop address');
2924+
throwRecipientMismatch('recipient address of txPrebuild does not match hop address', [
2925+
{ address: txPrebuild.recipients[0].address, amount: txPrebuild.recipients[0].amount.toString() },
2926+
]);
28982927
}
28992928

29002929
// Convert TransactionRecipient array to Recipient array
2901-
const recipients: Recipient[] = txParams.recipients.map((r) => {
2930+
const hopRecipients: Recipient[] = recipients.map((r) => {
29022931
return {
29032932
address: r.address,
29042933
amount: typeof r.amount === 'number' ? r.amount.toString() : r.amount,
29052934
};
29062935
});
29072936

29082937
// Check destination address and amount
2909-
await this.validateHopPrebuild(wallet, txPrebuild.hopTransaction, { recipients });
2910-
} else if (txParams.recipients.length > 1) {
2938+
await this.validateHopPrebuild(wallet, txPrebuild.hopTransaction, { recipients: hopRecipients });
2939+
} else if (recipients.length > 1) {
29112940
// Check total amount for batch transaction
29122941
if (txParams.tokenName) {
29132942
const expectedTotalAmount = new BigNumber(0);
29142943
if (!expectedTotalAmount.isEqualTo(txPrebuild.recipients[0].amount)) {
2915-
throw new Error('batch token transaction amount in txPrebuild should be zero for token transfers');
2944+
throwRecipientMismatch('batch token transaction amount in txPrebuild should be zero for token transfers', [
2945+
{ address: txPrebuild.recipients[0].address, amount: txPrebuild.recipients[0].amount.toString() },
2946+
]);
29162947
}
29172948
} else {
29182949
let expectedTotalAmount = new BigNumber(0);
2919-
for (let i = 0; i < txParams.recipients.length; i++) {
2920-
expectedTotalAmount = expectedTotalAmount.plus(txParams.recipients[i].amount);
2950+
for (let i = 0; i < recipients.length; i++) {
2951+
expectedTotalAmount = expectedTotalAmount.plus(recipients[i].amount);
29212952
}
29222953
if (!expectedTotalAmount.isEqualTo(txPrebuild.recipients[0].amount)) {
2923-
throw new Error(
2924-
'batch transaction amount in txPrebuild received from BitGo servers does not match txParams supplied by client'
2954+
throwRecipientMismatch(
2955+
'batch transaction amount in txPrebuild received from BitGo servers does not match txParams supplied by client',
2956+
[{ address: txPrebuild.recipients[0].address, amount: txPrebuild.recipients[0].amount.toString() }]
29252957
);
29262958
}
29272959
}
@@ -2932,29 +2964,37 @@ export abstract class AbstractEthLikeNewCoins extends AbstractEthLikeCoin {
29322964
!batcherContractAddress ||
29332965
batcherContractAddress.toLowerCase() !== txPrebuild.recipients[0].address.toLowerCase()
29342966
) {
2935-
throw new Error('recipient address of txPrebuild does not match batcher address');
2967+
throwRecipientMismatch('recipient address of txPrebuild does not match batcher address', [
2968+
{ address: txPrebuild.recipients[0].address, amount: txPrebuild.recipients[0].amount.toString() },
2969+
]);
29362970
}
29372971
} else {
29382972
// Check recipient address and amount for normal transaction
2939-
if (txParams.recipients.length !== 1) {
2940-
throw new Error(`normal transaction only supports 1 recipient but ${txParams.recipients.length} found`);
2973+
if (recipients.length !== 1) {
2974+
throw new Error(`normal transaction only supports 1 recipient but ${recipients.length} found`);
29412975
}
2942-
const expectedAmount = new BigNumber(txParams.recipients[0].amount);
2976+
const expectedAmount = new BigNumber(recipients[0].amount);
29432977
if (!expectedAmount.isEqualTo(txPrebuild.recipients[0].amount)) {
2944-
throw new Error(
2945-
'normal transaction amount in txPrebuild received from BitGo servers does not match txParams supplied by client'
2978+
throwRecipientMismatch(
2979+
'normal transaction amount in txPrebuild received from BitGo servers does not match txParams supplied by client',
2980+
[{ address: txPrebuild.recipients[0].address, amount: txPrebuild.recipients[0].amount.toString() }]
29462981
);
29472982
}
2948-
if (
2949-
this.isETHAddress(txParams.recipients[0].address) &&
2950-
txParams.recipients[0].address !== txPrebuild.recipients[0].address
2951-
) {
2952-
throw new Error('destination address in normal txPrebuild does not match that in txParams supplied by client');
2983+
if (this.isETHAddress(recipients[0].address) && recipients[0].address !== txPrebuild.recipients[0].address) {
2984+
throwRecipientMismatch(
2985+
'destination address in normal txPrebuild does not match that in txParams supplied by client',
2986+
[{ address: txPrebuild.recipients[0].address, amount: txPrebuild.recipients[0].amount.toString() }]
2987+
);
29532988
}
29542989
}
29552990
// Check coin is correct for all transaction types
29562991
if (!this.verifyCoin(txPrebuild)) {
2957-
throw new Error(`coin in txPrebuild did not match that in txParams supplied by client`);
2992+
throw new TxIntentMismatchError(
2993+
'coin in txPrebuild did not match that in txParams supplied by client',
2994+
undefined,
2995+
[txParams],
2996+
txPrebuild?.txHex
2997+
);
29582998
}
29592999
return true;
29603000
}

modules/abstract-utxo/src/abstractUtxoCoin.ts

Lines changed: 64 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ import {
2222
IWallet,
2323
KeychainsTriplet,
2424
KeyIndices,
25+
MismatchedRecipient,
2526
MultisigType,
2627
multisigTypes,
2728
P2shP2wshUnsupportedError,
@@ -39,6 +40,7 @@ import {
3940
TransactionParams as BaseTransactionParams,
4041
TransactionPrebuild as BaseTransactionPrebuild,
4142
Triple,
43+
TxIntentMismatchRecipientError,
4244
UnexpectedAddressError,
4345
UnsupportedAddressTypeError,
4446
VerificationOptions,
@@ -72,6 +74,11 @@ import {
7274
parseTransaction,
7375
verifyTransaction,
7476
} from './transaction';
77+
import {
78+
AggregateValidationError,
79+
ErrorMissingOutputs,
80+
ErrorImplicitExternalOutputs,
81+
} from './transaction/descriptor/verifyTransaction';
7582
import { assertDescriptorWalletAddress, getDescriptorMapFromWallet, isDescriptorWallet } from './descriptor';
7683
import { getChainFromNetwork, getFamilyFromNetwork, getFullNameFromNetwork } from './names';
7784
import { CustomChangeOptions } from './transaction/fixedScript';
@@ -113,6 +120,52 @@ const { getExternalChainCode, isChainCode, scriptTypeForChain, outputScripts } =
113120

114121
type Unspent<TNumber extends number | bigint = number> = bitgo.Unspent<TNumber>;
115122

123+
/**
124+
* Convert ValidationError to TxIntentMismatchRecipientError with structured data
125+
*
126+
* This preserves the structured error information from the original ValidationError
127+
* by extracting the mismatched outputs and converting them to the standardized format.
128+
* The original error is preserved as the `cause` for debugging purposes.
129+
*/
130+
function convertValidationErrorToTxIntentMismatch(
131+
error: AggregateValidationError,
132+
reqId: string | IRequestTracer | undefined,
133+
txParams: BaseTransactionParams,
134+
txHex: string | undefined
135+
): TxIntentMismatchRecipientError {
136+
const mismatchedRecipients: MismatchedRecipient[] = [];
137+
138+
for (const err of error.errors) {
139+
if (err instanceof ErrorMissingOutputs) {
140+
mismatchedRecipients.push(
141+
...err.missingOutputs.map((output) => ({
142+
address: output.address,
143+
amount: output.amount.toString(),
144+
}))
145+
);
146+
} else if (err instanceof ErrorImplicitExternalOutputs) {
147+
mismatchedRecipients.push(
148+
...err.implicitExternalOutputs.map((output) => ({
149+
address: output.address,
150+
amount: output.amount.toString(),
151+
}))
152+
);
153+
}
154+
}
155+
156+
const txIntentError = new TxIntentMismatchRecipientError(
157+
error.message,
158+
reqId,
159+
[txParams],
160+
txHex,
161+
mismatchedRecipients
162+
);
163+
// Preserve the original structured error as the cause for debugging
164+
// See: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Error/cause
165+
(txIntentError as Error & { cause?: Error }).cause = error;
166+
return txIntentError;
167+
}
168+
116169
export type DecodedTransaction<TNumber extends number | bigint> =
117170
| utxolib.bitgo.UtxoTransaction<TNumber>
118171
| utxolib.bitgo.UtxoPsbt;
@@ -631,12 +684,21 @@ export abstract class AbstractUtxoCoin extends BaseCoin {
631684
* @param params.verification.disableNetworking Disallow fetching any data from the internet for verification purposes
632685
* @param params.verification.keychains Pass keychains manually rather than fetching them by id
633686
* @param params.verification.addresses Address details to pass in for out-of-band verification
634-
* @returns {boolean}
687+
* @returns {boolean} True if verification passes
688+
* @throws {TxIntentMismatchError} if transaction validation fails
689+
* @throws {TxIntentMismatchRecipientError} if transaction recipients don't match user intent
635690
*/
636691
async verifyTransaction<TNumber extends number | bigint = number>(
637692
params: VerifyTransactionOptions<TNumber>
638693
): Promise<boolean> {
639-
return verifyTransaction(this, this.bitgo, params);
694+
try {
695+
return await verifyTransaction(this, this.bitgo, params);
696+
} catch (error) {
697+
if (error instanceof AggregateValidationError) {
698+
throw convertValidationErrorToTxIntentMismatch(error, params.reqId, params.txParams, params.txPrebuild.txHex);
699+
}
700+
throw error;
701+
}
640702
}
641703

642704
/**

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

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

5-
import { AbstractUtxoCoin, BaseOutput, BaseParsedTransactionOutputs } from '../../abstractUtxoCoin';
5+
import {
6+
AbstractUtxoCoin,
7+
BaseOutput,
8+
BaseParsedTransactionOutputs,
9+
VerifyTransactionOptions,
10+
} from '../../abstractUtxoCoin';
611

712
import { toBaseParsedTransactionOutputsFromPsbt } from './parse';
813

@@ -66,16 +71,25 @@ export function assertValidTransaction(
6671
* @param coin
6772
* @param params
6873
* @param descriptorMap
74+
* @returns {boolean} True if verification passes
75+
* @throws {TxIntentMismatchError} if transaction validation fails
6976
*/
70-
export async function verifyTransaction(
77+
export async function verifyTransaction<TNumber extends number | bigint>(
7178
coin: AbstractUtxoCoin,
72-
params: VerifyTransactionOptions,
79+
params: VerifyTransactionOptions<TNumber>,
7380
descriptorMap: DescriptorMap
7481
): Promise<boolean> {
7582
const tx = coin.decodeTransactionFromPrebuild(params.txPrebuild);
7683
if (!(tx instanceof utxolib.bitgo.UtxoPsbt)) {
77-
throw new Error('unexpected transaction type');
84+
throw new TxIntentMismatchError(
85+
'unexpected transaction type',
86+
params.reqId,
87+
[params.txParams],
88+
params.txPrebuild.txHex
89+
);
7890
}
91+
7992
assertValidTransaction(tx, descriptorMap, params.txParams.recipients ?? [], tx.network);
93+
8094
return true;
8195
}

0 commit comments

Comments
 (0)