taler-typescript-core

Wallet core logic and WebUIs for various components
Log | Files | Refs | Submodules | README | LICENSE

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:
Mpackages/taler-wallet-core/src/pay-merchant.test.ts | 39+++++++++++++++++++++++++++++++++++++++
Mpackages/taler-wallet-core/src/pay-merchant.ts | 89++++++++++++++++++++++++++++++++++++++++++++++++++++---------------------------
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, );