commit 36a84e83a47e8721f3e719bead5bbc9761be0d77
parent 339b7883a7f2d3be9856a440fe74e3546ec5f6a2
Author: Florian Dold <dold@taler.net>
Date: Fri, 24 Jul 2026 16:41:59 +0200
wallet-core: discard payments left claiming after a cold start
Diffstat:
5 files changed, 276 insertions(+), 0 deletions(-)
diff --git a/packages/taler-harness/src/integrationtests/test-payment-claim-cleanup.ts b/packages/taler-harness/src/integrationtests/test-payment-claim-cleanup.ts
@@ -0,0 +1,178 @@
+/*
+ This file is part of GNU Taler
+ (C) 2025 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ * Imports.
+ */
+import {
+ TalerMerchantApi,
+ TalerMerchantInstanceHttpClient,
+ TransactionIdStr,
+ TransactionMajorState,
+ TransactionMinorState,
+ succeedOrThrow,
+} from "@gnu-taler/taler-util";
+import { WalletApiOperation } from "@gnu-taler/taler-wallet-core";
+import {
+ createSimpleTestkudosEnvironmentV3,
+ createWalletDaemonWithClient,
+} from "../harness/environments.js";
+import { GlobalTestState, WalletClient, waitMs } from "../harness/harness.js";
+
+/**
+ * A payment that is still claiming has never been shown to the user, so it
+ * must not survive a restart of the wallet as a transaction that retries in
+ * the background forever. It gets one more attempt to claim, and is
+ * discarded if that attempt does not succeed.
+ */
+export async function runPaymentClaimCleanupTest(t: GlobalTestState) {
+ const { merchant, merchantAdminAccessToken } =
+ await createSimpleTestkudosEnvironmentV3(t);
+
+ const merchantClient = new TalerMerchantInstanceHttpClient(
+ merchant.makeInstanceBaseUrl(),
+ );
+
+ async function makeOrder(summary: string): Promise<string> {
+ const orderResp = succeedOrThrow(
+ await merchantClient.createOrder(merchantAdminAccessToken, {
+ order: {
+ summary,
+ amount: "TESTKUDOS:5",
+ fulfillment_url: "taler://fulfillment-success/thx",
+ } satisfies TalerMerchantApi.Order,
+ }),
+ );
+ const orderStatus = succeedOrThrow(
+ await merchantClient.getOrderDetails(
+ merchantAdminAccessToken,
+ orderResp.order_id,
+ ),
+ );
+ t.assertTrue(orderStatus.order_status === "unpaid");
+ return orderStatus.taler_pay_uri;
+ }
+
+ // A claiming payment has no contract terms yet and does not show up in
+ // the transaction list, so ask for it directly.
+ async function hasTransaction(
+ walletClient: WalletClient,
+ transactionId: TransactionIdStr,
+ ): Promise<boolean> {
+ try {
+ await walletClient.call(WalletApiOperation.GetTransactionById, {
+ transactionId,
+ });
+ return true;
+ } catch (e) {
+ return false;
+ }
+ }
+
+ // Both orders are created while the merchant is up, but only claimed
+ // while it is down.
+ const payUriOne = await makeOrder("claim cleanup one");
+ const payUriTwo = await makeOrder("claim cleanup two");
+
+ let w = await createWalletDaemonWithClient(t, {
+ name: "w1",
+ persistent: true,
+ });
+
+ async function restartWallet(): Promise<void> {
+ w.walletClient.remoteWallet?.close();
+ await w.walletService.stop();
+ w = await createWalletDaemonWithClient(t, {
+ name: "w1",
+ persistent: true,
+ });
+ }
+
+ /**
+ * Scan a payment URI while the merchant is unreachable, so that the
+ * payment gets stuck in the claiming state.
+ */
+ async function scanWhileMerchantDown(
+ talerPayUri: string,
+ ): Promise<TransactionIdStr> {
+ const res = await w.walletClient.call(
+ WalletApiOperation.PreparePayForUriV2,
+ { talerPayUri },
+ );
+ await w.walletClient.call(WalletApiOperation.TestingWaitTransactionState, {
+ transactionId: res.transactionId,
+ txState: {
+ major: TransactionMajorState.Pending,
+ minor: TransactionMinorState.ClaimProposal,
+ },
+ });
+ return res.transactionId;
+ }
+
+ await merchant.stop();
+
+ let discardedTxId: TransactionIdStr;
+
+ await t.runSpanAsync("claim still failing after restart", async () => {
+ const transactionId = await scanWhileMerchantDown(payUriOne);
+ discardedTxId = transactionId;
+ t.assertTrue(await hasTransaction(w.walletClient, transactionId));
+
+ // The merchant stays down, so the single retry after the restart fails.
+ await restartWallet();
+
+ let gone = false;
+ for (let i = 0; i < 60; i++) {
+ if (!(await hasTransaction(w.walletClient, transactionId))) {
+ gone = true;
+ break;
+ }
+ await waitMs(500);
+ }
+ t.assertTrue(gone);
+ });
+
+ await t.runSpanAsync("claim succeeding after restart", async () => {
+ // Created by this wallet run, so it is not a leftover claim and keeps
+ // retrying in the background as before.
+ const transactionId = await scanWhileMerchantDown(payUriTwo);
+
+ w.walletClient.remoteWallet?.close();
+ await w.walletService.stop();
+ await merchant.start({ skipDbinit: true });
+ await merchant.pingUntilAvailable();
+ w = await createWalletDaemonWithClient(t, {
+ name: "w1",
+ persistent: true,
+ });
+
+ // The retry after the restart succeeds, so the payment survives and
+ // becomes an offer the user can act on.
+ await w.walletClient.call(WalletApiOperation.TestingWaitTransactionState, {
+ transactionId,
+ txState: {
+ major: TransactionMajorState.Dialog,
+ minor: TransactionMinorState.Proposed,
+ },
+ });
+ t.assertTrue(await hasTransaction(w.walletClient, transactionId));
+
+ // The discarded one stays gone.
+ t.assertTrue(!(await hasTransaction(w.walletClient, discardedTxId)));
+ });
+}
+
+runPaymentClaimCleanupTest.suites = ["wallet"];
diff --git a/packages/taler-harness/src/integrationtests/testrunner.ts b/packages/taler-harness/src/integrationtests/testrunner.ts
@@ -135,6 +135,7 @@ import { runPaivanaRepurchaseTest } from "./test-paivana-repurchase.js";
import { runPaivanaTest } from "./test-paivana.js";
import { runPayPaidTest } from "./test-pay-paid.js";
import { runPaymentAbortTest } from "./test-payment-abort.js";
+import { runPaymentClaimCleanupTest } from "./test-payment-claim-cleanup.js";
import { runPaymentClaimTest } from "./test-payment-claim.js";
import { runPaymentDeletedTest } from "./test-payment-deleted.js";
import { runPaymentExpiredTest } from "./test-payment-expired.js";
@@ -272,6 +273,7 @@ const allTests: TestMainFunction[] = [
runMerchantRefundApiTest,
runMerchantSpecPublicOrdersTest,
runPaymentClaimTest,
+ runPaymentClaimCleanupTest,
runPaymentForgettableTest,
runPaymentIdempotencyTest,
runPaymentMultipleTest,
diff --git a/packages/taler-wallet-core/src/pay-merchant.ts b/packages/taler-wallet-core/src/pay-merchant.ts
@@ -855,6 +855,83 @@ export async function getTotalPaymentCostInTx(
return Amounts.sum([zero, ...costs]).amount;
}
+/**
+ * Remember the payments that are still claiming, so that each of them gets
+ * exactly one more attempt to claim before being discarded.
+ *
+ * Must be called before the task loop is started, while no claim of our own
+ * can be in flight: a claiming payment in the DB is then necessarily left
+ * over from a previous run of the wallet.
+ */
+export async function collectLeftoverClaims(
+ wex: WalletExecutionContext,
+): Promise<void> {
+ const purchases = await wex.runWalletDbTx(async (tx) => {
+ return tx.getActivePurchases();
+ });
+ for (const p of purchases) {
+ if (p.purchaseStatus === PurchaseStatus.PendingDownloadingProposal) {
+ wex.ws.leftoverClaims.add(p.proposalId);
+ }
+ }
+ if (wex.ws.leftoverClaims.size > 0) {
+ logger.info(
+ `retrying ${wex.ws.leftoverClaims.size} leftover claim(s) once`,
+ );
+ }
+}
+
+/**
+ * Claim a proposal left over from a previous run of the wallet, discarding
+ * the transaction if the attempt does not succeed.
+ *
+ * The user never got to see the proposal for such a payment, so keeping it
+ * around only produces background requests that they have no way to explain.
+ * That is why even a transient error discards it here: the payment URI can
+ * simply be scanned again. Claiming is idempotent for our nonce, so this one
+ * retry also recovers a claim whose response we failed to store earlier.
+ */
+async function retryLeftoverClaim(
+ wex: WalletExecutionContext,
+ proposalId: string,
+): Promise<TaskRunResult> {
+ const ctx = new PayMerchantTransactionContext(wex, proposalId);
+ let res: TaskRunResult;
+ try {
+ res = await processDownloadProposal(wex, proposalId);
+ } catch (e) {
+ logger.info(
+ `discarding leftover claim ${ctx.transactionId}: ${safeStringifyException(e)}`,
+ );
+ await discardLeftoverClaim(wex, proposalId);
+ return TaskRunResult.finished();
+ }
+ // A claim that fails permanently does not throw, it parks the transaction
+ // in a failed state instead.
+ const purchase = await wex.runWalletDbTx(async (tx) => {
+ return tx.getPurchase(proposalId);
+ });
+ switch (purchase?.purchaseStatus) {
+ case PurchaseStatus.PendingDownloadingProposal:
+ case PurchaseStatus.FailedClaim:
+ logger.info(`discarding leftover claim ${ctx.transactionId}`);
+ await discardLeftoverClaim(wex, proposalId);
+ return TaskRunResult.finished();
+ }
+ return res;
+}
+
+async function discardLeftoverClaim(
+ wex: WalletExecutionContext,
+ proposalId: string,
+): Promise<void> {
+ const ctx = new PayMerchantTransactionContext(wex, proposalId);
+ await wex.runWalletDbTx(async (tx) => {
+ await ctx.deleteTransactionInTx(tx);
+ });
+ wex.taskScheduler.stopShepherdTask(ctx.taskId);
+}
+
async function failProposalClaimPermanently(
wex: WalletExecutionContext,
proposalId: string,
@@ -2678,6 +2755,11 @@ export async function processPurchase(
switch (purchase.purchaseStatus) {
case PurchaseStatus.PendingDownloadingProposal:
+ // Consumes the entry, so that only the first attempt after a cold
+ // start is the decisive one.
+ if (wex.ws.leftoverClaims.delete(proposalId)) {
+ return retryLeftoverClaim(wex, proposalId);
+ }
return processDownloadProposal(wex, proposalId);
case PurchaseStatus.PendingPaying:
case PurchaseStatus.PendingPayingReplay:
diff --git a/packages/taler-wallet-core/src/requests.ts b/packages/taler-wallet-core/src/requests.ts
@@ -323,6 +323,7 @@ import {
sendTalerUriMessage,
} from "./mailbox.js";
import {
+ collectLeftoverClaims,
confirmPay,
getChoicesForPayment,
preparePayForTemplateV2,
@@ -829,6 +830,10 @@ async function handleSetWalletRunConfig(
await migrateMaterializedTransactions(wex);
+ if (!wex.ws.initCalled) {
+ await collectLeftoverClaims(wex);
+ }
+
const resp: InitResponse = {
versionInfo: await handleGetVersion(wex),
};
diff --git a/packages/taler-wallet-core/src/wallet.ts b/packages/taler-wallet-core/src/wallet.ts
@@ -651,6 +651,15 @@ export class InternalWalletState {
initCalled = false;
+ /**
+ * Proposal IDs of payments that were still claiming when this wallet
+ * was started, i.e. left over from a previous run.
+ *
+ * Each of them gets one more attempt to claim, after which the
+ * transaction is discarded instead of being retried forever.
+ */
+ readonly leftoverClaims = new Set<string>();
+
refreshCostCache: Cache<AmountJson> = new Cache(
1000,
Duration.fromSpec({ minutes: 1 }),