taler-typescript-core

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

commit f9170accd7225ef9525f493233d6ff9376f9091f
parent c00bc75a3f8aaac8cd1ff333bde0dfc606d0b835
Author: Florian Dold <dold@taler.net>
Date:   Wed, 22 Jul 2026 01:55:34 +0200

wallet: spend the coins a repaired selection added

After a purse creation conflict the selection is redone, but only the
coins carried over from the previous attempt had been marked as spent.
The replacements stayed fresh and were still offered to other payments
although they had been deposited into the purse.

The coins to mark are the ones the re-selection added, which is the new
selection minus one occurrence per carried-over coin.

Diffstat:
Mpackages/taler-wallet-core/src/coinSelection.ts | 28++++++++++++++++++++++++++++
Mpackages/taler-wallet-core/src/pay-peer-pull-debit.ts | 15++++++++++++++-
Mpackages/taler-wallet-core/src/pay-peer-push-debit.ts | 10++++++++++
3 files changed, 52 insertions(+), 1 deletion(-)

diff --git a/packages/taler-wallet-core/src/coinSelection.ts b/packages/taler-wallet-core/src/coinSelection.ts @@ -78,6 +78,34 @@ export type PreviousPayCoins = { contribution: AmountJson; }[]; +/** + * Coins that a re-selection added on top of the ones it carried over. + * + * A carried-over coin keeps its previous contribution, so each entry of the + * repair list accounts for exactly one occurrence in the new selection. + * Occurrences beyond that were selected now and still have to be marked as + * spent by the caller. + */ +export function coinsAddedByRepair( + repair: PreviousPayCoins, + selected: SelectedCoin[], +): SelectedCoin[] { + const carriedOver = new Map<string, number>(); + for (const prev of repair) { + carriedOver.set(prev.coinPub, (carriedOver.get(prev.coinPub) ?? 0) + 1); + } + const added: SelectedCoin[] = []; + for (const coin of selected) { + const remaining = carriedOver.get(coin.coinPub) ?? 0; + if (remaining > 0) { + carriedOver.set(coin.coinPub, remaining - 1); + continue; + } + added.push(coin); + } + return added; +} + export interface ExchangeRestrictionSpec { exchanges: AllowedExchangeInfo[]; auditors: AllowedAuditorInfo[]; diff --git a/packages/taler-wallet-core/src/pay-peer-pull-debit.ts b/packages/taler-wallet-core/src/pay-peer-pull-debit.ts @@ -62,7 +62,11 @@ import { getRandomBytes, j2s, } from "@gnu-taler/taler-util"; -import { PreviousPayCoins, selectPeerCoins } from "./coinSelection.js"; +import { + PreviousPayCoins, + coinsAddedByRepair, + selectPeerCoins, +} from "./coinSelection.js"; import { PendingTaskType, RecordHandle, @@ -438,6 +442,15 @@ async function handlePurseCreationConflict( contributions: sel.coins.map((x) => x.contribution), totalCost: Amounts.stringify(totalAmount), }; + // The carried-over coins were spent when they were first selected, + // the ones that replace the double-spent coin still have to be. + const added = coinsAddedByRepair(repair, sel.coins); + await spendCoins(ctx.wex, tx, { + transactionId: ctx.transactionId, + coinPubs: added.map((x) => x.coinPub), + contributions: added.map((x) => Amounts.parseOrThrow(x.contribution)), + refreshReason: RefreshReason.PayPeerPull, + }); await h.update(rec); break; } diff --git a/packages/taler-wallet-core/src/pay-peer-push-debit.ts b/packages/taler-wallet-core/src/pay-peer-push-debit.ts @@ -59,6 +59,7 @@ import { } from "@gnu-taler/taler-util"; import { PreviousPayCoins, + coinsAddedByRepair, selectPeerCoins, selectPeerCoinsInTx, } from "./coinSelection.js"; @@ -593,6 +594,15 @@ async function handlePurseCreationConflict( coinPubs: sel.coins.map((x) => x.coinPub), contributions: sel.coins.map((x) => x.contribution), }; + // The carried-over coins were spent when they were first selected, + // the ones that replace the double-spent coin still have to be. + const added = coinsAddedByRepair(repair, sel.coins); + await spendCoins(ctx.wex, tx, { + transactionId: ctx.transactionId, + coinPubs: added.map((x) => x.coinPub), + contributions: added.map((x) => Amounts.parseOrThrow(x.contribution)), + refreshReason: RefreshReason.PayPeerPush, + }); await h.update(rec); } }