Skip to content

Commit 982f902

Browse files
committed
feat: replace generic Error with TxIntentMismatchError
Ticket: WP-6189
1 parent 062be3d commit 982f902

File tree

4 files changed

+89
-45
lines changed

4 files changed

+89
-45
lines changed

modules/abstract-eth/src/abstractEthLikeNewCoins.ts

Lines changed: 46 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ import {
2828
PresignTransactionOptions as BasePresignTransactionOptions,
2929
Recipient,
3030
SignTransactionOptions as BaseSignTransactionOptions,
31+
TxIntentMismatchError,
3132
TransactionParams,
3233
TransactionPrebuild as BaseTransactionPrebuild,
3334
TransactionRecipient,
@@ -2767,23 +2768,30 @@ export abstract class AbstractEthLikeNewCoins extends AbstractEthLikeCoin {
27672768
* @param {TransactionPrebuild} params.txPrebuild - prebuild object returned by server
27682769
* @param {Wallet} params.wallet - Wallet object to obtain keys to verify against
27692770
* @returns {boolean}
2771+
* @throws {TxIntentMismatchError} if transaction validation fails
27702772
*/
27712773
async verifyTssTransaction(params: VerifyEthTransactionOptions): Promise<boolean> {
27722774
const { txParams, txPrebuild, wallet } = params;
2775+
2776+
// Helper to throw TxIntentMismatchError with consistent context
2777+
const throwTxMismatch = (message: string): never => {
2778+
throw new TxIntentMismatchError(message, '', [txParams], txPrebuild?.txHex || '');
2779+
};
2780+
27732781
if (
27742782
!txParams?.recipients &&
27752783
!(
27762784
txParams.prebuildTx?.consolidateId ||
27772785
(txParams.type && ['acceleration', 'fillNonce', 'transferToken', 'tokenApproval'].includes(txParams.type))
27782786
)
27792787
) {
2780-
throw new Error(`missing txParams`);
2788+
throwTxMismatch(`missing txParams`);
27812789
}
27822790
if (!wallet || !txPrebuild) {
2783-
throw new Error(`missing params`);
2791+
throwTxMismatch(`missing params`);
27842792
}
27852793
if (txParams.hop && txParams.recipients && txParams.recipients.length > 1) {
2786-
throw new Error(`tx cannot be both a batch and hop transaction`);
2794+
throwTxMismatch(`tx cannot be both a batch and hop transaction`);
27872795
}
27882796

27892797
if (txParams.type && ['transfer'].includes(txParams.type)) {
@@ -2798,21 +2806,21 @@ export abstract class AbstractEthLikeNewCoins extends AbstractEthLikeCoin {
27982806
const txJson = tx.toJson();
27992807
if (txJson.data === '0x') {
28002808
if (expectedAmount !== txJson.value) {
2801-
throw new Error('the transaction amount in txPrebuild does not match the value given by client');
2809+
throwTxMismatch('the transaction amount in txPrebuild does not match the value given by client');
28022810
}
28032811
if (expectedDestination.toLowerCase() !== txJson.to.toLowerCase()) {
2804-
throw new Error('destination address does not match with the recipient address');
2812+
throwTxMismatch('destination address does not match with the recipient address');
28052813
}
28062814
} else if (txJson.data.startsWith('0xa9059cbb')) {
28072815
const [recipientAddress, amount] = getRawDecoded(
28082816
['address', 'uint256'],
28092817
getBufferedByteCode('0xa9059cbb', txJson.data)
28102818
);
28112819
if (expectedAmount !== amount.toString()) {
2812-
throw new Error('the transaction amount in txPrebuild does not match the value given by client');
2820+
throwTxMismatch('the transaction amount in txPrebuild does not match the value given by client');
28132821
}
28142822
if (expectedDestination.toLowerCase() !== addHexPrefix(recipientAddress.toString()).toLowerCase()) {
2815-
throw new Error('destination address does not match with the recipient address');
2823+
throwTxMismatch('destination address does not match with the recipient address');
28162824
}
28172825
}
28182826
}
@@ -2829,6 +2837,7 @@ export abstract class AbstractEthLikeNewCoins extends AbstractEthLikeCoin {
28292837
* @param {TransactionPrebuild} params.txPrebuild - prebuild object returned by server
28302838
* @param {Wallet} params.wallet - Wallet object to obtain keys to verify against
28312839
* @returns {boolean}
2840+
* @throws {TxIntentMismatchError} if transaction validation fails
28322841
*/
28332842
async verifyTransaction(params: VerifyEthTransactionOptions): Promise<boolean> {
28342843
const ethNetwork = this.getNetwork();
@@ -2838,21 +2847,29 @@ export abstract class AbstractEthLikeNewCoins extends AbstractEthLikeCoin {
28382847
return this.verifyTssTransaction(params);
28392848
}
28402849

2850+
// Helper to throw TxIntentMismatchError with consistent context
2851+
const throwTxMismatch = (message: string): never => {
2852+
throw new TxIntentMismatchError(message, '', [txParams], txPrebuild?.txHex || '');
2853+
};
2854+
28412855
if (!txParams?.recipients || !txPrebuild?.recipients || !wallet) {
2842-
throw new Error(`missing params`);
2856+
throwTxMismatch(`missing params`);
28432857
}
2844-
if (txParams.hop && txParams.recipients.length > 1) {
2845-
throw new Error(`tx cannot be both a batch and hop transaction`);
2858+
2859+
const recipients = txParams.recipients!;
2860+
2861+
if (txParams.hop && recipients.length > 1) {
2862+
throwTxMismatch(`tx cannot be both a batch and hop transaction`);
28462863
}
28472864
if (txPrebuild.recipients.length > 1) {
2848-
throw new Error(
2865+
throwTxMismatch(
28492866
`${this.getChain()} doesn't support sending to more than 1 destination address within a single transaction. Try again, using only a single recipient.`
28502867
);
28512868
}
28522869
if (txParams.hop && txPrebuild.hopTransaction) {
28532870
// Check recipient amount for hop transaction
2854-
if (txParams.recipients.length !== 1) {
2855-
throw new Error(`hop transaction only supports 1 recipient but ${txParams.recipients.length} found`);
2871+
if (recipients.length !== 1) {
2872+
throwTxMismatch(`hop transaction only supports 1 recipient but ${recipients.length} found`);
28562873
}
28572874

28582875
// Check tx sends to hop address
@@ -2862,33 +2879,33 @@ export abstract class AbstractEthLikeNewCoins extends AbstractEthLikeCoin {
28622879
const expectedHopAddress = optionalDeps.ethUtil.stripHexPrefix(decodedHopTx.getSenderAddress().toString());
28632880
const actualHopAddress = optionalDeps.ethUtil.stripHexPrefix(txPrebuild.recipients[0].address);
28642881
if (expectedHopAddress.toLowerCase() !== actualHopAddress.toLowerCase()) {
2865-
throw new Error('recipient address of txPrebuild does not match hop address');
2882+
throwTxMismatch('recipient address of txPrebuild does not match hop address');
28662883
}
28672884

28682885
// Convert TransactionRecipient array to Recipient array
2869-
const recipients: Recipient[] = txParams.recipients.map((r) => {
2886+
const hopRecipients: Recipient[] = recipients.map((r) => {
28702887
return {
28712888
address: r.address,
28722889
amount: typeof r.amount === 'number' ? r.amount.toString() : r.amount,
28732890
};
28742891
});
28752892

28762893
// Check destination address and amount
2877-
await this.validateHopPrebuild(wallet, txPrebuild.hopTransaction, { recipients });
2878-
} else if (txParams.recipients.length > 1) {
2894+
await this.validateHopPrebuild(wallet, txPrebuild.hopTransaction, { recipients: hopRecipients });
2895+
} else if (recipients.length > 1) {
28792896
// Check total amount for batch transaction
28802897
if (txParams.tokenName) {
28812898
const expectedTotalAmount = new BigNumber(0);
28822899
if (!expectedTotalAmount.isEqualTo(txPrebuild.recipients[0].amount)) {
2883-
throw new Error('batch token transaction amount in txPrebuild should be zero for token transfers');
2900+
throwTxMismatch('batch token transaction amount in txPrebuild should be zero for token transfers');
28842901
}
28852902
} else {
28862903
let expectedTotalAmount = new BigNumber(0);
2887-
for (let i = 0; i < txParams.recipients.length; i++) {
2888-
expectedTotalAmount = expectedTotalAmount.plus(txParams.recipients[i].amount);
2904+
for (let i = 0; i < recipients.length; i++) {
2905+
expectedTotalAmount = expectedTotalAmount.plus(recipients[i].amount);
28892906
}
28902907
if (!expectedTotalAmount.isEqualTo(txPrebuild.recipients[0].amount)) {
2891-
throw new Error(
2908+
throwTxMismatch(
28922909
'batch transaction amount in txPrebuild received from BitGo servers does not match txParams supplied by client'
28932910
);
28942911
}
@@ -2900,29 +2917,26 @@ export abstract class AbstractEthLikeNewCoins extends AbstractEthLikeCoin {
29002917
!batcherContractAddress ||
29012918
batcherContractAddress.toLowerCase() !== txPrebuild.recipients[0].address.toLowerCase()
29022919
) {
2903-
throw new Error('recipient address of txPrebuild does not match batcher address');
2920+
throwTxMismatch('recipient address of txPrebuild does not match batcher address');
29042921
}
29052922
} else {
29062923
// Check recipient address and amount for normal transaction
2907-
if (txParams.recipients.length !== 1) {
2908-
throw new Error(`normal transaction only supports 1 recipient but ${txParams.recipients.length} found`);
2924+
if (recipients.length !== 1) {
2925+
throwTxMismatch(`normal transaction only supports 1 recipient but ${recipients.length} found`);
29092926
}
2910-
const expectedAmount = new BigNumber(txParams.recipients[0].amount);
2927+
const expectedAmount = new BigNumber(recipients[0].amount);
29112928
if (!expectedAmount.isEqualTo(txPrebuild.recipients[0].amount)) {
2912-
throw new Error(
2929+
throwTxMismatch(
29132930
'normal transaction amount in txPrebuild received from BitGo servers does not match txParams supplied by client'
29142931
);
29152932
}
2916-
if (
2917-
this.isETHAddress(txParams.recipients[0].address) &&
2918-
txParams.recipients[0].address !== txPrebuild.recipients[0].address
2919-
) {
2920-
throw new Error('destination address in normal txPrebuild does not match that in txParams supplied by client');
2933+
if (this.isETHAddress(recipients[0].address) && recipients[0].address !== txPrebuild.recipients[0].address) {
2934+
throwTxMismatch('destination address in normal txPrebuild does not match that in txParams supplied by client');
29212935
}
29222936
}
29232937
// Check coin is correct for all transaction types
29242938
if (!this.verifyCoin(txPrebuild)) {
2925-
throw new Error(`coin in txPrebuild did not match that in txParams supplied by client`);
2939+
throwTxMismatch(`coin in txPrebuild did not match that in txParams supplied by client`);
29262940
}
29272941
return true;
29282942
}

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: 9 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, 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,12 @@ export async function verifyTransaction(
7476
): Promise<boolean> {
7577
const tx = coin.decodeTransactionFromPrebuild(params.txPrebuild);
7678
if (!(tx instanceof utxolib.bitgo.UtxoPsbt)) {
77-
throw new Error('unexpected transaction type');
79+
throw new TxIntentMismatchError(
80+
'unexpected transaction type',
81+
params.reqId || '',
82+
[params.txParams],
83+
params.txPrebuild.txHex || ''
84+
);
7885
}
7986
assertValidTransaction(tx, descriptorMap, params.txParams.recipients ?? [], tx.network);
8087
return true;

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

Lines changed: 33 additions & 11 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 } 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,19 +25,41 @@ 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,
3148
params: VerifyTransactionOptions<TNumber>
3249
): Promise<boolean> {
3350
const { txParams, txPrebuild, wallet, verification = {}, reqId } = params;
3451

52+
// Helper to throw TxIntentMismatchError with consistent context
53+
const throwTxMismatch = (message: string): never => {
54+
throw new TxIntentMismatchError(message, reqId || '', [txParams], txPrebuild.txHex || '');
55+
};
56+
3557
if (!_.isUndefined(verification.disableNetworking) && !_.isBoolean(verification.disableNetworking)) {
36-
throw new Error('verification.disableNetworking must be a boolean');
58+
throwTxMismatch('verification.disableNetworking must be a boolean');
3759
}
3860
const isPsbt = txPrebuild.txHex && utxolib.bitgo.isPsbt(txPrebuild.txHex);
3961
if (isPsbt && txPrebuild.txInfo?.unspents) {
40-
throw new Error('should not have unspents in txInfo for psbt');
62+
throwTxMismatch('should not have unspents in txInfo for psbt');
4163
}
4264
const disableNetworking = !!verification.disableNetworking;
4365
const parsedTransaction: ParsedTransaction<TNumber> = await coin.parseTransaction<TNumber>({
@@ -64,7 +86,7 @@ export async function verifyTransaction<TNumber extends bigint | number>(
6486
if (!_.isEmpty(keySignatures)) {
6587
const verify = (key, pub) => {
6688
if (!keychains.user || !keychains.user.pub) {
67-
throw new Error('missing user keychain');
89+
throwTxMismatch('missing user keychain');
6890
}
6991
return verifyKeySignature({
7092
userKeychain: keychains.user as { pub: string },
@@ -75,7 +97,7 @@ export async function verifyTransaction<TNumber extends bigint | number>(
7597
const isBackupKeySignatureValid = verify(keychains.backup, keySignatures.backupPub);
7698
const isBitgoKeySignatureValid = verify(keychains.bitgo, keySignatures.bitgoPub);
7799
if (!isBackupKeySignatureValid || !isBitgoKeySignatureValid) {
78-
throw new Error('secondary public key signatures invalid');
100+
throwTxMismatch('secondary public key signatures invalid');
79101
}
80102
debug('successfully verified backup and bitgo key signatures');
81103
} else if (!disableNetworking) {
@@ -86,11 +108,11 @@ export async function verifyTransaction<TNumber extends bigint | number>(
86108

87109
if (parsedTransaction.needsCustomChangeKeySignatureVerification) {
88110
if (!keychains.user || !userPublicKeyVerified) {
89-
throw new Error('transaction requires verification of user public key, but it was unable to be verified');
111+
throwTxMismatch('transaction requires verification of user public key, but it was unable to be verified');
90112
}
91113
const customChangeKeySignaturesVerified = verifyCustomChangeKeySignatures(parsedTransaction, keychains.user);
92114
if (!customChangeKeySignaturesVerified) {
93-
throw new Error(
115+
throwTxMismatch(
94116
'transaction requires verification of custom change key signatures, but they were unable to be verified'
95117
);
96118
}
@@ -100,7 +122,7 @@ export async function verifyTransaction<TNumber extends bigint | number>(
100122
const missingOutputs = parsedTransaction.missingOutputs;
101123
if (missingOutputs.length !== 0) {
102124
// there are some outputs in the recipients list that have not made it into the actual transaction
103-
throw new Error('expected outputs missing in transaction prebuild');
125+
throwTxMismatch('expected outputs missing in transaction prebuild');
104126
}
105127

106128
const intendedExternalSpend = parsedTransaction.explicitExternalSpendAmount;
@@ -140,13 +162,13 @@ export async function verifyTransaction<TNumber extends bigint | number>(
140162
} else {
141163
// the additional external outputs can only be BitGo's pay-as-you-go fee, but we cannot verify the wallet address
142164
// there are some addresses that are outside the scope of intended recipients that are not change addresses
143-
throw new Error('prebuild attempts to spend to unintended external recipients');
165+
throwTxMismatch('prebuild attempts to spend to unintended external recipients');
144166
}
145167
}
146168

147169
const allOutputs = parsedTransaction.outputs;
148170
if (!txPrebuild.txHex) {
149-
throw new Error(`txPrebuild.txHex not set`);
171+
throw new TxIntentMismatchError(`txPrebuild.txHex not set`, reqId || '', [txParams], '');
150172
}
151173
const inputs = isPsbt
152174
? getPsbtTxInputs(txPrebuild.txHex, coin.network).map((v) => ({
@@ -163,7 +185,7 @@ export async function verifyTransaction<TNumber extends bigint | number>(
163185
const fee = inputAmount - outputAmount;
164186

165187
if (fee < 0) {
166-
throw new Error(
188+
throwTxMismatch(
167189
`attempting to spend ${outputAmount} satoshis, which exceeds the input amount (${inputAmount} satoshis) by ${-fee}`
168190
);
169191
}

0 commit comments

Comments
 (0)