Skip to content

Commit 727d46f

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 727d46f

File tree

3 files changed

+81
-81
lines changed

3 files changed

+81
-81
lines changed

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

Lines changed: 37 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -1,50 +1,54 @@
11
import * as utxolib from '@bitgo/utxo-lib';
2-
import { ITransactionRecipient, TxIntentMismatchError, VerifyTransactionOptions } from '@bitgo/sdk-core';
2+
import {
3+
ITransactionRecipient,
4+
MismatchedRecipient,
5+
TxIntentMismatchError,
6+
TxIntentMismatchRecipientError,
7+
VerifyTransactionOptions,
8+
} from '@bitgo/sdk-core';
39
import { DescriptorMap } from '@bitgo/utxo-core/descriptor';
410

511
import { AbstractUtxoCoin, BaseOutput, BaseParsedTransactionOutputs } from '../../abstractUtxoCoin';
612

713
import { toBaseParsedTransactionOutputsFromPsbt } from './parse';
814

9-
export class ValidationError extends Error {
10-
constructor(message: string) {
11-
super(message);
12-
}
13-
}
14-
15-
export class ErrorMissingOutputs extends ValidationError {
16-
constructor(public missingOutputs: BaseOutput<bigint | 'max'>[]) {
17-
super(`missing outputs (count=${missingOutputs.length})`);
18-
}
19-
}
20-
21-
export class ErrorImplicitExternalOutputs extends ValidationError {
22-
constructor(public implicitExternalOutputs: BaseOutput<bigint | 'max'>[]) {
23-
super(`unexpected implicit external outputs (count=${implicitExternalOutputs.length})`);
24-
}
25-
}
26-
27-
export class AggregateValidationError extends ValidationError {
28-
constructor(public errors: ValidationError[]) {
29-
super(`aggregate validation error (count=${errors.length})`);
30-
}
31-
}
32-
15+
/**
16+
* Assert that the transaction outputs match expected outputs
17+
*
18+
* @param parsedOutputs - Parsed transaction outputs including missing and implicit external outputs
19+
* @throws {TxIntentMismatchRecipientError} if outputs don't match expected recipients
20+
*/
3321
export function assertExpectedOutputDifference(
3422
parsedOutputs: BaseParsedTransactionOutputs<bigint, BaseOutput<bigint | 'max'>>
3523
): void {
36-
const errors: ValidationError[] = [];
24+
const errorMessages: string[] = [];
25+
const allMismatchedRecipients: MismatchedRecipient[] = [];
26+
27+
// Check for missing outputs - these are recipients that the user intended but weren't included
3728
if (parsedOutputs.missingOutputs.length > 0) {
38-
errors.push(new ErrorMissingOutputs(parsedOutputs.missingOutputs));
29+
errorMessages.push(`missing outputs (count=${parsedOutputs.missingOutputs.length})`);
30+
const missingRecipients: MismatchedRecipient[] = parsedOutputs.missingOutputs.map((output) => ({
31+
address: output.address,
32+
amount: output.amount.toString(),
33+
}));
34+
allMismatchedRecipients.push(...missingRecipients);
3935
}
36+
37+
// Check for implicit external outputs - these are outputs the user didn't request
4038
if (parsedOutputs.implicitExternalOutputs.length > 0) {
4139
// FIXME: for paygo we need to relax this a little bit
42-
errors.push(new ErrorImplicitExternalOutputs(parsedOutputs.implicitExternalOutputs));
40+
errorMessages.push(`unexpected implicit external outputs (count=${parsedOutputs.implicitExternalOutputs.length})`);
41+
const implicitRecipients: MismatchedRecipient[] = parsedOutputs.implicitExternalOutputs.map((output) => ({
42+
address: output.address,
43+
amount: output.amount.toString(),
44+
}));
45+
allMismatchedRecipients.push(...implicitRecipients);
4346
}
44-
if (errors.length > 0) {
45-
// FIXME(BTC-1688): enable ES2021
46-
// throw new AggregateError(errors);
47-
throw new AggregateValidationError(errors);
47+
48+
// If there are any mismatches, throw a single error with all the details
49+
if (errorMessages.length > 0) {
50+
const message = errorMessages.join(', ');
51+
throw new TxIntentMismatchRecipientError(message, undefined, [], undefined, allMismatchedRecipients);
4852
}
4953
}
5054

@@ -68,6 +72,7 @@ export function assertValidTransaction(
6872
* @param descriptorMap
6973
* @returns {boolean} True if verification passes
7074
* @throws {TxIntentMismatchError} if transaction validation fails
75+
* @throws {TxIntentMismatchRecipientError} if transaction recipients don't match user intent
7176
*/
7277
export async function verifyTransaction(
7378
coin: AbstractUtxoCoin,

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
}

modules/abstract-utxo/test/transaction/descriptor/parse.ts

Lines changed: 37 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -10,14 +10,10 @@ import {
1010
} from '@bitgo/utxo-core/testutil/descriptor';
1111
import { toPlainObject } from '@bitgo/utxo-core/testutil';
1212
import { createAddressFromDescriptor } from '@bitgo/utxo-core/descriptor';
13+
import { TxIntentMismatchRecipientError } from '@bitgo/sdk-core';
1314

1415
import { ParsedOutputsBigInt, toBaseParsedTransactionOutputsFromPsbt } from '../../../src/transaction/descriptor/parse';
15-
import {
16-
AggregateValidationError,
17-
assertExpectedOutputDifference,
18-
ErrorImplicitExternalOutputs,
19-
ErrorMissingOutputs,
20-
} from '../../../src/transaction/descriptor/verifyTransaction';
16+
import { assertExpectedOutputDifference } from '../../../src/transaction/descriptor/verifyTransaction';
2117
import { toAmountType } from '../../../src/transaction/descriptor/parseToAmountType';
2218
import { BaseOutput } from '../../../src';
2319

@@ -38,10 +34,6 @@ function toBaseOutput<TNumber>(output: OutputWithValue, amountType: 'bigint' | '
3834
};
3935
}
4036

41-
function toBaseOutputBigInt(output: OutputWithValue): BaseOutput<bigint> {
42-
return toBaseOutput(output, 'bigint');
43-
}
44-
4537
function toBaseOutputString(output: OutputWithValue): BaseOutput<string> {
4638
return toBaseOutput(output, 'string');
4739
}
@@ -97,66 +89,69 @@ describe('parse', function () {
9789
);
9890
});
9991

