diff --git a/modules/abstract-utxo/src/transaction/descriptor/parse.ts b/modules/abstract-utxo/src/transaction/descriptor/parse.ts index ce3911e57f..6ffe19ca5c 100644 --- a/modules/abstract-utxo/src/transaction/descriptor/parse.ts +++ b/modules/abstract-utxo/src/transaction/descriptor/parse.ts @@ -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'; @@ -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, }; } diff --git a/modules/abstract-utxo/src/transaction/outputDifference.ts b/modules/abstract-utxo/src/transaction/outputDifference.ts index 8460a0c983..82cb1f057c 100644 --- a/modules/abstract-utxo/src/transaction/outputDifference.ts +++ b/modules/abstract-utxo/src/transaction/outputDifference.ts @@ -50,6 +50,21 @@ export function getMissingOutputs !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( + output: A, + expectedOutputs: ExpectedOutput[] +): boolean { + return !expectedOutputs.some((expected) => matchingOutput(output, expected)); +} + export type OutputDifferenceWithExpected = { /** These are the external outputs that were expected and found in the transaction. */ explicitOutputs: TActual[]; diff --git a/modules/abstract-utxo/test/unit/transaction/descriptor/parse.ts b/modules/abstract-utxo/test/unit/transaction/descriptor/parse.ts index fdc4957c23..440173afdd 100644 --- a/modules/abstract-utxo/test/unit/transaction/descriptor/parse.ts +++ b/modules/abstract-utxo/test/unit/transaction/descriptor/parse.ts @@ -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); + }); + }); }); }); diff --git a/modules/abstract-utxo/test/unit/transaction/outputDifference.ts b/modules/abstract-utxo/test/unit/transaction/outputDifference.ts index a246855fcc..e93f5d58a5 100644 --- a/modules/abstract-utxo/test/unit/transaction/outputDifference.ts +++ b/modules/abstract-utxo/test/unit/transaction/outputDifference.ts @@ -4,6 +4,7 @@ import { ActualOutput, ExpectedOutput, getMissingOutputs, + isImplicitOutput, matchingOutput, outputDifference, outputDifferencesWithExpected, @@ -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[],