Skip to content

Commit 74a940f

Browse files
fix(abstract-utxo): handle explicit internal recipients correctly
Previously, we only considered the external outputs of the transaction prebuild when calculating the output differences. When a user explicitly specified an internal recipient, this triggered a missing output error. We now compare the full output list of the prebuild with the expected outputs. TICKET: BTC-1791
1 parent 12281b2 commit 74a940f

File tree

5 files changed

+81
-52
lines changed

5 files changed

+81
-52
lines changed

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

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -40,9 +40,8 @@ function parseOutputsWithPsbt(
4040
recipientOutputs: RecipientOutput[]
4141
): ParsedOutputs {
4242
const parsed = coreDescriptors.parse(psbt, descriptorMap, psbt.network);
43-
const externalOutputs = parsed.outputs.filter((o) => o.scriptId === undefined);
4443
const changeOutputs = parsed.outputs.filter((o) => o.scriptId !== undefined);
45-
const outputDiffs = outputDifferencesWithExpected(externalOutputs, recipientOutputs);
44+
const outputDiffs = outputDifferencesWithExpected(parsed.outputs, recipientOutputs);
4645
return {
4746
outputs: parsed.outputs,
4847
changeOutputs,
@@ -72,9 +71,11 @@ function toBaseOutputs(
7271
export type ParsedOutputsBigInt = BaseParsedTransactionOutputs<bigint, BaseOutput<bigint | 'max'>>;
7372

7473
function toBaseParsedTransactionOutputs(
75-
{ outputs, changeOutputs, explicitExternalOutputs, implicitExternalOutputs, missingOutputs }: ParsedOutputs,
74+
{ outputs, changeOutputs, explicitOutputs, implicitOutputs, missingOutputs }: ParsedOutputs,
7675
network: utxolib.Network
7776
): ParsedOutputsBigInt {
77+
const explicitExternalOutputs = explicitOutputs.filter((o) => o.scriptId === undefined);
78+
const implicitExternalOutputs = implicitOutputs.filter((o) => o.scriptId === undefined);
7879
return {
7980
outputs: toBaseOutputs(outputs, network),
8081
changeOutputs: toBaseOutputs(changeOutputs, network),

modules/abstract-utxo/src/transaction/outputDifference.ts

Lines changed: 11 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -42,33 +42,33 @@ export function outputDifference<A extends ActualOutput | ExpectedOutput, B exte
4242

4343
export type OutputDifferenceWithExpected<TActual extends ActualOutput, TExpected extends ExpectedOutput> = {
4444
/** These are the external outputs that were expected and found in the transaction. */
45-
explicitExternalOutputs: TActual[];
45+
explicitOutputs: TActual[];
4646
/**
4747
* These are the surprise external outputs that were not explicitly specified in the transaction.
4848
* They can be PayGo fees.
4949
*/
50-
implicitExternalOutputs: TActual[];
50+
implicitOutputs: TActual[];
5151
/**
5252
* These are the outputs that were expected to be in the transaction but were not found.
5353
*/
5454
missingOutputs: TExpected[];
5555
};
5656

5757
/**
58-
* @param actualExternalOutputs - external outputs in the transaction
59-
* @param expectedExternalOutputs - external outputs that were expected to be in the transaction
58+
* @param actualOutputs - external outputs in the transaction
59+
* @param expectedOutputs - external outputs that were expected to be in the transaction
6060
* @returns the difference between the actual and expected external outputs
6161
*/
6262
export function outputDifferencesWithExpected<TActual extends ActualOutput, TExpected extends ExpectedOutput>(
63-
actualExternalOutputs: TActual[],
64-
expectedExternalOutputs: TExpected[]
63+
actualOutputs: TActual[],
64+
expectedOutputs: TExpected[]
6565
): OutputDifferenceWithExpected<TActual, TExpected> {
66-
const implicitExternalOutputs = outputDifference(actualExternalOutputs, expectedExternalOutputs);
67-
const explicitExternalOutputs = outputDifference(actualExternalOutputs, implicitExternalOutputs);
68-
const missingOutputs = outputDifference(expectedExternalOutputs, actualExternalOutputs);
66+
const implicitOutputs = outputDifference(actualOutputs, expectedOutputs);
67+
const explicitOutputs = outputDifference(actualOutputs, implicitOutputs);
68+
const missingOutputs = outputDifference(expectedOutputs, actualOutputs);
6969
return {
70-
explicitExternalOutputs,
71-
implicitExternalOutputs,
70+
explicitOutputs,
71+
implicitOutputs,
7272
missingOutputs,
7373
};
7474
}

modules/abstract-utxo/test/transaction/descriptor/fixtures/parseWithInternalRecipient.json

Lines changed: 1 addition & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -28,11 +28,5 @@
2828
}
2929
],
3030
"implicitExternalSpendAmount": "400000",
31-
"missingOutputs": [
32-
{
33-
"address": "bc1q2yau645jl7k577lmanqn9a0ulcgcqm0wrrmx09dppd3kcwguvyzqk86umj",
34-
"amount": "400000",
35-
"external": true
36-
}
37-
]
31+
"missingOutputs": []
3832
}

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

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -76,8 +76,8 @@ describe('outputDifference', function () {
7676
) {
7777
const result = outputDifferencesWithExpected(outputs, recipients);
7878
assert.deepStrictEqual(result, {
79-
explicitExternalOutputs: expected.explicit,
80-
implicitExternalOutputs: expected.implicit,
79+
explicitOutputs: expected.explicit,
80+
implicitOutputs: expected.implicit,
8181
missingOutputs: expected.missing,
8282
});
8383
}

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

Lines changed: 63 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,11 @@
11
import assert from 'assert';
22

3-
import { mockPsbtDefaultWithDescriptorTemplate } from '../../core/descriptor/psbt/mock.utils';
3+
import * as utxolib from '@bitgo/utxo-lib';
4+
import { Descriptor } from '@bitgo/wasm-miniscript';
5+
6+
import { mockPsbtDefault } from '../../core/descriptor/psbt/mock.utils';
47
import { ParsedOutputsBigInt, toBaseParsedTransactionOutputsFromPsbt } from '../../../src/transaction/descriptor/parse';
5-
import { getDefaultXPubs, getDescriptorMap } from '../../core/descriptor/descriptor.utils';
8+
import { getDefaultXPubs, getDescriptor, getDescriptorMap } from '../../core/descriptor/descriptor.utils';
69
import { toPlainObject } from '../../core/toPlainObject.utils';
710
import {
811
AggregateValidationError,
@@ -12,6 +15,7 @@ import {
1215
} from '../../../src/transaction/descriptor/verifyTransaction';
1316
import { toAmountType } from '../../../src/transaction/descriptor/parseToAmountType';
1417
import { BaseOutput } from '../../../src';
18+
import { createAddressFromDescriptor } from '../../../src/core/descriptor';
1519

1620
import { getFixtureRoot } from './fixtures.utils';
1721

@@ -46,9 +50,23 @@ function toMaxOutput(output: OutputWithValue): OutputWithValue<'max'> {
4650
}
4751

4852
describe('parse', function () {
49-
const psbt = mockPsbtDefaultWithDescriptorTemplate('Wsh2Of3');
53+
const descriptorSelf = getDescriptor('Wsh2Of3', getDefaultXPubs('a'));
54+
const descriptorOther = getDescriptor('Wsh2Of3', getDefaultXPubs('b'));
55+
const psbt = mockPsbtDefault({ descriptorSelf, descriptorOther });
56+
57+
function recipient(descriptor: Descriptor, index: number, value = 1000) {
58+
return { value, address: createAddressFromDescriptor(descriptor, index, utxolib.networks.bitcoin) };
59+
}
60+
61+
function internalRecipient(index: number, value?: number): OutputWithValue {
62+
return recipient(descriptorSelf, index, value);
63+
}
5064

51-
function getBaseParsedTransaction(recipients: OutputWithValue[]): ParsedOutputsBigInt {
65+
function externalRecipient(index: number, value?: number): OutputWithValue {
66+
return recipient(descriptorOther, index, value);
67+
}
68+
69+
function getBaseParsedTransaction(psbt: utxolib.bitgo.UtxoPsbt, recipients: OutputWithValue[]): ParsedOutputsBigInt {
5270
return toBaseParsedTransactionOutputsFromPsbt(
5371
psbt,
5472
getDescriptorMap('Wsh2Of3', getDefaultXPubs('a')),
@@ -59,27 +77,30 @@ describe('parse', function () {
5977

6078
describe('toBase', function () {
6179
it('should return the correct BaseParsedTransactionOutputs', async function () {
62-
await assertEqualFixture('parseWithoutRecipients.json', toPlainObject(getBaseParsedTransaction([])));
80+
await assertEqualFixture('parseWithoutRecipients.json', toPlainObject(getBaseParsedTransaction(psbt, [])));
6381
await assertEqualFixture(
6482
'parseWithExternalRecipient.json',
65-
toPlainObject(getBaseParsedTransaction([psbt.txOutputs[0]]))
83+
toPlainObject(getBaseParsedTransaction(psbt, [psbt.txOutputs[0]]))
6684
);
6785
await assertEqualFixture(
6886
'parseWithInternalRecipient.json',
69-
toPlainObject(getBaseParsedTransaction([psbt.txOutputs[1]]))
87+
toPlainObject(getBaseParsedTransaction(psbt, [psbt.txOutputs[1]]))
7088
);
7189
await assertEqualFixture(
7290
'parseWithExternalRecipient.json',
7391
// max recipient: ignore actual value
74-
toPlainObject(getBaseParsedTransaction([toMaxOutput(psbt.txOutputs[0])]))
92+
toPlainObject(getBaseParsedTransaction(psbt, [toMaxOutput(psbt.txOutputs[0])]))
7593
);
7694
});
7795

7896
function assertEqualValidationError(actual: unknown, expected: AggregateValidationError) {
97+
function normErrors(e: Error[]): Error[] {
98+
return e.map((e) => ({ ...e, stack: undefined }));
99+
}
79100
if (actual instanceof AggregateValidationError) {
80-
assert.deepStrictEqual(actual.errors, expected.errors);
101+
assert.deepStrictEqual(normErrors(actual.errors), normErrors(expected.errors));
81102
} else {
82-
throw new Error('unexpected error type');
103+
throw new Error('unexpected error type: ' + actual);
83104
}
84105
}
85106

@@ -90,35 +111,48 @@ describe('parse', function () {
90111
});
91112
}
92113

93-
it('should throw expected errors', function () {
114+
function implicitOutputError(output: OutputWithValue, { external = true } = {}): ErrorImplicitExternalOutputs {
115+
return new ErrorImplicitExternalOutputs([{ ...toBaseOutputBigInt(output), external }]);
116+
}
117+
118+
function missingOutputError(output: OutputWithValue, { external = true } = {}): ErrorMissingOutputs {
119+
return new ErrorMissingOutputs([{ ...toBaseOutputBigInt(output), external }]);
120+
}
121+
122+
it('should throw expected error: no recipient requested', function () {
94123
assertValidationError(
95-
() => assertExpectedOutputDifference(getBaseParsedTransaction([])),
96-
new AggregateValidationError([
97-
new ErrorImplicitExternalOutputs([{ ...toBaseOutputBigInt(psbt.txOutputs[0]), external: true }]),
98-
])
124+
() => assertExpectedOutputDifference(getBaseParsedTransaction(psbt, [])),
125+
new AggregateValidationError([implicitOutputError(psbt.txOutputs[0])])
99126
);
127+
});
100128

129+
it('should throw expected error: only internal recipient requested', function () {
101130
assertValidationError(
102-
() => assertExpectedOutputDifference(getBaseParsedTransaction([])),
103-
new AggregateValidationError([
104-
new ErrorImplicitExternalOutputs([{ ...toBaseOutputBigInt(psbt.txOutputs[0]), external: true }]),
105-
])
131+
() => assertExpectedOutputDifference(getBaseParsedTransaction(psbt, [psbt.txOutputs[1]])),
132+
new AggregateValidationError([implicitOutputError(psbt.txOutputs[0])])
106133
);
134+
});
107135

136+
it('should throw expected error: only internal max recipient requested', function () {
108137
assertValidationError(
109-
() => assertExpectedOutputDifference(getBaseParsedTransaction([psbt.txOutputs[1]])),
110-
new AggregateValidationError([
111-
new ErrorMissingOutputs([{ ...toBaseOutputBigInt(psbt.txOutputs[1]), external: true }]),
112-
new ErrorImplicitExternalOutputs([{ ...toBaseOutputBigInt(psbt.txOutputs[0]), external: true }]),
113-
])
138+
() => assertExpectedOutputDifference(getBaseParsedTransaction(psbt, [toMaxOutput(psbt.txOutputs[1])])),
139+
new AggregateValidationError([implicitOutputError(psbt.txOutputs[0])])
114140
);
141+
});
142+
143+
it('should throw expected error: swapped recipient', function () {
144+
const recipient = externalRecipient(99);
145+
assertValidationError(
146+
() => assertExpectedOutputDifference(getBaseParsedTransaction(psbt, [recipient])),
147+
new AggregateValidationError([missingOutputError(recipient), implicitOutputError(psbt.txOutputs[0])])
148+
);
149+
});
115150

151+
it('should throw expected error: missing internal recipient', function () {
152+
const recipient = internalRecipient(99);
116153
assertValidationError(
117-
() => assertExpectedOutputDifference(getBaseParsedTransaction([toMaxOutput(psbt.txOutputs[1])])),
118-
new AggregateValidationError([
119-
new ErrorMissingOutputs([{ ...toBaseOutputBigInt(toMaxOutput(psbt.txOutputs[1])), external: true }]),
120-
new ErrorImplicitExternalOutputs([{ ...toBaseOutputBigInt(psbt.txOutputs[0]), external: true }]),
121-
])
154+
() => assertExpectedOutputDifference(getBaseParsedTransaction(psbt, [recipient])),
155+
new AggregateValidationError([missingOutputError(recipient), implicitOutputError(psbt.txOutputs[0])])
122156
);
123157
});
124158
});

0 commit comments

Comments
 (0)