taler-typescript-core

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

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:
Mpackages/taler-wallet-core/src/pay-merchant.test.ts | 32++++++++++++++++++++++++++++++++
Mpackages/taler-wallet-core/src/pay-merchant.ts | 20++++++++++++++++++++
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 &&