commit c970593ca94063d91495a359390a3fa3b51be682
parent 72b3202dc370578435392422cf11f2a07ab3e1ba
Author: Florian Dold <dold@taler.net>
Date: Thu, 3 Sep 2026 15:37:35 +0200
wallet-core: recover the contribution of a coin the merchant rejects
A 409 insufficient-funds reply only removed the coin from the
selection; the value already allocated to it stayed on the dormant
coin. The refresh claims back whatever the exchange still holds for
it, so a merchant cannot strand funds by rejecting coins.
Diffstat:
2 files changed, 98 insertions(+), 30 deletions(-)
diff --git a/packages/taler-wallet-core/src/pay-merchant.test.ts b/packages/taler-wallet-core/src/pay-merchant.test.ts
@@ -68,6 +68,7 @@ import {
splitPaymentOutputTokenSignatures,
storeFirstPaySuccess,
validateClaimResponseBindings,
+ partitionPayCoinSelectionForRepair,
} from "./pay-merchant.js";
import { makeIdbRunner } from "./db/testing/runners.js";
import type { WalletExecutionContext } from "./wallet.js";
@@ -923,3 +924,41 @@ test("payment token finalization rolls back with payment success", async () => {
await runner.close();
}
});
+
+test("merchant repair recovers the contribution of a coin the merchant rejected", () => {
+ const payCoinSelection: WalletCoinSelection = {
+ coinPubs: ["coin-one", "coin-two", "coin-three"],
+ coinContributions: ["TESTKUDOS:1", "TESTKUDOS:2", "TESTKUDOS:3"],
+ };
+ const exchangeByCoinPub = new Map([
+ ["coin-one", "https://exchange-a.example/"],
+ ["coin-two", "https://exchange-a.example/"],
+ ["coin-three", "https://exchange-b.example/"],
+ ]);
+
+ const rejected = partitionPayCoinSelectionForRepair(
+ payCoinSelection,
+ exchangeByCoinPub,
+ { excludeCoinPub: "coin-two" },
+ );
+ assert.deepStrictEqual(
+ rejected.prevPayCoins.map((c) => c.coinPub),
+ ["coin-one", "coin-three"],
+ );
+ assert.deepStrictEqual(rejected.recoveredCoins, [
+ { coinPub: "coin-two", amount: "TESTKUDOS:2" },
+ ]);
+
+ const refused = partitionPayCoinSelectionForRepair(
+ payCoinSelection,
+ exchangeByCoinPub,
+ { excludeExchangeBaseUrls: new Set(["https://exchange-b.example/"]) },
+ );
+ assert.deepStrictEqual(
+ refused.prevPayCoins.map((c) => c.coinPub),
+ ["coin-one", "coin-two"],
+ );
+ assert.deepStrictEqual(refused.recoveredCoins, [
+ { coinPub: "coin-three", amount: "TESTKUDOS:3" },
+ ]);
+});
diff --git a/packages/taler-wallet-core/src/pay-merchant.ts b/packages/taler-wallet-core/src/pay-merchant.ts
@@ -2420,6 +2420,48 @@ export function getPayRepairAcceptedExchanges(
}));
}
+/**
+ * Split the coins of a payment that has to be repaired into the ones that
+ * stay in the selection and the ones whose contribution has to be recovered
+ * by a refresh.
+ *
+ * A coin leaves the selection either because the exchange reported it as
+ * unusable (double-spent, per the merchant's forwarded reply) or because its
+ * exchange legally refused the deposit. In both cases the merchant did not
+ * receive the coin's contribution, so whatever the exchange still holds for
+ * that coin is claimed back through a refresh; the melt corrects itself
+ * against the coin's history if the exchange disagrees about the balance.
+ */
+export function partitionPayCoinSelectionForRepair(
+ payCoinSelection: NonNullable<WalletPurchasePayInfo["payCoinSelection"]>,
+ exchangeByCoinPub: ReadonlyMap<string, string>,
+ opts: {
+ excludeCoinPub?: string;
+ excludeExchangeBaseUrls?: ReadonlySet<string>;
+ },
+): { prevPayCoins: PreviousPayCoins; recoveredCoins: CoinRefreshRequest[] } {
+ const prevPayCoins: PreviousPayCoins = [];
+ const recoveredCoins: CoinRefreshRequest[] = [];
+ for (let i = 0; i < payCoinSelection.coinPubs.length; i++) {
+ const coinPub = payCoinSelection.coinPubs[i];
+ const contrib = payCoinSelection.coinContributions[i];
+ const exchangeBaseUrl = exchangeByCoinPub.get(coinPub);
+ if (
+ (opts.excludeCoinPub != null && coinPub === opts.excludeCoinPub) ||
+ (exchangeBaseUrl != null &&
+ opts.excludeExchangeBaseUrls?.has(exchangeBaseUrl))
+ ) {
+ recoveredCoins.push({ coinPub, amount: contrib });
+ continue;
+ }
+ prevPayCoins.push({
+ coinPub,
+ contribution: Amounts.parseOrThrow(contrib),
+ });
+ }
+ return { prevPayCoins, recoveredCoins };
+}
+
async function reselectCoinsTx(
tx: WalletDbTransaction,
ctx: PayMerchantTransactionContext,
@@ -2450,43 +2492,30 @@ async function reselectCoinsTx(
throw Error("choice index not specified for contract v1");
}
- const prevPayCoins: PreviousPayCoins = [];
const prevTokensPubs: string[] = [];
let payCoinsToSpend: SelectedCoin[] = [];
- const removedExchangeCoins: CoinRefreshRequest[] = [];
+ let prevPayCoins: PreviousPayCoins = [];
+ let recoveredCoins: CoinRefreshRequest[] = [];
const payCoinSelection = p.payInfo.payCoinSelection;
const payTokenSelection = p.payInfo.payTokenSelection;
if (payCoinSelection) {
const selectedCoins = await tx.getCoinsByPubs(payCoinSelection.coinPubs);
- const selectedCoinsByPub = new Map(
- selectedCoins.map((coin) => [coin.coinPub, coin]),
+ const exchangeByCoinPub = new Map(
+ selectedCoins.map((coin) => [coin.coinPub, coin.exchangeBaseUrl]),
);
- for (let i = 0; i < payCoinSelection.coinPubs.length; i++) {
- const coinPub = payCoinSelection.coinPubs[i];
- if (opts.excludeCoinPub != null && coinPub === opts.excludeCoinPub) {
- // Exclude the coin that the exchange reported as broken
- // (e.g. double-spent) so re-selection doesn't just pick the
- // same failing coin again.
- continue;
- }
- const contrib = payCoinSelection.coinContributions[i];
- const coin = selectedCoinsByPub.get(coinPub);
- checkDbInvariant(!!coin, `selected payment coin ${coinPub} is missing`);
- if (opts.excludeExchangeBaseUrls?.has(coin.exchangeBaseUrl)) {
- // A legal refusal is definitive for this exchange and this payment.
- // The exchange did not accept the deposit, so recover the allocated
- // contribution while replacing it with a coin from another exchange
- // accepted by the signed contract.
- removedExchangeCoins.push({ coinPub, amount: contrib });
- continue;
- }
- prevPayCoins.push({
- coinPub,
- contribution: Amounts.parseOrThrow(contrib),
- });
+ for (const coinPub of payCoinSelection.coinPubs) {
+ checkDbInvariant(
+ exchangeByCoinPub.has(coinPub),
+ `selected payment coin ${coinPub} is missing`,
+ );
}
+ ({ prevPayCoins, recoveredCoins } = partitionPayCoinSelectionForRepair(
+ payCoinSelection,
+ exchangeByCoinPub,
+ opts,
+ ));
const acceptedExchanges = getPayRepairAcceptedExchanges(
contractData.contractTerms,
@@ -2495,7 +2524,7 @@ async function reselectCoinsTx(
if (
opts.excludeExchangeBaseUrls &&
- (removedExchangeCoins.length === 0 || acceptedExchanges.length === 0)
+ (recoveredCoins.length === 0 || acceptedExchanges.length === 0)
) {
return "insufficient";
}
@@ -2570,12 +2599,12 @@ async function reselectCoinsTx(
await tx.upsertPurchase(p);
await ctx.updateTransactionMeta(tx);
- if (removedExchangeCoins.length > 0) {
+ if (recoveredCoins.length > 0) {
await createRefreshGroup(
ctx.wex,
tx,
Amounts.currencyOf(p.payInfo.totalPayCost),
- removedExchangeCoins,
+ recoveredCoins,
RefreshReason.AbortPay,
ctx.transactionId,
);