Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
78 changes: 46 additions & 32 deletions modules/abstract-eth/src/abstractEthLikeNewCoins.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ import {
PresignTransactionOptions as BasePresignTransactionOptions,
Recipient,
SignTransactionOptions as BaseSignTransactionOptions,
TxIntentMismatchError,
TransactionParams,
TransactionPrebuild as BaseTransactionPrebuild,
TransactionRecipient,
Expand Down Expand Up @@ -2767,23 +2768,30 @@ export abstract class AbstractEthLikeNewCoins extends AbstractEthLikeCoin {
* @param {TransactionPrebuild} params.txPrebuild - prebuild object returned by server
* @param {Wallet} params.wallet - Wallet object to obtain keys to verify against
* @returns {boolean}
* @throws {TxIntentMismatchError} if transaction validation fails
*/
async verifyTssTransaction(params: VerifyEthTransactionOptions): Promise<boolean> {
const { txParams, txPrebuild, wallet } = params;

// Helper to throw TxIntentMismatchError with consistent context
const throwTxMismatch = (message: string): never => {
throw new TxIntentMismatchError(message, undefined, [txParams], txPrebuild?.txHex);
};

if (
!txParams?.recipients &&
!(
txParams.prebuildTx?.consolidateId ||
(txParams.type && ['acceleration', 'fillNonce', 'transferToken', 'tokenApproval'].includes(txParams.type))
)
) {
throw new Error(`missing txParams`);
throwTxMismatch(`missing txParams`);
}
if (!wallet || !txPrebuild) {
throw new Error(`missing params`);
throwTxMismatch(`missing params`);
}
if (txParams.hop && txParams.recipients && txParams.recipients.length > 1) {
throw new Error(`tx cannot be both a batch and hop transaction`);
throwTxMismatch(`tx cannot be both a batch and hop transaction`);
}

if (txParams.type && ['transfer'].includes(txParams.type)) {
Expand All @@ -2798,21 +2806,21 @@ export abstract class AbstractEthLikeNewCoins extends AbstractEthLikeCoin {
const txJson = tx.toJson();
if (txJson.data === '0x') {
if (expectedAmount !== txJson.value) {
throw new Error('the transaction amount in txPrebuild does not match the value given by client');
throwTxMismatch('the transaction amount in txPrebuild does not match the value given by client');
}
if (expectedDestination.toLowerCase() !== txJson.to.toLowerCase()) {
throw new Error('destination address does not match with the recipient address');
throwTxMismatch('destination address does not match with the recipient address');
}
} else if (txJson.data.startsWith('0xa9059cbb')) {
const [recipientAddress, amount] = getRawDecoded(
['address', 'uint256'],
getBufferedByteCode('0xa9059cbb', txJson.data)
);
if (expectedAmount !== amount.toString()) {
throw new Error('the transaction amount in txPrebuild does not match the value given by client');
throwTxMismatch('the transaction amount in txPrebuild does not match the value given by client');
}
if (expectedDestination.toLowerCase() !== addHexPrefix(recipientAddress.toString()).toLowerCase()) {
throw new Error('destination address does not match with the recipient address');
throwTxMismatch('destination address does not match with the recipient address');
}
}
}
Expand All @@ -2829,6 +2837,7 @@ export abstract class AbstractEthLikeNewCoins extends AbstractEthLikeCoin {
* @param {TransactionPrebuild} params.txPrebuild - prebuild object returned by server
* @param {Wallet} params.wallet - Wallet object to obtain keys to verify against
* @returns {boolean}
* @throws {TxIntentMismatchError} if transaction validation fails
*/
async verifyTransaction(params: VerifyEthTransactionOptions): Promise<boolean> {
const ethNetwork = this.getNetwork();
Expand All @@ -2838,21 +2847,29 @@ export abstract class AbstractEthLikeNewCoins extends AbstractEthLikeCoin {
return this.verifyTssTransaction(params);
}

// Helper to throw TxIntentMismatchError with consistent context
const throwTxMismatch = (message: string): never => {
throw new TxIntentMismatchError(message, undefined, [txParams], txPrebuild?.txHex);
};

if (!txParams?.recipients || !txPrebuild?.recipients || !wallet) {
throw new Error(`missing params`);
throwTxMismatch(`missing params`);
}
if (txParams.hop && txParams.recipients.length > 1) {
throw new Error(`tx cannot be both a batch and hop transaction`);

const recipients = txParams.recipients!;

if (txParams.hop && recipients.length > 1) {
throwTxMismatch(`tx cannot be both a batch and hop transaction`);
}
if (txPrebuild.recipients.length > 1) {
throw new Error(
throwTxMismatch(
`${this.getChain()} doesn't support sending to more than 1 destination address within a single transaction. Try again, using only a single recipient.`
);
}
if (txParams.hop && txPrebuild.hopTransaction) {
// Check recipient amount for hop transaction
if (txParams.recipients.length !== 1) {
throw new Error(`hop transaction only supports 1 recipient but ${txParams.recipients.length} found`);
if (recipients.length !== 1) {
throwTxMismatch(`hop transaction only supports 1 recipient but ${recipients.length} found`);
}

// Check tx sends to hop address
Expand All @@ -2862,33 +2879,33 @@ 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()) {
throw new Error('recipient address of txPrebuild does not match hop address');
throwTxMismatch('recipient address of txPrebuild does not match hop address');
}

// Convert TransactionRecipient array to Recipient array
const recipients: Recipient[] = txParams.recipients.map((r) => {
const hopRecipients: Recipient[] = recipients.map((r) => {
return {
address: r.address,
amount: typeof r.amount === 'number' ? r.amount.toString() : r.amount,
};
});

// Check destination address and amount
await this.validateHopPrebuild(wallet, txPrebuild.hopTransaction, { recipients });
} else if (txParams.recipients.length > 1) {
await this.validateHopPrebuild(wallet, txPrebuild.hopTransaction, { recipients: hopRecipients });
} else if (recipients.length > 1) {
// Check total amount for batch transaction
if (txParams.tokenName) {
const expectedTotalAmount = new BigNumber(0);
if (!expectedTotalAmount.isEqualTo(txPrebuild.recipients[0].amount)) {
throw new Error('batch token transaction amount in txPrebuild should be zero for token transfers');
throwTxMismatch('batch token transaction amount in txPrebuild should be zero for token transfers');
}
} else {
let expectedTotalAmount = new BigNumber(0);
for (let i = 0; i < txParams.recipients.length; i++) {
expectedTotalAmount = expectedTotalAmount.plus(txParams.recipients[i].amount);
for (let i = 0; i < recipients.length; i++) {
expectedTotalAmount = expectedTotalAmount.plus(recipients[i].amount);
}
if (!expectedTotalAmount.isEqualTo(txPrebuild.recipients[0].amount)) {
throw new Error(
throwTxMismatch(
'batch transaction amount in txPrebuild received from BitGo servers does not match txParams supplied by client'
);
}
Expand All @@ -2900,29 +2917,26 @@ export abstract class AbstractEthLikeNewCoins extends AbstractEthLikeCoin {
!batcherContractAddress ||
batcherContractAddress.toLowerCase() !== txPrebuild.recipients[0].address.toLowerCase()
) {
throw new Error('recipient address of txPrebuild does not match batcher address');
throwTxMismatch('recipient address of txPrebuild does not match batcher address');
}
} else {
// Check recipient address and amount for normal transaction
if (txParams.recipients.length !== 1) {
throw new Error(`normal transaction only supports 1 recipient but ${txParams.recipients.length} found`);
if (recipients.length !== 1) {
throwTxMismatch(`normal transaction only supports 1 recipient but ${recipients.length} found`);
}
const expectedAmount = new BigNumber(txParams.recipients[0].amount);
const expectedAmount = new BigNumber(recipients[0].amount);
if (!expectedAmount.isEqualTo(txPrebuild.recipients[0].amount)) {
throw new Error(
throwTxMismatch(
'normal transaction amount in txPrebuild received from BitGo servers does not match txParams supplied by client'
);
}
if (
this.isETHAddress(txParams.recipients[0].address) &&
txParams.recipients[0].address !== txPrebuild.recipients[0].address
) {
throw new Error('destination address in normal txPrebuild does not match that in txParams supplied by client');
if (this.isETHAddress(recipients[0].address) && recipients[0].address !== txPrebuild.recipients[0].address) {
throwTxMismatch('destination address in normal txPrebuild does not match that in txParams supplied by client');
}
}
// Check coin is correct for all transaction types
if (!this.verifyCoin(txPrebuild)) {
throw new Error(`coin in txPrebuild did not match that in txParams supplied by client`);
throwTxMismatch(`coin in txPrebuild did not match that in txParams supplied by client`);
}
return true;
}
Expand Down
1 change: 1 addition & 0 deletions modules/abstract-utxo/src/abstractUtxoCoin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -632,6 +632,7 @@ export abstract class AbstractUtxoCoin extends BaseCoin {
* @param params.verification.keychains Pass keychains manually rather than fetching them by id
* @param params.verification.addresses Address details to pass in for out-of-band verification
* @returns {boolean}
* @throws {TxIntentMismatchError} if transaction validation fails
*/
async verifyTransaction<TNumber extends number | bigint = number>(
params: VerifyTransactionOptions<TNumber>
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import * as utxolib from '@bitgo/utxo-lib';
import { ITransactionRecipient, VerifyTransactionOptions } from '@bitgo/sdk-core';
import { ITransactionRecipient, TxIntentMismatchError, VerifyTransactionOptions } from '@bitgo/sdk-core';
import { DescriptorMap } from '@bitgo/utxo-core/descriptor';

import { AbstractUtxoCoin, BaseOutput, BaseParsedTransactionOutputs } from '../../abstractUtxoCoin';
Expand Down Expand Up @@ -66,6 +66,8 @@ export function assertValidTransaction(
* @param coin
* @param params
* @param descriptorMap
* @returns {boolean} True if verification passes
* @throws {TxIntentMismatchError} if transaction validation fails
*/
export async function verifyTransaction(
coin: AbstractUtxoCoin,
Expand All @@ -74,7 +76,12 @@ export async function verifyTransaction(
): Promise<boolean> {
const tx = coin.decodeTransactionFromPrebuild(params.txPrebuild);
if (!(tx instanceof utxolib.bitgo.UtxoPsbt)) {
throw new Error('unexpected transaction type');
throw new TxIntentMismatchError(
'unexpected transaction type',
params.reqId,
[params.txParams],
params.txPrebuild.txHex
);
}
assertValidTransaction(tx, descriptorMap, params.txParams.recipients ?? [], tx.network);
return true;
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import buildDebug from 'debug';
import _ from 'lodash';
import BigNumber from 'bignumber.js';
import { BitGoBase } from '@bitgo/sdk-core';
import { BitGoBase, TxIntentMismatchError } from '@bitgo/sdk-core';
import * as utxolib from '@bitgo/utxo-lib';

import { AbstractUtxoCoin, Output, ParsedTransaction, VerifyTransactionOptions } from '../../abstractUtxoCoin';
Expand All @@ -25,19 +25,41 @@ function getPayGoLimit(allowPaygoOutput?: boolean): number {
return 0.015;
}

/**
* Verify that a transaction prebuild complies with the original intention for fixed-script wallets
*
* This implementation handles transaction verification for traditional UTXO coins using fixed scripts
* (non-descriptor wallets). It validates keychains, signatures, outputs, and amounts.
*
* @param coin - The UTXO coin instance
* @param bitgo - BitGo API instance for network calls
* @param params - Verification parameters
* @param params.txParams - Transaction parameters passed to send
* @param params.txPrebuild - Prebuild object returned by server
* @param params.wallet - Wallet object to obtain keys to verify against
* @param params.verification - Verification options (disableNetworking, keychains, addresses)
* @param params.reqId - Optional request ID for logging
* @returns {boolean} True if verification passes
* @throws {TxIntentMismatchError} if transaction validation fails
*/
export async function verifyTransaction<TNumber extends bigint | number>(
coin: AbstractUtxoCoin,
bitgo: BitGoBase,
params: VerifyTransactionOptions<TNumber>
): Promise<boolean> {
const { txParams, txPrebuild, wallet, verification = {}, reqId } = params;

// Helper to throw TxIntentMismatchError with consistent context
const throwTxMismatch = (message: string): never => {
throw new TxIntentMismatchError(message, reqId, [txParams], txPrebuild.txHex);
};

if (!_.isUndefined(verification.disableNetworking) && !_.isBoolean(verification.disableNetworking)) {
throw new Error('verification.disableNetworking must be a boolean');
throwTxMismatch('verification.disableNetworking must be a boolean');
}
const isPsbt = txPrebuild.txHex && utxolib.bitgo.isPsbt(txPrebuild.txHex);
if (isPsbt && txPrebuild.txInfo?.unspents) {
throw new Error('should not have unspents in txInfo for psbt');
throwTxMismatch('should not have unspents in txInfo for psbt');
}
const disableNetworking = !!verification.disableNetworking;
const parsedTransaction: ParsedTransaction<TNumber> = await coin.parseTransaction<TNumber>({
Expand All @@ -64,7 +86,7 @@ export async function verifyTransaction<TNumber extends bigint | number>(
if (!_.isEmpty(keySignatures)) {
const verify = (key, pub) => {
if (!keychains.user || !keychains.user.pub) {
throw new Error('missing user keychain');
throwTxMismatch('missing user keychain');
}
return verifyKeySignature({
userKeychain: keychains.user as { pub: string },
Expand All @@ -75,7 +97,7 @@ export async function verifyTransaction<TNumber extends bigint | number>(
const isBackupKeySignatureValid = verify(keychains.backup, keySignatures.backupPub);
const isBitgoKeySignatureValid = verify(keychains.bitgo, keySignatures.bitgoPub);
if (!isBackupKeySignatureValid || !isBitgoKeySignatureValid) {
throw new Error('secondary public key signatures invalid');
throwTxMismatch('secondary public key signatures invalid');
}
debug('successfully verified backup and bitgo key signatures');
} else if (!disableNetworking) {
Expand All @@ -86,11 +108,11 @@ export async function verifyTransaction<TNumber extends bigint | number>(

if (parsedTransaction.needsCustomChangeKeySignatureVerification) {
if (!keychains.user || !userPublicKeyVerified) {
throw new Error('transaction requires verification of user public key, but it was unable to be verified');
throwTxMismatch('transaction requires verification of user public key, but it was unable to be verified');
}
const customChangeKeySignaturesVerified = verifyCustomChangeKeySignatures(parsedTransaction, keychains.user);
if (!customChangeKeySignaturesVerified) {
throw new Error(
throwTxMismatch(
'transaction requires verification of custom change key signatures, but they were unable to be verified'
);
}
Expand All @@ -100,7 +122,7 @@ export async function verifyTransaction<TNumber extends bigint | number>(
const missingOutputs = parsedTransaction.missingOutputs;
if (missingOutputs.length !== 0) {
// there are some outputs in the recipients list that have not made it into the actual transaction
throw new Error('expected outputs missing in transaction prebuild');
throwTxMismatch('expected outputs missing in transaction prebuild');
}

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

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

if (fee < 0) {
throw new Error(
throwTxMismatch(
`attempting to spend ${outputAmount} satoshis, which exceeds the input amount (${inputAmount} satoshis) by ${-fee}`
);
}
Expand Down
Loading