commit f0ab91ce6a26dd3b916de0abe74a3609a9396b58
parent 8cb6e9096a56a39775eb5a5b2488046be39d8ab2
Author: Florian Dold <dold@taler.net>
Date: Sun, 6 Sep 2026 12:41:06 +0200
wallet: account for fee-only coins when completing payment aborts
A contribution no larger than its denomination refresh fee cannot yield
new coins even after a full refund. Permit these completed zero-output
refreshes while requiring recovery evidence for value above the fee.
Diffstat:
2 files changed, 52 insertions(+), 0 deletions(-)
diff --git a/packages/taler-wallet-core/src/pay-merchant.test.ts b/packages/taler-wallet-core/src/pay-merchant.test.ts
@@ -1142,3 +1142,35 @@ test("merchant abort accepts value recovered directly by abort refresh", () => {
"recovered",
);
});
+
+test("merchant abort accounts for contributions consumed by the refresh fee", () => {
+ const selection = {
+ coinPubs: ["coin"],
+ coinContributions: ["TESTKUDOS:0.01"],
+ } as WalletCoinSelection;
+ const refresh = recoveryRefresh(
+ RefreshReason.AbortPay,
+ "coin",
+ "TESTKUDOS:0",
+ );
+ const fees = new Map([["coin", "TESTKUDOS:0.01" as const]]);
+ assert.strictEqual(
+ classifyMerchantAbortRecoveryEvidence(selection, refresh, [], [], fees),
+ "recovered",
+ );
+ selection.coinContributions[0] = "TESTKUDOS:0.02";
+ assert.strictEqual(
+ classifyMerchantAbortRecoveryEvidence(selection, refresh, [], [], fees),
+ "failed",
+ );
+ selection.coinContributions[0] = "TESTKUDOS:0.01";
+ assert.strictEqual(
+ classifyMerchantAbortRecoveryEvidence(selection, refresh, [], []),
+ "failed",
+ );
+ refresh.statusPerCoin[0] = RefreshCoinStatus.Pending;
+ assert.strictEqual(
+ classifyMerchantAbortRecoveryEvidence(selection, refresh, [], [], fees),
+ "pending",
+ );
+});
diff --git a/packages/taler-wallet-core/src/pay-merchant.ts b/packages/taler-wallet-core/src/pay-merchant.ts
@@ -5741,11 +5741,20 @@ async function waitForRefreshOnAbortedPayment(
...(await tx.getRefundItemsByGroup(refundGroup.refundGroupId)),
);
}
+ const refreshFeesByCoin = new Map<string, AmountString>();
+ const abortCoins = await tx.getCoinsByPubs(payCoinSelection.coinPubs);
+ const abortDenoms = await getDenomInfos(wex, tx, abortCoins);
+ for (const coin of abortCoins) {
+ const denom = abortDenoms.get(denomRefKey(coin));
+ checkDbInvariant(!!denom, "aborting coin has no denomination");
+ refreshFeesByCoin.set(coin.coinPub, denom.feeRefresh);
+ }
const evidence = classifyMerchantAbortRecoveryEvidence(
payCoinSelection,
refreshGroup,
refundItems,
relatedRefreshes,
+ refreshFeesByCoin,
);
if (evidence === "pending") {
return false;
@@ -5794,6 +5803,7 @@ export function classifyMerchantAbortRecoveryEvidence(
abortRefresh: WalletRefreshGroup,
refundItems: WalletRefundItem[],
relatedRefreshes: WalletRefreshGroup[],
+ refreshFeesByCoin: ReadonlyMap<string, AmountString> = new Map(),
): "recovered" | "pending" | "failed" {
if (
abortRefresh.oldCoinPubs.length !==
@@ -5822,6 +5832,16 @@ export function classifyMerchantAbortRecoveryEvidence(
if (Amounts.isNonZero(abortOutput)) {
continue;
}
+ // Even a fully refunded contribution cannot produce new coins when the
+ // denomination's refresh fee consumes all of it. Require refund proof
+ // for every contribution with value above that authenticated fee.
+ const refreshFee = refreshFeesByCoin.get(coinPub);
+ if (
+ refreshFee &&
+ Amounts.cmp(payCoinSelection.coinContributions[i], refreshFee) <= 0
+ ) {
+ continue;
+ }
const refundItem = refundItems.find(
(x) =>
x.coinPub === coinPub &&