commit edcf051016a25c2daba531ad3b47e0772bf6660b
parent b32819d2d026822074c410be98ca97c7cd0226fa
Author: Florian Dold <dold@taler.net>
Date: Thu, 20 Aug 2026 19:06:42 +0200
wallet-core: spend only replacement payment coins
Diffstat:
2 files changed, 69 insertions(+), 3 deletions(-)
diff --git a/packages/taler-wallet-core/src/pay-merchant.test.ts b/packages/taler-wallet-core/src/pay-merchant.test.ts
@@ -0,0 +1,52 @@
+/*
+ This file is part of GNU Taler
+ (C) 2026 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+import { Amounts, AmountString, SelectedCoin } from "@gnu-taler/taler-util";
+import assert from "node:assert";
+import { test } from "node:test";
+import { getCoinsToSpendForMerchantRepair } from "./pay-merchant.js";
+
+function makeSelectedCoin(
+ coinPub: string,
+ contribution: AmountString,
+): SelectedCoin {
+ return {
+ coinPub,
+ contribution,
+ denomPubHash: `denom-${coinPub}`,
+ exchangeBaseUrl: "https://exchange.example/",
+ exchangeMasterPub: "exchange-master-pub",
+ };
+}
+
+test("merchant repair spends only newly selected coins", () => {
+ const retained = makeSelectedCoin(
+ "retained",
+ "TESTKUDOS:1" as AmountString,
+ );
+ const added = makeSelectedCoin("added", "TESTKUDOS:2" as AmountString);
+
+ const toSpend = getCoinsToSpendForMerchantRepair(
+ [
+ {
+ coinPub: retained.coinPub,
+ contribution: Amounts.parseOrThrow(retained.contribution),
+ },
+ ],
+ [retained, added],
+ );
+
+ assert.deepStrictEqual(toSpend, [added]);
+});
diff --git a/packages/taler-wallet-core/src/pay-merchant.ts b/packages/taler-wallet-core/src/pay-merchant.ts
@@ -83,6 +83,7 @@ import {
safeStringifyException,
ScopeInfo,
ScopeType,
+ SelectedCoin,
SelectedProspectiveCoin,
SharePaymentResult,
SignedTokenEnvelope,
@@ -110,6 +111,7 @@ import {
throwUnexpectedRequestError,
} from "@gnu-taler/taler-util/http";
import {
+ coinsAddedByRepair,
PreviousPayCoins,
selectPayCoins,
selectPayCoinsInTx,
@@ -1788,6 +1790,13 @@ function setCoinSel(rec: WalletPurchase, coinSel: PayCoinSelection): void {
rec.exchanges.sort();
}
+export function getCoinsToSpendForMerchantRepair(
+ previous: PreviousPayCoins,
+ selected: SelectedCoin[],
+): SelectedCoin[] {
+ return coinsAddedByRepair(previous, selected);
+}
+
async function reselectCoinsTx(
tx: WalletDbTransaction,
ctx: PayMerchantTransactionContext,
@@ -1817,6 +1826,7 @@ async function reselectCoinsTx(
const prevPayCoins: PreviousPayCoins = [];
const prevTokensPubs: string[] = [];
+ let payCoinsToSpend: SelectedCoin[] = [];
const payCoinSelection = p.payInfo.payCoinSelection;
const payTokenSelection = p.payInfo.payTokenSelection;
@@ -1861,6 +1871,10 @@ async function reselectCoinsTx(
default:
assertUnreachable(res);
}
+ payCoinsToSpend = getCoinsToSpendForMerchantRepair(
+ prevPayCoins,
+ res.coinSel.coins,
+ );
setCoinSel(p, res.coinSel);
}
@@ -1900,9 +1914,9 @@ async function reselectCoinsTx(
if (p.payInfo.payCoinSelection) {
await spendCoins(ctx.wex, tx, {
transactionId: ctx.transactionId,
- coinPubs: p.payInfo.payCoinSelection.coinPubs,
- contributions: p.payInfo.payCoinSelection.coinContributions.map((x) =>
- Amounts.parseOrThrow(x),
+ coinPubs: payCoinsToSpend.map((x) => x.coinPub),
+ contributions: payCoinsToSpend.map((x) =>
+ Amounts.parseOrThrow(x.contribution),
),
refreshReason: RefreshReason.PayMerchant,
});