taler-typescript-core

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

commit f59667fc2f3de51493c783f848867bb7c598efbf
parent d2883bf078f190067d7cfd8e5e91db0273c151eb
Author: Florian Dold <dold@taler.net>
Date:   Thu, 20 Aug 2026 19:06:46 +0200

wallet-core: fail aborts when value recovery fails

Diffstat:
Mpackages/taler-wallet-core/src/deposits.ts | 47+++++++++++++++++++++--------------------------
Mpackages/taler-wallet-core/src/pay-merchant.ts | 38+++++++++++++++++++-------------------
Mpackages/taler-wallet-core/src/refresh.test.ts | 18++++++++++++++++++
Mpackages/taler-wallet-core/src/refresh.ts | 21+++++++++++++++++++++
4 files changed, 79 insertions(+), 45 deletions(-)

diff --git a/packages/taler-wallet-core/src/deposits.ts b/packages/taler-wallet-core/src/deposits.ts @@ -76,6 +76,7 @@ import { getRandomBytes, hashWire, j2s, + makeErrorDetail, } from "@gnu-taler/taler-util"; import { getExchangesForDeposit, @@ -138,6 +139,7 @@ import { } from "./pay-merchant.js"; import { runWithMaybeProgressContext } from "./progress.js"; import { + classifyRecoveryRefresh, RefreshTransactionContext, createRefreshGroup, getTotalRefreshCosts, @@ -1048,14 +1050,10 @@ async function waitForRefreshOnDepositGroup( await genericWaitForState(wex, { async checkState() { return await wex.runWalletDbTx(async (tx) => { - const refreshGroup = await tx.getRefreshGroup(abortRefreshGroupId); - switch (refreshGroup?.operationStatus) { - case undefined: - case RefreshOperationStatus.Failed: - case RefreshOperationStatus.Finished: - return true; - } - return false; + const refreshGroup = await tx.getRefreshGroup(abortRefreshGroupId); + return ( + classifyRecoveryRefresh(refreshGroup?.operationStatus) !== "pending" + ); }); }, filterNotification(notif) { @@ -1068,29 +1066,26 @@ async function waitForRefreshOnDepositGroup( const didTransition = await wex.runWalletDbTx(async (tx) => { const refreshGroup = await tx.getRefreshGroup(abortRefreshGroupId); - let newOpState: DepositOperationStatus | undefined; - switch (refreshGroup?.operationStatus) { - case undefined: { - // Maybe it got manually deleted? Means that we should - // just go into aborted. - logger.warn("no aborting refresh group found for deposit group"); - newOpState = DepositOperationStatus.AbortedDeposit; - break; - } - case RefreshOperationStatus.Failed: - case RefreshOperationStatus.Finished: { - newOpState = DepositOperationStatus.AbortedDeposit; - break; - } - default: - return false; + const recovery = classifyRecoveryRefresh(refreshGroup?.operationStatus); + if (recovery === "pending") { + return false; } const [newDg, h] = await ctx.getRecordHandle(tx); if (!newDg) { return false; } - newDg.operationStatus = newOpState; - await h.update(newDg, "refresh-done"); + if (recovery === "recovered") { + newDg.operationStatus = DepositOperationStatus.AbortedDeposit; + await h.update(newDg, "refresh-done"); + } else { + newDg.operationStatus = DepositOperationStatus.FailedDeposit; + newDg.failReason = makeErrorDetail( + TalerErrorCode.WALLET_UNEXPECTED_EXCEPTION, + {}, + `abort recovery refresh ${abortRefreshGroupId} failed or disappeared`, + ); + await h.update(newDg, "refresh-failed"); + } return true; }); if (didTransition) { diff --git a/packages/taler-wallet-core/src/pay-merchant.ts b/packages/taler-wallet-core/src/pay-merchant.ts @@ -165,6 +165,7 @@ import { instantiateTemplateRaw } from "./pay-template.js"; import { runWithMaybeProgressContext } from "./progress.js"; import { calculateRefreshOutput, + classifyRecoveryRefresh, createRefreshGroup, getTotalRefreshCosts, RefreshTransactionContext, @@ -4530,14 +4531,9 @@ async function waitForRefreshOnAbortedPayment( async checkState() { return await wex.runWalletDbTx(async (tx) => { const refreshGroup = await tx.getRefreshGroup(abortRefreshGroupId); - switch (refreshGroup?.operationStatus) { - case undefined: - case RefreshOperationStatus.Failed: - case RefreshOperationStatus.Finished: - return true; - default: - return false; - } + return ( + classifyRecoveryRefresh(refreshGroup?.operationStatus) !== "pending" + ); }); }, filterNotification(notif) { @@ -4550,15 +4546,9 @@ async function waitForRefreshOnAbortedPayment( const didTransition = await wex.runWalletDbTx(async (tx) => { const refreshGroup = await tx.getRefreshGroup(abortRefreshGroupId); - switch (refreshGroup?.operationStatus) { - case undefined: - logger.warn("no aborting refresh group found for payment"); - break; - case RefreshOperationStatus.Failed: - case RefreshOperationStatus.Finished: - break; - default: - return false; + const recovery = classifyRecoveryRefresh(refreshGroup?.operationStatus); + if (recovery === "pending") { + return false; } const [rec, h] = await ctx.getRecordHandle(tx); if (rec?.purchaseStatus !== PurchaseStatus.AbortingWithRefund) { @@ -4569,8 +4559,18 @@ async function waitForRefreshOnAbortedPayment( ctx.transactionId, rec.payInfo?.payTokenSelection?.tokenPubs ?? [], ); - rec.purchaseStatus = finalStatus; - await h.update(rec, "abort-refresh-done"); + if (recovery === "recovered") { + rec.purchaseStatus = finalStatus; + await h.update(rec, "abort-refresh-done"); + } else { + rec.purchaseStatus = PurchaseStatus.FailedAbort; + rec.failReason = makeErrorDetail( + TalerErrorCode.WALLET_UNEXPECTED_EXCEPTION, + {}, + `abort recovery refresh ${abortRefreshGroupId} failed or disappeared`, + ); + await h.update(rec, "abort-refresh-failed"); + } return true; }); diff --git a/packages/taler-wallet-core/src/refresh.test.ts b/packages/taler-wallet-core/src/refresh.test.ts @@ -17,9 +17,11 @@ import { Amounts, DenominationInfo } from "@gnu-taler/taler-util"; import assert from "node:assert"; import { test } from "node:test"; import { + classifyRecoveryRefresh, getTotalRefreshCostInternal, requireValidNorevealIndex, } from "./refresh.js"; +import { RefreshOperationStatus } from "./db-common.js"; test("melt noreveal index must be an integer inside kappa", () => { assert.doesNotThrow(() => requireValidNorevealIndex(0, 3)); @@ -32,6 +34,22 @@ test("melt noreveal index must be an integer inside kappa", () => { } }); +test("only a finished recovery refresh counts as recovered", () => { + assert.strictEqual( + classifyRecoveryRefresh(RefreshOperationStatus.Finished), + "recovered", + ); + assert.strictEqual( + classifyRecoveryRefresh(RefreshOperationStatus.Pending), + "pending", + ); + assert.strictEqual( + classifyRecoveryRefresh(RefreshOperationStatus.Failed), + "failed", + ); + assert.strictEqual(classifyRecoveryRefresh(undefined), "failed"); +}); + test("an impossible refresh costs the full remaining amount", () => { const amountLeft = Amounts.parseOrThrow("TESTKUDOS:4"); const refreshedDenom = { diff --git a/packages/taler-wallet-core/src/refresh.ts b/packages/taler-wallet-core/src/refresh.ts @@ -1087,6 +1087,27 @@ export function requireValidNorevealIndex(index: number, kappa: number): void { ); } +export type RecoveryRefreshOutcome = "pending" | "recovered" | "failed"; + +export function classifyRecoveryRefresh( + status: RefreshOperationStatus | undefined, +): RecoveryRefreshOutcome { + switch (status) { + case RefreshOperationStatus.Finished: + return "recovered"; + case undefined: + case RefreshOperationStatus.Failed: + return "failed"; + case RefreshOperationStatus.Pending: + case RefreshOperationStatus.PendingRedenominate: + case RefreshOperationStatus.Suspended: + case RefreshOperationStatus.SuspendedRedenominate: + return "pending"; + default: + assertUnreachable(status); + } +} + /** * Handle a "Gone" response from the exchange to a melt request. */