Skip to content

Commit 641406f

Browse files
committed
feat: improve transaction verification error handling
Replace custom validation errors with SDK's TxIntentMismatchRecipientError to provide more detailed information about mismatched transaction outputs. This enhances error reporting by including specific details about missing or unexpected outputs, helping users understand what differs between their intent and the actual transaction. Co-authored-by: llm-git <llm-git@ttll.de> Ticket: BTC-2579 TICKET: WP-6189
1 parent 6e7a8b7 commit 641406f

File tree

2 files changed

+72
-9
lines changed

2 files changed

+72
-9
lines changed

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

Lines changed: 65 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,12 @@
11
import * as utxolib from '@bitgo/utxo-lib';
2-
import { ITransactionRecipient, TxIntentMismatchError, VerifyTransactionOptions } from '@bitgo/sdk-core';
2+
import {
3+
IRequestTracer,
4+
ITransactionRecipient,
5+
MismatchedRecipient,
6+
TxIntentMismatchError,
7+
TxIntentMismatchRecipientError,
8+
VerifyTransactionOptions,
9+
} from '@bitgo/sdk-core';
310
import { DescriptorMap } from '@bitgo/utxo-core/descriptor';
411

512
import { AbstractUtxoCoin, BaseOutput, BaseParsedTransactionOutputs } from '../../abstractUtxoCoin';
@@ -57,6 +64,52 @@ export function assertValidTransaction(
5764
assertExpectedOutputDifference(toBaseParsedTransactionOutputsFromPsbt(psbt, descriptors, recipients, network));
5865
}
5966

67+
/**
68+
* Convert ValidationError to TxIntentMismatchRecipientError with structured data
69+
*
70+
* This preserves the structured error information from the original ValidationError
71+
* by extracting the mismatched outputs and converting them to the standardized format.
72+
* The original error is preserved as the `cause` for debugging purposes.
73+
*/
74+
function convertValidationErrorToTxIntentMismatch(
75+
error: AggregateValidationError,
76+
reqId: string | IRequestTracer | undefined,
77+
txParams: VerifyTransactionOptions['txParams'],
78+
txHex: string | undefined
79+
): TxIntentMismatchRecipientError {
80+
const mismatchedRecipients: MismatchedRecipient[] = [];
81+
82+
for (const err of error.errors) {
83+
if (err instanceof ErrorMissingOutputs) {
84+
mismatchedRecipients.push(
85+
...err.missingOutputs.map((output) => ({
86+
address: output.address,
87+
amount: output.amount.toString(),
88+
}))
89+
);
90+
} else if (err instanceof ErrorImplicitExternalOutputs) {
91+
mismatchedRecipients.push(
92+
...err.implicitExternalOutputs.map((output) => ({
93+
address: output.address,
94+
amount: output.amount.toString(),
95+
}))
96+
);
97+
}
98+
}
99+
100+
const txIntentError = new TxIntentMismatchRecipientError(
101+
error.message,
102+
reqId,
103+
[txParams],
104+
txHex,
105+
mismatchedRecipients
106+
);
107+
// Preserve the original structured error as the cause for debugging
108+
// See: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Error/cause
109+
(txIntentError as Error & { cause?: Error }).cause = error;
110+
return txIntentError;
111+
}
112+
60113
/**
61114
* Wrapper around assertValidTransaction that returns a boolean instead of throwing.
62115
*
@@ -68,6 +121,7 @@ export function assertValidTransaction(
68121
* @param descriptorMap
69122
* @returns {boolean} True if verification passes
70123
* @throws {TxIntentMismatchError} if transaction validation fails
124+
* @throws {TxIntentMismatchRecipientError} if transaction recipients don't match user intent
71125
*/
72126
export async function verifyTransaction(
73127
coin: AbstractUtxoCoin,
@@ -83,6 +137,15 @@ export async function verifyTransaction(
83137
params.txPrebuild.txHex
84138
);
85139
}
86-
assertValidTransaction(tx, descriptorMap, params.txParams.recipients ?? [], tx.network);
140+
141+
try {
142+
assertValidTransaction(tx, descriptorMap, params.txParams.recipients ?? [], tx.network);
143+
} catch (error) {
144+
if (error instanceof AggregateValidationError) {
145+
throw convertValidationErrorToTxIntentMismatch(error, params.reqId, params.txParams, params.txPrebuild.txHex);
146+
}
147+
throw error;
148+
}
149+
87150
return true;
88151
}

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

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -55,11 +55,11 @@ export async function verifyTransaction<TNumber extends bigint | number>(
5555
};
5656

5757
if (!_.isUndefined(verification.disableNetworking) && !_.isBoolean(verification.disableNetworking)) {
58-
throwTxMismatch('verification.disableNetworking must be a boolean');
58+
throw new TypeError('verification.disableNetworking must be a boolean');
5959
}
6060
const isPsbt = txPrebuild.txHex && utxolib.bitgo.isPsbt(txPrebuild.txHex);
6161
if (isPsbt && txPrebuild.txInfo?.unspents) {
62-
throwTxMismatch('should not have unspents in txInfo for psbt');
62+
throw new Error('should not have unspents in txInfo for psbt');
6363
}
6464
const disableNetworking = !!verification.disableNetworking;
6565
const parsedTransaction: ParsedTransaction<TNumber> = await coin.parseTransaction<TNumber>({
@@ -97,7 +97,7 @@ export async function verifyTransaction<TNumber extends bigint | number>(
9797
const isBackupKeySignatureValid = verify(keychains.backup, keySignatures.backupPub);
9898
const isBitgoKeySignatureValid = verify(keychains.bitgo, keySignatures.bitgoPub);
9999
if (!isBackupKeySignatureValid || !isBitgoKeySignatureValid) {
100-
throwTxMismatch('secondary public key signatures invalid');
100+
throw new Error('secondary public key signatures invalid');
101101
}
102102
debug('successfully verified backup and bitgo key signatures');
103103
} else if (!disableNetworking) {
@@ -108,11 +108,11 @@ export async function verifyTransaction<TNumber extends bigint | number>(
108108

109109
if (parsedTransaction.needsCustomChangeKeySignatureVerification) {
110110
if (!keychains.user || !userPublicKeyVerified) {
111-
throwTxMismatch('transaction requires verification of user public key, but it was unable to be verified');
111+
throw new Error('transaction requires verification of user public key, but it was unable to be verified');
112112
}
113113
const customChangeKeySignaturesVerified = verifyCustomChangeKeySignatures(parsedTransaction, keychains.user);
114114
if (!customChangeKeySignaturesVerified) {
115-
throwTxMismatch(
115+
throw new Error(
116116
'transaction requires verification of custom change key signatures, but they were unable to be verified'
117117
);
118118
}
@@ -168,7 +168,7 @@ export async function verifyTransaction<TNumber extends bigint | number>(
168168

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

187187
if (fee < 0) {
188-
throwTxMismatch(
188+
throw new Error(
189189
`attempting to spend ${outputAmount} satoshis, which exceeds the input amount (${inputAmount} satoshis) by ${-fee}`
190190
);
191191
}

0 commit comments

Comments
 (0)