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[],