Skip to content

Commit 4cab9c4

Browse files
committed
refactor: TxIntentMismatchError across transaction verification methods
Ticket: WP-6189
1 parent 4714aa6 commit 4cab9c4

5 files changed

Lines changed: 118 additions & 51 deletions

File tree

modules/abstract-eth/src/abstractEthLikeNewCoins.ts

Lines changed: 73 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@ import {
2828
PresignTransactionOptions as BasePresignTransactionOptions,
2929
Recipient,
3030
SignTransactionOptions as BaseSignTransactionOptions,
31-
SuspiciousTransactionError,
31+
TxIntentMismatchError,
3232
TransactionParams,
3333
TransactionPrebuild as BaseTransactionPrebuild,
3434
TransactionRecipient,
@@ -2768,6 +2768,7 @@ export abstract class AbstractEthLikeNewCoins extends AbstractEthLikeCoin {
27682768
* @param {TransactionPrebuild} params.txPrebuild - prebuild object returned by server
27692769
* @param {Wallet} params.wallet - Wallet object to obtain keys to verify against
27702770
* @returns {boolean}
2771+
* @throws {TxIntentMismatchError} if transaction validation fails
27712772
*/
27722773
async verifyTssTransaction(params: VerifyEthTransactionOptions): Promise<boolean> {
27732774
const { txParams, txPrebuild, wallet } = params;
@@ -2778,13 +2779,13 @@ export abstract class AbstractEthLikeNewCoins extends AbstractEthLikeCoin {
27782779
(txParams.type && ['acceleration', 'fillNonce', 'transferToken', 'tokenApproval'].includes(txParams.type))
27792780
)
27802781
) {
2781-
throw new SuspiciousTransactionError(`missing txParams`);
2782+
throw new TxIntentMismatchError(`missing txParams`, '', [], '');
27822783
}
27832784
if (!wallet || !txPrebuild) {
2784-
throw new SuspiciousTransactionError(`missing params`);
2785+
throw new TxIntentMismatchError(`missing params`, '', [], '');
27852786
}
27862787
if (txParams.hop && txParams.recipients && txParams.recipients.length > 1) {
2787-
throw new SuspiciousTransactionError(`tx cannot be both a batch and hop transaction`);
2788+
throw new TxIntentMismatchError(`tx cannot be both a batch and hop transaction`, '', [], '');
27882789
}
27892790

27902791
if (txParams.type && ['transfer'].includes(txParams.type)) {
@@ -2799,25 +2800,41 @@ export abstract class AbstractEthLikeNewCoins extends AbstractEthLikeCoin {
27992800
const txJson = tx.toJson();
28002801
if (txJson.data === '0x') {
28012802
if (expectedAmount !== txJson.value) {
2802-
throw new SuspiciousTransactionError(
2803-
'the transaction amount in txPrebuild does not match the value given by client'
2803+
throw new TxIntentMismatchError(
2804+
'the transaction amount in txPrebuild does not match the value given by client',
2805+
'',
2806+
[],
2807+
''
28042808
);
28052809
}
28062810
if (expectedDestination.toLowerCase() !== txJson.to.toLowerCase()) {
2807-
throw new SuspiciousTransactionError('destination address does not match with the recipient address');
2811+
throw new TxIntentMismatchError(
2812+
'destination address does not match with the recipient address',
2813+
'',
2814+
[],
2815+
''
2816+
);
28082817
}
28092818
} else if (txJson.data.startsWith('0xa9059cbb')) {
28102819
const [recipientAddress, amount] = getRawDecoded(
28112820
['address', 'uint256'],
28122821
getBufferedByteCode('0xa9059cbb', txJson.data)
28132822
);
28142823
if (expectedAmount !== amount.toString()) {
2815-
throw new SuspiciousTransactionError(
2816-
'the transaction amount in txPrebuild does not match the value given by client'
2824+
throw new TxIntentMismatchError(
2825+
'the transaction amount in txPrebuild does not match the value given by client',
2826+
'',
2827+
[],
2828+
''
28172829
);
28182830
}
28192831
if (expectedDestination.toLowerCase() !== addHexPrefix(recipientAddress.toString()).toLowerCase()) {
2820-
throw new SuspiciousTransactionError('destination address does not match with the recipient address');
2832+
throw new TxIntentMismatchError(
2833+
'destination address does not match with the recipient address',
2834+
'',
2835+
[],
2836+
''
2837+
);
28212838
}
28222839
}
28232840
}
@@ -2834,6 +2851,7 @@ export abstract class AbstractEthLikeNewCoins extends AbstractEthLikeCoin {
28342851
* @param {TransactionPrebuild} params.txPrebuild - prebuild object returned by server
28352852
* @param {Wallet} params.wallet - Wallet object to obtain keys to verify against
28362853
* @returns {boolean}
2854+
* @throws {TxIntentMismatchError} if transaction validation fails
28372855
*/
28382856
async verifyTransaction(params: VerifyEthTransactionOptions): Promise<boolean> {
28392857
const ethNetwork = this.getNetwork();
@@ -2844,21 +2862,27 @@ export abstract class AbstractEthLikeNewCoins extends AbstractEthLikeCoin {
28442862
}
28452863

28462864
if (!txParams?.recipients || !txPrebuild?.recipients || !wallet) {
2847-
throw new SuspiciousTransactionError(`missing params`);
2865+
throw new TxIntentMismatchError(`missing params`, '', [], '');
28482866
}
28492867
if (txParams.hop && txParams.recipients.length > 1) {
2850-
throw new SuspiciousTransactionError(`tx cannot be both a batch and hop transaction`);
2868+
throw new TxIntentMismatchError(`tx cannot be both a batch and hop transaction`, '', [], '');
28512869
}
28522870
if (txPrebuild.recipients.length > 1) {
2853-
throw new SuspiciousTransactionError(
2854-
`${this.getChain()} doesn't support sending to more than 1 destination address within a single transaction. Try again, using only a single recipient.`
2871+
throw new TxIntentMismatchError(
2872+
`${this.getChain()} doesn't support sending to more than 1 destination address within a single transaction. Try again, using only a single recipient.`,
2873+
'',
2874+
[],
2875+
''
28552876
);
28562877
}
28572878
if (txParams.hop && txPrebuild.hopTransaction) {
28582879
// Check recipient amount for hop transaction
28592880
if (txParams.recipients.length !== 1) {
2860-
throw new SuspiciousTransactionError(
2861-
`hop transaction only supports 1 recipient but ${txParams.recipients.length} found`
2881+
throw new TxIntentMismatchError(
2882+
`hop transaction only supports 1 recipient but ${txParams.recipients.length} found`,
2883+
'',
2884+
[],
2885+
''
28622886
);
28632887
}
28642888

@@ -2869,7 +2893,7 @@ export abstract class AbstractEthLikeNewCoins extends AbstractEthLikeCoin {
28692893
const expectedHopAddress = optionalDeps.ethUtil.stripHexPrefix(decodedHopTx.getSenderAddress().toString());
28702894
const actualHopAddress = optionalDeps.ethUtil.stripHexPrefix(txPrebuild.recipients[0].address);
28712895
if (expectedHopAddress.toLowerCase() !== actualHopAddress.toLowerCase()) {
2872-
throw new SuspiciousTransactionError('recipient address of txPrebuild does not match hop address');
2896+
throw new TxIntentMismatchError('recipient address of txPrebuild does not match hop address', '', [], '');
28732897
}
28742898

28752899
// Convert TransactionRecipient array to Recipient array
@@ -2887,8 +2911,11 @@ export abstract class AbstractEthLikeNewCoins extends AbstractEthLikeCoin {
28872911
if (txParams.tokenName) {
28882912
const expectedTotalAmount = new BigNumber(0);
28892913
if (!expectedTotalAmount.isEqualTo(txPrebuild.recipients[0].amount)) {
2890-
throw new SuspiciousTransactionError(
2891-
'batch token transaction amount in txPrebuild should be zero for token transfers'
2914+
throw new TxIntentMismatchError(
2915+
'batch token transaction amount in txPrebuild should be zero for token transfers',
2916+
'',
2917+
[],
2918+
''
28922919
);
28932920
}
28942921
} else {
@@ -2897,8 +2924,11 @@ export abstract class AbstractEthLikeNewCoins extends AbstractEthLikeCoin {
28972924
expectedTotalAmount = expectedTotalAmount.plus(txParams.recipients[i].amount);
28982925
}
28992926
if (!expectedTotalAmount.isEqualTo(txPrebuild.recipients[0].amount)) {
2900-
throw new SuspiciousTransactionError(
2901-
'batch transaction amount in txPrebuild received from BitGo servers does not match txParams supplied by client'
2927+
throw new TxIntentMismatchError(
2928+
'batch transaction amount in txPrebuild received from BitGo servers does not match txParams supplied by client',
2929+
'',
2930+
[],
2931+
''
29022932
);
29032933
}
29042934
}
@@ -2909,33 +2939,47 @@ export abstract class AbstractEthLikeNewCoins extends AbstractEthLikeCoin {
29092939
!batcherContractAddress ||
29102940
batcherContractAddress.toLowerCase() !== txPrebuild.recipients[0].address.toLowerCase()
29112941
) {
2912-
throw new SuspiciousTransactionError('recipient address of txPrebuild does not match batcher address');
2942+
throw new TxIntentMismatchError('recipient address of txPrebuild does not match batcher address', '', [], '');
29132943
}
29142944
} else {
29152945
// Check recipient address and amount for normal transaction
29162946
if (txParams.recipients.length !== 1) {
2917-
throw new SuspiciousTransactionError(
2918-
`normal transaction only supports 1 recipient but ${txParams.recipients.length} found`
2947+
throw new TxIntentMismatchError(
2948+
`normal transaction only supports 1 recipient but ${txParams.recipients.length} found`,
2949+
'',
2950+
[],
2951+
''
29192952
);
29202953
}
29212954
const expectedAmount = new BigNumber(txParams.recipients[0].amount);
29222955
if (!expectedAmount.isEqualTo(txPrebuild.recipients[0].amount)) {
2923-
throw new SuspiciousTransactionError(
2924-
'normal transaction amount in txPrebuild received from BitGo servers does not match txParams supplied by client'
2956+
throw new TxIntentMismatchError(
2957+
'normal transaction amount in txPrebuild received from BitGo servers does not match txParams supplied by client',
2958+
'',
2959+
[],
2960+
''
29252961
);
29262962
}
29272963
if (
29282964
this.isETHAddress(txParams.recipients[0].address) &&
29292965
txParams.recipients[0].address !== txPrebuild.recipients[0].address
29302966
) {
2931-
throw new SuspiciousTransactionError(
2932-
'destination address in normal txPrebuild does not match that in txParams supplied by client'
2967+
throw new TxIntentMismatchError(
2968+
'destination address in normal txPrebuild does not match that in txParams supplied by client',
2969+
'',
2970+
[],
2971+
''
29332972
);
29342973
}
29352974
}
29362975
// Check coin is correct for all transaction types
29372976
if (!this.verifyCoin(txPrebuild)) {
2938-
throw new SuspiciousTransactionError(`coin in txPrebuild did not match that in txParams supplied by client`);
2977+
throw new TxIntentMismatchError(
2978+
`coin in txPrebuild did not match that in txParams supplied by client`,
2979+
'',
2980+
[],
2981+
''
2982+
);
29392983
}
29402984
return true;
29412985
}

modules/abstract-utxo/src/abstractUtxoCoin.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -632,6 +632,7 @@ export abstract class AbstractUtxoCoin extends BaseCoin {
632632
* @param params.verification.keychains Pass keychains manually rather than fetching them by id
633633
* @param params.verification.addresses Address details to pass in for out-of-band verification
634634
* @returns {boolean}
635+
* @throws {TxIntentMismatchError} if transaction validation fails
635636
*/
636637
async verifyTransaction<TNumber extends number | bigint = number>(
637638
params: VerifyTransactionOptions<TNumber>

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

Lines changed: 4 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, SuspiciousTransactionError, VerifyTransactionOptions } from '@bitgo/sdk-core';
2+
import { ITransactionRecipient, TxIntentMismatchError, VerifyTransactionOptions } from '@bitgo/sdk-core';
33
import { DescriptorMap } from '@bitgo/utxo-core/descriptor';
44

55
import { AbstractUtxoCoin, BaseOutput, BaseParsedTransactionOutputs } from '../../abstractUtxoCoin';
@@ -66,6 +66,8 @@ export function assertValidTransaction(
6666
* @param coin
6767
* @param params
6868
* @param descriptorMap
69+
* @returns {boolean} True if verification passes
70+
* @throws {TxIntentMismatchError} if transaction validation fails
6971
*/
7072
export async function verifyTransaction(
7173
coin: AbstractUtxoCoin,
@@ -74,7 +76,7 @@ export async function verifyTransaction(
7476
): Promise<boolean> {
7577
const tx = coin.decodeTransactionFromPrebuild(params.txPrebuild);
7678
if (!(tx instanceof utxolib.bitgo.UtxoPsbt)) {
77-
throw new SuspiciousTransactionError('unexpected transaction type');
79+
throw new TxIntentMismatchError('unexpected transaction type', '', [], '');
7880
}
7981
assertValidTransaction(tx, descriptorMap, params.txParams.recipients ?? [], tx.network);
8082
return true;

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

Lines changed: 40 additions & 14 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, SuspiciousTransactionError } from '@bitgo/sdk-core';
4+
import { BitGoBase, TxIntentMismatchError } from '@bitgo/sdk-core';
55
import * as utxolib from '@bitgo/utxo-lib';
66

77
import { AbstractUtxoCoin, Output, ParsedTransaction, VerifyTransactionOptions } from '../../abstractUtxoCoin';
@@ -25,6 +25,23 @@ function getPayGoLimit(allowPaygoOutput?: boolean): number {
2525
return 0.015;
2626
}
2727

28+
/**
29+
* Verify that a transaction prebuild complies with the original intention for fixed-script wallets
30+
*
31+
* This implementation handles transaction verification for traditional UTXO coins using fixed scripts
32+
* (non-descriptor wallets). It validates keychains, signatures, outputs, and amounts.
33+
*
34+
* @param coin - The UTXO coin instance
35+
* @param bitgo - BitGo API instance for network calls
36+
* @param params - Verification parameters
37+
* @param params.txParams - Transaction parameters passed to send
38+
* @param params.txPrebuild - Prebuild object returned by server
39+
* @param params.wallet - Wallet object to obtain keys to verify against
40+
* @param params.verification - Verification options (disableNetworking, keychains, addresses)
41+
* @param params.reqId - Optional request ID for logging
42+
* @returns {boolean} True if verification passes
43+
* @throws {TxIntentMismatchError} if transaction validation fails
44+
*/
2845
export async function verifyTransaction<TNumber extends bigint | number>(
2946
coin: AbstractUtxoCoin,
3047
bitgo: BitGoBase,
@@ -33,11 +50,11 @@ export async function verifyTransaction<TNumber extends bigint | number>(
3350
const { txParams, txPrebuild, wallet, verification = {}, reqId } = params;
3451

3552
if (!_.isUndefined(verification.disableNetworking) && !_.isBoolean(verification.disableNetworking)) {
36-
throw new SuspiciousTransactionError('verification.disableNetworking must be a boolean');
53+
throw new TxIntentMismatchError('verification.disableNetworking must be a boolean', '', [], '');
3754
}
3855
const isPsbt = txPrebuild.txHex && utxolib.bitgo.isPsbt(txPrebuild.txHex);
3956
if (isPsbt && txPrebuild.txInfo?.unspents) {
40-
throw new SuspiciousTransactionError('should not have unspents in txInfo for psbt');
57+
throw new TxIntentMismatchError('should not have unspents in txInfo for psbt', '', [], '');
4158
}
4259
const disableNetworking = !!verification.disableNetworking;
4360
const parsedTransaction: ParsedTransaction<TNumber> = await coin.parseTransaction<TNumber>({
@@ -64,7 +81,7 @@ export async function verifyTransaction<TNumber extends bigint | number>(
6481
if (!_.isEmpty(keySignatures)) {
6582
const verify = (key, pub) => {
6683
if (!keychains.user || !keychains.user.pub) {
67-
throw new SuspiciousTransactionError('missing user keychain');
84+
throw new TxIntentMismatchError('missing user keychain', '', [], '');
6885
}
6986
return verifyKeySignature({
7087
userKeychain: keychains.user as { pub: string },
@@ -75,7 +92,7 @@ export async function verifyTransaction<TNumber extends bigint | number>(
7592
const isBackupKeySignatureValid = verify(keychains.backup, keySignatures.backupPub);
7693
const isBitgoKeySignatureValid = verify(keychains.bitgo, keySignatures.bitgoPub);
7794
if (!isBackupKeySignatureValid || !isBitgoKeySignatureValid) {
78-
throw new SuspiciousTransactionError('secondary public key signatures invalid');
95+
throw new TxIntentMismatchError('secondary public key signatures invalid', '', [], '');
7996
}
8097
debug('successfully verified backup and bitgo key signatures');
8198
} else if (!disableNetworking) {
@@ -86,14 +103,20 @@ export async function verifyTransaction<TNumber extends bigint | number>(
86103

87104
if (parsedTransaction.needsCustomChangeKeySignatureVerification) {
88105
if (!keychains.user || !userPublicKeyVerified) {
89-
throw new SuspiciousTransactionError(
90-
'transaction requires verification of user public key, but it was unable to be verified'
106+
throw new TxIntentMismatchError(
107+
'transaction requires verification of user public key, but it was unable to be verified',
108+
'',
109+
[],
110+
''
91111
);
92112
}
93113
const customChangeKeySignaturesVerified = verifyCustomChangeKeySignatures(parsedTransaction, keychains.user);
94114
if (!customChangeKeySignaturesVerified) {
95-
throw new SuspiciousTransactionError(
96-
'transaction requires verification of custom change key signatures, but they were unable to be verified'
115+
throw new TxIntentMismatchError(
116+
'transaction requires verification of custom change key signatures, but they were unable to be verified',
117+
'',
118+
[],
119+
''
97120
);
98121
}
99122
debug('successfully verified user public key and custom change key signatures');
@@ -102,7 +125,7 @@ export async function verifyTransaction<TNumber extends bigint | number>(
102125
const missingOutputs = parsedTransaction.missingOutputs;
103126
if (missingOutputs.length !== 0) {
104127
// there are some outputs in the recipients list that have not made it into the actual transaction
105-
throw new SuspiciousTransactionError('expected outputs missing in transaction prebuild');
128+
throw new TxIntentMismatchError('expected outputs missing in transaction prebuild', '', [], '');
106129
}
107130

108131
const intendedExternalSpend = parsedTransaction.explicitExternalSpendAmount;
@@ -142,13 +165,13 @@ export async function verifyTransaction<TNumber extends bigint | number>(
142165
} else {
143166
// the additional external outputs can only be BitGo's pay-as-you-go fee, but we cannot verify the wallet address
144167
// there are some addresses that are outside the scope of intended recipients that are not change addresses
145-
throw new SuspiciousTransactionError('prebuild attempts to spend to unintended external recipients');
168+
throw new TxIntentMismatchError('prebuild attempts to spend to unintended external recipients', '', [], '');
146169
}
147170
}
148171

149172
const allOutputs = parsedTransaction.outputs;
150173
if (!txPrebuild.txHex) {
151-
throw new SuspiciousTransactionError(`txPrebuild.txHex not set`);
174+
throw new TxIntentMismatchError(`txPrebuild.txHex not set`, '', [], '');
152175
}
153176
const inputs = isPsbt
154177
? getPsbtTxInputs(txPrebuild.txHex, coin.network).map((v) => ({
@@ -165,8 +188,11 @@ export async function verifyTransaction<TNumber extends bigint | number>(
165188
const fee = inputAmount - outputAmount;
166189

167190
if (fee < 0) {
168-
throw new SuspiciousTransactionError(
169-
`attempting to spend ${outputAmount} satoshis, which exceeds the input amount (${inputAmount} satoshis) by ${-fee}`
191+
throw new TxIntentMismatchError(
192+
`attempting to spend ${outputAmount} satoshis, which exceeds the input amount (${inputAmount} satoshis) by ${-fee}`,
193+
'',
194+
[],
195+
''
170196
);
171197
}
172198

modules/sdk-core/src/bitgo/errors.ts

Lines changed: 0 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -160,12 +160,6 @@ export class InvalidTransactionError extends BitGoJsError {
160160
}
161161
}
162162

163-
export class SuspiciousTransactionError extends BitGoJsError {
164-
public constructor(message?: string) {
165-
super(message || 'Suspicious transaction detected');
166-
}
167-
}
168-
169163
export class MissingEncryptedKeychainError extends Error {
170164
public constructor(message?: string) {
171165
super(message || 'No encrypted keychains on this wallet.');

0 commit comments

Comments
 (0)