100-
function assertEqualValidationError(actual: unknown, expected: AggregateValidationError) {
101-
function normErrors(e: Error[]): Error[] {
102-
return e.map((e) => ({ ...e, stack: undefined }));
103-
}
104-
if (actual instanceof AggregateValidationError) {
105-
assert.deepStrictEqual(normErrors(actual.errors), normErrors(expected.errors));
106-
} else {
107-
throw new Error('unexpected error type: ' + actual);
108-
}
109-
}
110-
111-
function assertValidationError(f: () => void, expected: AggregateValidationError) {
112-
assert.throws(f, (err) => {
113-
assertEqualValidationError(err, expected);
92+
function assertTxIntentMismatchError(
93+
f: () => void,
94+
expectedMessage: string,
95+
expectedMismatchedRecipients: { address?: string; amount: string }[]
96+
) {
97+
assert.throws(f, (err: unknown) => {
98+
if (!(err instanceof TxIntentMismatchRecipientError)) {
99+
const error = err as Error;
100+
throw new Error(
101+
`Expected TxIntentMismatchRecipientError but got ${error.constructor.name}: ${error.message}`
102+
);
103+
}
104+
assert.strictEqual(err.message, expectedMessage);
105+
assert.deepStrictEqual(err.mismatchedRecipients, expectedMismatchedRecipients);
114106
return true;
115107
});
116108
}
117109

118-
function implicitOutputError(output: OutputWithValue, { external = true } = {}): ErrorImplicitExternalOutputs {
119-
return new ErrorImplicitExternalOutputs([{ ...toBaseOutputBigInt(output), external }]);
120-
}
121-
122-
function missingOutputError(output: OutputWithValue, { external = true } = {}): ErrorMissingOutputs {
123-
return new ErrorMissingOutputs([{ ...toBaseOutputBigInt(output), external }]);
124-
}
125-
126110
it('should throw expected error: no recipient requested', function () {
127-
assertValidationError(
111+
assertTxIntentMismatchError(
128112
() => assertExpectedOutputDifference(getBaseParsedTransaction(psbt, [])),
129-
new AggregateValidationError([implicitOutputError(psbt.txOutputs[0])])
113+
'unexpected implicit external outputs (count=1)',
114+
[{ address: psbt.txOutputs[0].address, amount: psbt.txOutputs[0].value.toString() }]
130115
);
131116
});
132117

133118
it('should throw expected error: only internal recipient requested', function () {
134-
assertValidationError(
119+
assertTxIntentMismatchError(
135120
() => assertExpectedOutputDifference(getBaseParsedTransaction(psbt, [psbt.txOutputs[1]])),
136-
new AggregateValidationError([implicitOutputError(psbt.txOutputs[0])])
121+
'unexpected implicit external outputs (count=1)',
122+
[{ address: psbt.txOutputs[0].address, amount: psbt.txOutputs[0].value.toString() }]
137123
);
138124
});
139125

140126
it('should throw expected error: only internal max recipient requested', function () {
141-
assertValidationError(
127+
assertTxIntentMismatchError(
142128
() => assertExpectedOutputDifference(getBaseParsedTransaction(psbt, [toMaxOutput(psbt.txOutputs[1])])),
143-
new AggregateValidationError([implicitOutputError(psbt.txOutputs[0])])
129+
'unexpected implicit external outputs (count=1)',
130+
[{ address: psbt.txOutputs[0].address, amount: psbt.txOutputs[0].value.toString() }]
144131
);
145132
});
146133

147134
it('should throw expected error: swapped recipient', function () {
148135
const recipient = externalRecipient(99);
149-
assertValidationError(
136+
assertTxIntentMismatchError(
150137
() => assertExpectedOutputDifference(getBaseParsedTransaction(psbt, [recipient])),
151-
new AggregateValidationError([missingOutputError(recipient), implicitOutputError(psbt.txOutputs[0])])
138+
'missing outputs (count=1), unexpected implicit external outputs (count=1)',
139+
[
140+
{ address: recipient.address, amount: recipient.value.toString() },
141+
{ address: psbt.txOutputs[0].address, amount: psbt.txOutputs[0].value.toString() },
142+
]
152143
);
153144
});
154145

155146
it('should throw expected error: missing internal recipient', function () {
156147
const recipient = internalRecipient(99);
157-
assertValidationError(
148+
assertTxIntentMismatchError(
158149
() => assertExpectedOutputDifference(getBaseParsedTransaction(psbt, [recipient])),
159-
new AggregateValidationError([missingOutputError(recipient), implicitOutputError(psbt.txOutputs[0])])
150+
'missing outputs (count=1), unexpected implicit external outputs (count=1)',
151+
[
152+
{ address: recipient.address, amount: recipient.value.toString() },
153+
{ address: psbt.txOutputs[0].address, amount: psbt.txOutputs[0].value.toString() },
154+
]
160155
);
161156
});
162157
});

0 commit comments

Comments
 (0)