commit 7bae442956ab603886979ff99a6aff816ed1bc19
parent 2a71a72efcdf609cb73180e6b41e9898ddf2692b
Author: Florian Dold <dold@taler.net>
Date: Thu, 3 Sep 2026 15:41:17 +0200
wallet-core: refresh what a deposit refund restores on the coin
A full refund credits the coin with the contribution minus the refund
fee plus the waived deposit fee; melting only the contribution
stranded the difference.
Diffstat:
2 files changed, 71 insertions(+), 4 deletions(-)
diff --git a/packages/taler-wallet-core/src/deposits.test.ts b/packages/taler-wallet-core/src/deposits.test.ts
@@ -16,6 +16,7 @@
import {
AbsoluteTime,
+ Amounts,
HttpStatusCode,
TalerErrorCode,
TalerProtocolTimestamp,
@@ -38,6 +39,7 @@ import {
depositRefundStatusIsRetryable,
reconstructDepositRefundRequests,
testing_getDepositTrackingTiming,
+ getDepositAbortRefreshAmount,
} from "./deposits.js";
test("deposit tracking waits at a future wire deadline", () => {
@@ -252,3 +254,27 @@ test("hard-KYC deposit cleanup remains finalizing until recovery completes", ()
},
);
});
+
+test("deposit abort refreshes the amount the refund restored on the coin", () => {
+ const denom = { feeDeposit: "TESTKUDOS:0.1", feeRefund: "TESTKUDOS:0.02" };
+ assert.strictEqual(
+ Amounts.stringify(
+ getDepositAbortRefreshAmount(
+ "TESTKUDOS:5",
+ DepositElementStatus.RefundSuccess,
+ denom,
+ ),
+ ),
+ "TESTKUDOS:5.08",
+ );
+ assert.strictEqual(
+ Amounts.stringify(
+ getDepositAbortRefreshAmount(
+ "TESTKUDOS:5",
+ DepositElementStatus.RefundNotFound,
+ denom,
+ ),
+ ),
+ "TESTKUDOS:5",
+ );
+});
diff --git a/packages/taler-wallet-core/src/deposits.ts b/packages/taler-wallet-core/src/deposits.ts
@@ -24,6 +24,7 @@
import {
AbsoluteTime,
AmountJson,
+ AmountLike,
AmountString,
Amounts,
BatchDepositRequestCoin,
@@ -1036,6 +1037,33 @@ function isDepositHardKycFailure(depositGroup: WalletDepositGroup): boolean {
* already wired the deposit. Other permanent refund failures prove neither
* recovery nor delivery and therefore get their own outcome.
*/
+/**
+ * The amount a refresh can claim back from a coin after the deposit abort
+ * refunded it.
+ *
+ * A refund of the full contribution makes the exchange credit the coin with
+ * the contribution minus the refund fee, plus the deposit fee it waives on a
+ * full refund. A coin whose deposit the exchange never saw still holds its
+ * whole contribution.
+ */
+export function getDepositAbortRefreshAmount(
+ contribution: AmountLike,
+ status:
+ | DepositElementStatus.RefundSuccess
+ | DepositElementStatus.RefundNotFound,
+ denom: { feeDeposit: AmountLike; feeRefund: AmountLike },
+): AmountJson {
+ const contrib = Amounts.parseOrThrow(contribution);
+ if (status === DepositElementStatus.RefundNotFound) {
+ return contrib;
+ }
+ const afterRefundFee = Amounts.sub(contrib, denom.feeRefund);
+ if (afterRefundFee.saturated) {
+ return Amounts.zeroOfAmount(contrib);
+ }
+ return Amounts.add(afterRefundFee.amount, denom.feeDeposit).amount;
+}
+
export function classifyDepositAbortOutcome(
statusPerCoin: DepositElementStatus[],
recovery?: "pending" | "recovered" | "failed",
@@ -1360,17 +1388,30 @@ async function refundDepositGroup(
if (outcome === "refunding") {
return;
}
+ const denoms = await getDenomInfos(wex, tx, coins);
const refreshCoins: CoinRefreshRequest[] = [];
for (let i = 0; i < newTxPerCoin.length; i++) {
+ const st = newTxPerCoin[i];
if (
- newTxPerCoin[i] !== DepositElementStatus.RefundSuccess &&
- newTxPerCoin[i] !== DepositElementStatus.RefundNotFound
+ st !== DepositElementStatus.RefundSuccess &&
+ st !== DepositElementStatus.RefundNotFound
) {
continue;
}
+ const coinPub = payCoinSelection.coinPubs[i];
+ const coin = coinsByPub.get(coinPub);
+ checkDbInvariant(!!coin, `coin ${coinPub} not found in DB`);
+ const denom = denoms.get(denomRefKey(coin));
+ checkDbInvariant(!!denom, `denomination of coin ${coinPub} is missing`);
refreshCoins.push({
- amount: payCoinSelection.coinContributions[i],
- coinPub: payCoinSelection.coinPubs[i],
+ amount: Amounts.stringify(
+ getDepositAbortRefreshAmount(
+ payCoinSelection.coinContributions[i],
+ st,
+ denom,
+ ),
+ ),
+ coinPub,
refundRequest: refundReqPerCoin[i],
});
}