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:
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.
*/