taler-typescript-core

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

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:
Apackages/taler-harness/src/integrationtests/test-payment-claim-cleanup.ts | 178+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mpackages/taler-harness/src/integrationtests/testrunner.ts | 2++
Mpackages/taler-wallet-core/src/pay-merchant.ts | 82+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mpackages/taler-wallet-core/src/requests.ts | 5+++++
Mpackages/taler-wallet-core/src/wallet.ts | 9+++++++++
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 }),