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:
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 {