taler-typescript-core

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

commit 0c769ec41737d064cec85440434976954a3dadcc
parent e5039a31a44caeff82cc51d62002b34aec48339c
Author: Florian Dold <dold@taler.net>
Date:   Wed,  4 Nov 2026 00:38:18 +0100

wallet-core: support progress tokens for demo withdrawals

Add retries and cancellation, including recovery from lost bank
confirmation responses.

Issue: https://bugs.gnunet.org/view?id=11825

Diffstat:
Mpackages/taler-util/src/types-taler-wallet.ts | 7+++++++
Apackages/taler-wallet-core/src/testing-progress.test.ts | 199+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mpackages/taler-wallet-core/src/testing.ts | 123++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-------------
3 files changed, 310 insertions(+), 19 deletions(-)

diff --git a/packages/taler-util/src/types-taler-wallet.ts b/packages/taler-util/src/types-taler-wallet.ts @@ -3295,6 +3295,12 @@ export interface CoreApiResponseError { export interface WithdrawTestBalanceRequest { /** + * Track progress, retry failures, and allow cancellation of this request. + * Cancellation does not roll back an already-created withdrawal transaction. + */ + progressToken?: string; + + /** * Amount to withdraw. */ amount: AmountString; @@ -3373,6 +3379,7 @@ export const codecForWithdrawTestBalance = .property("forcedDenomSel", codecForAny()) .property("corebankApiBaseUrl", codecForCanonBaseUrl()) .property("useForeignAccount", codecOptional(codecForBoolean())) + .property("progressToken", codecOptional(codecForString())) .build("WithdrawTestBalanceRequest"); export interface SetCoinSuspendedRequest { diff --git a/packages/taler-wallet-core/src/testing-progress.test.ts b/packages/taler-wallet-core/src/testing-progress.test.ts @@ -0,0 +1,199 @@ +/* + This file is part of GNU Taler + (C) 2026 Taler Systems S.A. + SPDX-License-Identifier: GPL-3.0-or-later + */ +import { + CancellationToken, + codecForWithdrawTestBalance, + NotificationType, + TalerError, + TalerErrorCode, + TimerGroup, + timer, + WalletNotification, + WithdrawTestBalanceRequest, +} from "@gnu-taler/taler-util"; +import { + HeadersImpl, + HttpRequestLibrary, + HttpResponse, +} from "@gnu-taler/taler-util/http"; +import assert from "node:assert/strict"; +import { setImmediate } from "node:timers/promises"; +import { test } from "node:test"; +import { + handleCancelProgressToken, + handleRetryProgressTokenNow, +} from "./progress.js"; +import { withdrawTestBalance } from "./testing.js"; +import { WalletExecutionContext } from "./wallet.js"; + +const request: WithdrawTestBalanceRequest = { + amount: "KUDOS:5", + corebankApiBaseUrl: "https://bank.example/", + exchangeBaseUrl: "https://exchange.example/", + progressToken: "demo-cash", +}; +const control = { + operation: "withdrawTestBalance", + progressToken: "demo-cash", +}; +function fixture(http: HttpRequestLibrary) { + const cts = CancellationToken.create(); + const notifications: WalletNotification[] = []; + const timerGroup = new TimerGroup(timer); + const wex = { + http, + cts, + cancellationToken: cts.token, + ws: { + refcntIgnoreTos: 0, + progressMap: new Map(), + timerGroup, + notify(n: WalletNotification) { + notifications.push(n); + }, + }, + } as unknown as WalletExecutionContext; + return { wex, notifications, timerGroup }; +} +function cancelled(e: unknown): boolean { + assert.ok(e instanceof TalerError); + assert.equal( + e.errorDetail.code, + TalerErrorCode.WALLET_CORE_REQUEST_CANCELLED, + ); + return true; +} +function response(url: string, body: unknown): HttpResponse { + return { + requestUrl: url, + requestMethod: "POST", + status: 200, + headers: new HeadersImpl(), + async json() { + return body; + }, + async text() { + return JSON.stringify(body); + }, + async bytes() { + return new Uint8Array(); + }, + }; +} + +test("test balance codec accepts optional progress tokens", () => { + const codec = codecForWithdrawTestBalance(); + assert.equal(codec.decode(request).progressToken, "demo-cash"); + assert.equal( + codec.decode({ ...request, progressToken: undefined }).progressToken, + undefined, + ); + assert.throws(() => codec.decode({ ...request, progressToken: 3 })); +}); + +test("demo cash reports phases and cancels an in-flight bank request", async (t) => { + t.mock.timers.enable({ apis: ["setTimeout"] }); + let requestToken: CancellationToken | undefined; + let resolveHttp!: (r: HttpResponse) => void; + let calls = 0; + const f = fixture({ + fetch: async (_url, options) => { + calls++; + requestToken = options?.cancellationToken; + return new Promise((resolve) => { + resolveHttp = resolve; + }); + }, + }); + t.after(() => f.timerGroup.stopCurrentAndFutureTimers()); + const pending = withdrawTestBalance(f.wex, request); + const rejected = assert.rejects(pending, cancelled); + await setImmediate(); + assert.equal(requestToken, f.wex.cancellationToken); + for (let i = 0; i < 2; i++) { + t.mock.timers.tick(5000); + await setImmediate(); + } + await handleCancelProgressToken(f.wex, control); + await rejected; + assert.equal(requestToken?.isCancelled, true); + assert.equal(f.wex.ws.refcntIgnoreTos, 0); + assert.equal(f.wex.ws.progressMap.size, 0); + assert.deepEqual( + f.notifications.map((n) => + n.type === NotificationType.RequestProgressPhase ? n.phase : n.type, + ), + ["delayed", "stalled", "done"], + ); + for (const n of f.notifications) { + assert.ok(n.type === NotificationType.RequestProgressPhase); + assert.equal(n.operation, control.operation); + assert.equal(n.progressToken, control.progressToken); + } + // Even an HTTP implementation that finishes after cancellation cannot start + // the account-info query or a withdrawal. + resolveHttp(response("https://bank.example/accounts", {})); + t.mock.timers.tick(20000); + await setImmediate(); + assert.equal(calls, 1); + assert.equal(f.notifications.length, 3); +}); + +test("demo cash retries registration with stable credentials and cancels backoff", async (t) => { + const registrations: unknown[] = []; + let queries = 0; + let withdrawals = 0; + const failure = TalerError.fromDetail(TalerErrorCode.WALLET_NETWORK_ERROR, { + requestUrl: "https://bank.example/accounts", + requestMethod: "POST", + }); + const f = fixture({ + fetch: async (url, options) => { + if (url.endsWith("/accounts")) { + registrations.push(options?.body); + if (registrations.length === 1) throw failure; + return response(url, {}); + } + if (url.endsWith("/withdrawals")) { + withdrawals++; + throw failure; + } + queries++; + if (queries === 1) throw failure; + return response(url, { + payto_uri: "payto://x-taler-bank/bank.example/demo", + }); + }, + }); + t.after(() => f.timerGroup.stopCurrentAndFutureTimers()); + const pending = withdrawTestBalance(f.wex, request); + const rejected = assert.rejects(pending, cancelled); + for (let i = 0; i < 2; i++) { + await setImmediate(); + await handleRetryProgressTokenNow(f.wex, control); + } + await setImmediate(); + assert.equal(registrations.length, 3); + assert.deepEqual(registrations[0], registrations[1]); + assert.deepEqual(registrations[1], registrations[2]); + assert.equal(withdrawals, 1); + await handleRetryProgressTokenNow(f.wex, control); + await setImmediate(); + assert.equal(withdrawals, 2); + assert.equal(registrations.length, 3); + const errors = f.notifications.filter( + (n) => n.type === NotificationType.RequestProgressError, + ); + assert.equal(errors.length, 4); + assert.ok( + errors.every((n) => n.error.code === TalerErrorCode.WALLET_NETWORK_ERROR), + ); + await handleCancelProgressToken(f.wex, control); + await rejected; + assert.equal(f.wex.ws.refcntIgnoreTos, 0); + assert.equal(f.wex.ws.progressMap.size, 0); + assert.equal(withdrawals, 2); +}); diff --git a/packages/taler-wallet-core/src/testing.ts b/packages/taler-wallet-core/src/testing.ts @@ -29,11 +29,14 @@ import { addPaytoQueryParams, Amounts, AmountString, + CancellationToken, checkLogicInvariant, CheckPaymentResponse, ChoiceSelectionDetailType, ConfirmPayResultType, Duration, + encodeCrock, + getRandomBytes, IntegrationTestArgs, IntegrationTestV2Args, isFinalTransactionState, @@ -84,15 +87,23 @@ import { preparePeerPushCredit, } from "./pay-peer-push-credit.js"; import { initiatePeerPushDebit } from "./pay-peer-push-debit.js"; +import { + runWithMaybeProgressContext, + runWithProgressRetries, +} from "./progress.js"; import { getRefreshesForTransaction } from "./refresh.js"; import { getTransactionById, getTransactions, maybeTaskFromTransaction, parseTransactionIdentifier, + retryTransaction, } from "./transactions.js"; import type { WalletExecutionContext } from "./wallet.js"; -import { acceptBankIntegratedWithdrawal } from "./withdraw.js"; +import { + acceptBankIntegratedWithdrawal, + getBankWithdrawalInfo, +} from "./withdraw.js"; const logger = new Logger("operations/testing.ts"); @@ -117,12 +128,51 @@ export async function withdrawTestBalance( wex: WalletExecutionContext, req: WithdrawTestBalanceRequest, ): Promise<WithdrawTestBalanceResult> { - wex.ws.refcntIgnoreTos += 1; - try { - return await withdrawTestBalanceImpl(wex, req); - } finally { - wex.ws.refcntIgnoreTos -= 1; + // Keep the cancellation-aware HTTP client local to this request. Background + // transaction processing must remain alive when the caller cancels its wait. + if (req.progressToken != null) { + const http = wex.http; + const token = wex.cancellationToken; + wex = { + ...wex, + http: { + async fetch(url, options) { + token.throwIfCancelled(); + const response = await token.racePromise( + http.fetch(url, { + ...options, + cancellationToken: options?.cancellationToken + ? CancellationToken.race(token, options.cancellationToken) + : token, + }), + ); + token.throwIfCancelled(); + return response; + }, + }, + }; } + return runWithMaybeProgressContext( + wex, + "withdrawTestBalance", + req.progressToken, + async () => { + wex.ws.refcntIgnoreTos += 1; + try { + return await withdrawTestBalanceImpl(wex, req); + } catch (e) { + if (wex.cancellationToken?.isCancelled) { + throw TalerError.fromDetail( + TalerErrorCode.WALLET_CORE_REQUEST_CANCELLED, + {}, + ); + } + throw e; + } finally { + wex.ws.refcntIgnoreTos -= 1; + } + }, + ); } async function withdrawTestBalanceImpl( @@ -141,14 +191,21 @@ async function withdrawTestBalanceImpl( httpClient: wex.http, }); - const bankUser = await corebankClient.createRandomBankUser(); + // Registration is idempotent for identical credentials. In particular, a + // failed account-info query must not cause us to create another account. + const username = "user-" + encodeCrock(getRandomBytes(10)).toLowerCase(); + const password = "pw-" + encodeCrock(getRandomBytes(10)).toLowerCase(); + const bankUser = await runWithProgressRetries(wex, () => + corebankClient.registerAccount(username, password), + ); logger.trace(`Registered bank user ${JSON.stringify(bankUser)}`); corebankClient.setAuth(bankUser); - const wresp = await corebankClient.createWithdrawalOperation( - bankUser.username, - amount, + // A lost response can leave an unconfirmed bank operation behind. Only the + // operation returned successfully here is accepted and confirmed below. + const wresp = await runWithProgressRetries(wex, () => + corebankClient.createWithdrawalOperation(bankUser.username, amount), ); const acceptResp = await acceptBankIntegratedWithdrawal(wex, { @@ -160,16 +217,44 @@ async function withdrawTestBalanceImpl( // We need to wait until the wallet sent the reserve information // to the bank, otherwise the confirmation in the bank would fail. - await waitTransactionState(wex, { - transactionId: acceptResp.transactionId, - txState: { - major: TransactionMajorState.Pending, - minor: TransactionMinorState.BankConfirmTransfer, - }, - }); + const pc = wex.progressContext; + try { + if (pc) { + pc.onRetryNow = () => retryTransaction(wex, acceptResp.transactionId); + } + await waitTransactionState( + wex, + { + transactionId: acceptResp.transactionId, + progressToken: req.progressToken, + txState: { + major: TransactionMajorState.Pending, + minor: TransactionMinorState.BankConfirmTransfer, + }, + }, + "withdrawTestBalance", + ); + } finally { + if (pc) pc.onRetryNow = undefined; + } - await corebankClient.confirmWithdrawalOperation(bankUser.username, { - withdrawalOperationId: wresp.withdrawal_id, + let confirmationAttempted = false; + await runWithProgressRetries(wex, async () => { + // The bank may have confirmed the transfer even if we lost its response. + // Check before repeating a POST that can reject a duplicate reserve key. + if (confirmationAttempted) { + const info = await getBankWithdrawalInfo( + wex.http, + wresp.taler_withdraw_uri, + ); + if (info.status === "confirmed") return; + } + confirmationAttempted = true; + succeedOrThrow( + await corebankClient.confirmWithdrawalOperation(bankUser.username, { + withdrawalOperationId: wresp.withdrawal_id, + }), + ); }); return {