Skip to content
Closed
Show file tree
Hide file tree
Changes from all 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
30 changes: 27 additions & 3 deletions modules/abstract-utxo/src/transaction/descriptor/parse.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,12 @@ import { getKeySignatures, toBip32Triple, UtxoNamedKeychains } from '../../keych
import { getDescriptorMapFromWallet, getPolicyForEnv } from '../../descriptor';
import { IDescriptorWallet } from '../../descriptor/descriptorWallet';
import { fromExtendedAddressFormatToScript, toExtendedAddressFormat } from '../recipient';
import { outputDifferencesWithExpected, OutputDifferenceWithExpected } from '../outputDifference';
import {
getMissingOutputs,
isImplicitOutput,
outputDifference,
OutputDifferenceWithExpected,
} from '../outputDifference';
import { UtxoCoinName } from '../../names';
import { sumValues, toWasmPsbt, UtxoLibPsbt } from '../../wasmUtil';

Expand Down Expand Up @@ -44,11 +49,30 @@ function parseOutputsWithPsbt(
script: Buffer.from(output.script),
}));
const changeOutputs = outputs.filter((o) => o.scriptId !== undefined);
const outputDiffs = outputDifferencesWithExpected(outputs, recipientOutputs);

/**
* Two-pass approach to handle self-payments (outputs going to the sender's own wallet address):
*
* Pass 1 — missing outputs: Check all PSBT outputs (including change/internal) against the
* expected recipients. This ensures self-payments that are classified as change outputs by the
* descriptor wallet (isChange = true) are still matched against the intent recipients and do not
* appear as missing. This mirrors the multisig (fixedScript) flow where allOutputs includes
* both explanation.outputs and explanation.changeOutputs.
*
* Pass 2 — implicit outputs: Of the outputs NOT in the recipient list, only flag those that go
* to external addresses (scriptId = undefined) as implicit. Internal outputs (scriptId set) that
* are not in recipients are legitimate change outputs and should not be flagged.
*/
const missingOutputs = getMissingOutputs(outputs, recipientOutputs);
const implicitOutputs = outputs.filter((o) => isImplicitOutput(o, recipientOutputs));
const explicitOutputs = outputDifference(outputs, implicitOutputs);

return {
outputs,
changeOutputs,
...outputDiffs,
explicitOutputs,
implicitOutputs,
missingOutputs,
};
}

Expand Down
15 changes: 15 additions & 0 deletions modules/abstract-utxo/src/transaction/outputDifference.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,21 @@ export function getMissingOutputs<A extends ActualOutput, B extends ExpectedOutp
return outputDifference(expectedOutputs, actualOutputs).filter((o) => !o.optional);
}

/**
* Returns true if the given output was not explicitly requested by the user (i.e., it is not in
* the list of expected outputs). This handles both external and internal (change/self-payment)
* outputs — an output is implicit regardless of whether it is going to a wallet address or not.
*
* Used to identify surprise outputs such as PayGo fees that were added by the server without
* being part of the original send intent.
*/
export function isImplicitOutput<A extends ActualOutput>(
output: A,
expectedOutputs: ExpectedOutput[]
): boolean {
return !expectedOutputs.some((expected) => matchingOutput(output, expected));
}

export type OutputDifferenceWithExpected<TActual extends ActualOutput, TExpected extends ExpectedOutput> = {
/** These are the external outputs that were expected and found in the transaction. */
explicitOutputs: TActual[];
Expand Down
28 changes: 28 additions & 0 deletions modules/abstract-utxo/test/unit/transaction/descriptor/parse.ts
Original file line number Diff line number Diff line change
Expand Up @@ -160,5 +160,33 @@ describe('parse', function () {
new AggregateValidationError([missingOutputError(recipient), implicitOutputError(psbtOutput0)])
);
});

describe('self-payment (recipient address is a wallet address)', function () {
it('should pass when both the external output and the self-payment are specified as recipients', function () {
// psbtOutput1 goes to descriptorSelf (own wallet) — this is a self-payment
// Both outputs are requested, so there are no missing or implicit external outputs
assert.doesNotThrow(() =>
assertExpectedOutputDifference(getBaseParsedTransaction(psbt, [psbtOutput0, psbtOutput1]))
);
});

it('should not report the self-payment as missing even though it is classified as an internal (change) output', function () {
// psbtOutput1 goes to descriptorSelf (own wallet) and is marked as internal/change by the
// descriptor wallet parser. Specifying it as a recipient should still find it in the PSBT
// outputs (two-pass approach: all outputs are checked, not just external ones).
const result = getBaseParsedTransaction(psbt, [psbtOutput0, psbtOutput1]);
assert.strictEqual(result.missingOutputs.length, 0);
assert.strictEqual(result.implicitExternalOutputs.length, 0);
});

it('should not report an unspecified internal output as an implicit external output', function () {
// psbtOutput1 goes to descriptorSelf (own wallet). When NOT specified as a recipient,
// it should NOT appear in implicitExternalOutputs because it is an internal/change output.
// Only psbtOutput0 (external) is requested, so psbtOutput1 is just unreported change.
const result = getBaseParsedTransaction(psbt, [psbtOutput0]);
assert.strictEqual(result.missingOutputs.length, 0);
assert.strictEqual(result.implicitExternalOutputs.length, 0);
});
});
});
});
15 changes: 15 additions & 0 deletions modules/abstract-utxo/test/unit/transaction/outputDifference.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import {
ActualOutput,
ExpectedOutput,
getMissingOutputs,
isImplicitOutput,
matchingOutput,
outputDifference,
outputDifferencesWithExpected,
Expand Down Expand Up @@ -96,6 +97,20 @@ describe('outputDifference', function () {
});
});

describe('isImplicitOutput', function () {
it('returns true for outputs not in the expected list', function () {
assert.strictEqual(isImplicitOutput(a, []), true);
assert.strictEqual(isImplicitOutput(a, [b]), true);
assert.strictEqual(isImplicitOutput(a, [a2]), true);
});

it('returns false for outputs that match an expected output', function () {
assert.strictEqual(isImplicitOutput(a, [a]), false);
assert.strictEqual(isImplicitOutput(a, [b, a]), false);
assert.strictEqual(isImplicitOutput(a, [aMax]), false);
});
});

describe('outputDifferencesWithExpected', function () {
function test(
outputs: ActualOutput[],
Expand Down
Loading