Skip to content

Commit 1392085

Browse files
fix(abstract-utxo): align descriptor parse with multisig self-payment flow
Use a two-pass approach in parseOutputsWithPsbt to handle self-payments: - Pass 1 (missing): check ALL outputs (including change/internal) against expected recipients so self-payments are not reported as missing - Pass 2 (implicit): only flag EXTERNAL outputs (scriptId=undefined) not in the recipient list as implicit Also adds isImplicitOutput helper to outputDifference.ts and tests for both the helper and the self-payment scenarios. Ticket: VL-5164
1 parent 5b82c98 commit 1392085

4 files changed

Lines changed: 85 additions & 3 deletions

File tree

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

Lines changed: 27 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,12 @@ import { getKeySignatures, toBip32Triple, UtxoNamedKeychains } from '../../keych
77
import { getDescriptorMapFromWallet, getPolicyForEnv } from '../../descriptor';
88
import { IDescriptorWallet } from '../../descriptor/descriptorWallet';
99
import { fromExtendedAddressFormatToScript, toExtendedAddressFormat } from '../recipient';
10-
import { outputDifferencesWithExpected, OutputDifferenceWithExpected } from '../outputDifference';
10+
import {
11+
getMissingOutputs,
12+
isImplicitOutput,
13+
outputDifference,
14+
OutputDifferenceWithExpected,
15+
} from '../outputDifference';
1116
import { UtxoCoinName } from '../../names';
1217
import { sumValues, toWasmPsbt, UtxoLibPsbt } from '../../wasmUtil';
1318

@@ -44,11 +49,30 @@ function parseOutputsWithPsbt(
4449
script: Buffer.from(output.script),
4550
}));
4651
const changeOutputs = outputs.filter((o) => o.scriptId !== undefined);
47-
const outputDiffs = outputDifferencesWithExpected(outputs, recipientOutputs);
52+
53+
/**
54+
* Two-pass approach to handle self-payments (outputs going to the sender's own wallet address):
55+
*
56+
* Pass 1 — missing outputs: Check all PSBT outputs (including change/internal) against the
57+
* expected recipients. This ensures self-payments that are classified as change outputs by the
58+
* descriptor wallet (isChange = true) are still matched against the intent recipients and do not
59+
* appear as missing. This mirrors the multisig (fixedScript) flow where allOutputs includes
60+
* both explanation.outputs and explanation.changeOutputs.
61+
*
62+
* Pass 2 — implicit outputs: Of the outputs NOT in the recipient list, only flag those that go
63+
* to external addresses (scriptId = undefined) as implicit. Internal outputs (scriptId set) that
64+
* are not in recipients are legitimate change outputs and should not be flagged.
65+
*/
66+
const missingOutputs = getMissingOutputs(outputs, recipientOutputs);
67+
const implicitOutputs = outputs.filter((o) => isImplicitOutput(o, recipientOutputs));
68+
const explicitOutputs = outputDifference(outputs, implicitOutputs);
69+
4870
return {
4971
outputs,
5072
changeOutputs,
51-
...outputDiffs,
73+
explicitOutputs,
74+
implicitOutputs,
75+
missingOutputs,
5276
};
5377
}
5478

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

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,21 @@ export function getMissingOutputs<A extends ActualOutput, B extends ExpectedOutp
5050
return outputDifference(expectedOutputs, actualOutputs).filter((o) => !o.optional);
5151
}
5252

53+
/**
54+
* Returns true if the given output was not explicitly requested by the user (i.e., it is not in
55+
* the list of expected outputs). This handles both external and internal (change/self-payment)
56+
* outputs — an output is implicit regardless of whether it is going to a wallet address or not.
57+
*
58+
* Used to identify surprise outputs such as PayGo fees that were added by the server without
59+
* being part of the original send intent.
60+
*/
61+
export function isImplicitOutput<A extends ActualOutput>(
62+
output: A,
63+
expectedOutputs: ExpectedOutput[]
64+
): boolean {
65+
return !expectedOutputs.some((expected) => matchingOutput(output, expected));
66+
}
67+
5368
export type OutputDifferenceWithExpected<TActual extends ActualOutput, TExpected extends ExpectedOutput> = {
5469
/** These are the external outputs that were expected and found in the transaction. */
5570
explicitOutputs: TActual[];

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

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -160,5 +160,33 @@ describe('parse', function () {
160160
new AggregateValidationError([missingOutputError(recipient), implicitOutputError(psbtOutput0)])
161161
);
162162
});
163+
164+
describe('self-payment (recipient address is a wallet address)', function () {
165+
it('should pass when both the external output and the self-payment are specified as recipients', function () {
166+
// psbtOutput1 goes to descriptorSelf (own wallet) — this is a self-payment
167+
// Both outputs are requested, so there are no missing or implicit external outputs
168+
assert.doesNotThrow(() =>
169+
assertExpectedOutputDifference(getBaseParsedTransaction(psbt, [psbtOutput0, psbtOutput1]))
170+
);
171+
});
172+
173+
it('should not report the self-payment as missing even though it is classified as an internal (change) output', function () {
174+
// psbtOutput1 goes to descriptorSelf (own wallet) and is marked as internal/change by the
175+
// descriptor wallet parser. Specifying it as a recipient should still find it in the PSBT
176+
// outputs (two-pass approach: all outputs are checked, not just external ones).
177+
const result = getBaseParsedTransaction(psbt, [psbtOutput0, psbtOutput1]);
178+
assert.strictEqual(result.missingOutputs.length, 0);
179+
assert.strictEqual(result.implicitExternalOutputs.length, 0);
180+
});
181+
182+
it('should not report an unspecified internal output as an implicit external output', function () {
183+
// psbtOutput1 goes to descriptorSelf (own wallet). When NOT specified as a recipient,
184+
// it should NOT appear in implicitExternalOutputs because it is an internal/change output.
185+
// Only psbtOutput0 (external) is requested, so psbtOutput1 is just unreported change.
186+
const result = getBaseParsedTransaction(psbt, [psbtOutput0]);
187+
assert.strictEqual(result.missingOutputs.length, 0);
188+
assert.strictEqual(result.implicitExternalOutputs.length, 0);
189+
});
190+
});
163191
});
164192
});

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

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import {
44
ActualOutput,
55
ExpectedOutput,
66
getMissingOutputs,
7+
isImplicitOutput,
78
matchingOutput,
89
outputDifference,
910
outputDifferencesWithExpected,
@@ -96,6 +97,20 @@ describe('outputDifference', function () {
9697
});
9798
});
9899

100+
describe('isImplicitOutput', function () {
101+
it('returns true for outputs not in the expected list', function () {
102+
assert.strictEqual(isImplicitOutput(a, []), true);
103+
assert.strictEqual(isImplicitOutput(a, [b]), true);
104+
assert.strictEqual(isImplicitOutput(a, [a2]), true);
105+
});
106+
107+
it('returns false for outputs that match an expected output', function () {
108+
assert.strictEqual(isImplicitOutput(a, [a]), false);
109+
assert.strictEqual(isImplicitOutput(a, [b, a]), false);
110+
assert.strictEqual(isImplicitOutput(a, [aMax]), false);
111+
});
112+
});
113+
99114
describe('outputDifferencesWithExpected', function () {
100115
function test(
101116
outputs: ActualOutput[],

0 commit comments

Comments
 (0)