commit 8cb6e9096a56a39775eb5a5b2488046be39d8ab2
parent 9b715aedfc64af93fa7b7aabb6a099ce1563f02d
Author: Florian Dold <dold@taler.net>
Date: Sun, 6 Sep 2026 12:41:06 +0200
wallet: include waived deposit fees in refund history balances
Track cumulative gross refunds by merchant and contract. Return the
deposit fee once a deposit is fully refunded, while retaining the
comparison against the exchange balance and rejecting excess refunds.
Diffstat:
2 files changed, 164 insertions(+), 1 deletion(-)
diff --git a/packages/taler-wallet-core/src/refresh.test.ts b/packages/taler-wallet-core/src/refresh.test.ts
@@ -192,12 +192,18 @@ test("coin history balance is recomputed from typed operations", () => {
type: "DEPOSIT",
history_offset: 0,
amount: "TESTKUDOS:3",
+ deposit_fee: "TESTKUDOS:0.1",
+ merchant_pub: "merchant",
+ h_contract_terms: "contract",
h_denom_pub: "denom",
},
{
type: "REFUND",
history_offset: 1,
amount: "TESTKUDOS:1",
+ refund_fee: "TESTKUDOS:0.02",
+ merchant_pub: "merchant",
+ h_contract_terms: "contract",
},
],
} as never;
@@ -262,3 +268,99 @@ test("coin history accounts for the waived fee of refunded purse deposits", () =
"TESTKUDOS:10.08",
);
});
+
+for (const refundAmounts of [["TESTKUDOS:3"], ["TESTKUDOS:1", "TESTKUDOS:2"]]) {
+ test(`coin history returns the deposit fee once for ${refundAmounts.length} refunds`, () => {
+ const history = {
+ h_denom_pub: "denom",
+ balance:
+ refundAmounts.length === 1 ? "TESTKUDOS:10.08" : "TESTKUDOS:10.06",
+ history: [
+ {
+ type: "DEPOSIT",
+ history_offset: 0,
+ amount: "TESTKUDOS:3",
+ deposit_fee: "TESTKUDOS:0.1",
+ merchant_pub: "merchant",
+ h_contract_terms: "contract",
+ },
+ ...refundAmounts.map((amount, i) => ({
+ type: "REFUND",
+ history_offset: i + 1,
+ amount: Amounts.stringify(
+ Amounts.sub(amount, "TESTKUDOS:0.02").amount,
+ ),
+ refund_fee: "TESTKUDOS:0.02",
+ merchant_pub: "merchant",
+ h_contract_terms: "contract",
+ })),
+ ],
+ } as never;
+ assert.strictEqual(
+ validateAndRecomputeCoinHistoryBalance("denom", "TESTKUDOS:10", history),
+ refundAmounts.length === 1 ? "TESTKUDOS:10.08" : "TESTKUDOS:10.06",
+ );
+ });
+}
+
+test("coin history keeps refund fee waivers bound to one deposit", () => {
+ const response = {
+ h_denom_pub: "denom",
+ balance: "TESTKUDOS:7.08",
+ history: [
+ {
+ type: "DEPOSIT",
+ history_offset: 0,
+ amount: "TESTKUDOS:3",
+ deposit_fee: "TESTKUDOS:0.1",
+ merchant_pub: "merchant",
+ h_contract_terms: "first",
+ },
+ {
+ type: "DEPOSIT",
+ history_offset: 1,
+ amount: "TESTKUDOS:3",
+ deposit_fee: "TESTKUDOS:0.1",
+ merchant_pub: "merchant",
+ h_contract_terms: "second",
+ },
+ {
+ type: "REFUND",
+ history_offset: 2,
+ amount: "TESTKUDOS:2.98",
+ refund_fee: "TESTKUDOS:0.02",
+ merchant_pub: "merchant",
+ h_contract_terms: "first",
+ },
+ ],
+ };
+ assert.strictEqual(
+ validateAndRecomputeCoinHistoryBalance(
+ "denom",
+ "TESTKUDOS:10",
+ response as never,
+ ),
+ "TESTKUDOS:7.08",
+ );
+ response.history[2].merchant_pub = "another-merchant";
+ assert.throws(
+ () =>
+ validateAndRecomputeCoinHistoryBalance(
+ "denom",
+ "TESTKUDOS:10",
+ response as never,
+ ),
+ /no matching deposit/,
+ );
+ response.history[2].merchant_pub = "merchant";
+ response.history[2].amount = "TESTKUDOS:3.01";
+ assert.throws(
+ () =>
+ validateAndRecomputeCoinHistoryBalance(
+ "denom",
+ "TESTKUDOS:10",
+ response as never,
+ ),
+ /refunds exceed their deposit/,
+ );
+});
diff --git a/packages/taler-wallet-core/src/refresh.ts b/packages/taler-wallet-core/src/refresh.ts
@@ -1683,6 +1683,14 @@ export function validateAndRecomputeCoinHistoryBalance(
}
let balance = Amounts.parseOrThrow(denomValue);
const offsets = new Set<number>();
+ const deposits = new Map<
+ string,
+ {
+ amount: AmountString;
+ fee: AmountString;
+ refunded: AmountString;
+ }
+ >();
for (const item of response.history) {
if (
!Number.isInteger(item.history_offset) ||
@@ -1700,6 +1708,15 @@ export function validateAndRecomputeCoinHistoryBalance(
let addsValue = false;
switch (item.type) {
case "DEPOSIT":
+ deposits.set(`${item.merchant_pub}:${item.h_contract_terms}`, {
+ amount: item.amount,
+ fee: item.deposit_fee,
+ refunded: Amounts.stringify(
+ Amounts.zeroOfCurrency(Amounts.currencyOf(denomValue)),
+ ),
+ });
+ amount = item.amount;
+ break;
case "RECOUP-WITHDRAW":
case "RECOUP-REFRESH":
case "PURSE-DEPOSIT":
@@ -1711,7 +1728,51 @@ export function validateAndRecomputeCoinHistoryBalance(
case "RESERVE-OPEN-DEPOSIT":
amount = item.coin_contribution;
break;
- case "REFUND":
+ case "REFUND": {
+ const deposit = deposits.get(
+ `${item.merchant_pub}:${item.h_contract_terms}`,
+ );
+ if (!deposit) {
+ throw TalerError.fromDetail(
+ TalerErrorCode.WALLET_TRANSACTION_PROTOCOL_VIOLATION,
+ {},
+ "coin history refund has no matching deposit",
+ );
+ }
+ // History reports the net refund. The exchange also returns the
+ // deposit fee once gross cumulative refunds cover this deposit.
+ const grossRefund = Amounts.add(item.amount, item.refund_fee);
+ const totalRefund = Amounts.add(deposit.refunded, grossRefund.amount);
+ if (
+ grossRefund.saturated ||
+ totalRefund.saturated ||
+ Amounts.cmp(totalRefund.amount, deposit.amount) > 0
+ ) {
+ throw TalerError.fromDetail(
+ TalerErrorCode.WALLET_TRANSACTION_PROTOCOL_VIOLATION,
+ {},
+ "coin history refunds exceed their deposit",
+ );
+ }
+ amount = item.amount;
+ if (
+ Amounts.cmp(deposit.refunded, deposit.amount) < 0 &&
+ Amounts.cmp(totalRefund.amount, deposit.amount) === 0
+ ) {
+ const withWaivedFee = Amounts.add(amount, deposit.fee);
+ if (withWaivedFee.saturated) {
+ throw TalerError.fromDetail(
+ TalerErrorCode.WALLET_TRANSACTION_PROTOCOL_VIOLATION,
+ {},
+ "refunded deposit fee overflows the coin balance",
+ );
+ }
+ amount = Amounts.stringify(withWaivedFee.amount);
+ }
+ deposit.refunded = Amounts.stringify(totalRefund.amount);
+ addsValue = true;
+ break;
+ }
case "RECOUP-REFRESH-RECEIVER":
case "PURSE-REFUND":
amount = item.amount;