commit d67bbf3e71054e267cfb3a0eb4c81cb2bfc42cb6
parent 4a1ecd2b95f478e545097f986664245aaae82a21
Author: Florian Dold <dold@taler.net>
Date: Wed, 9 Sep 2026 14:40:21 +0200
wallet-core: add progressToken to waitTransactionState
Also, waitTransactionState is now an alias to
testingWaitTransactionState.
Diffstat:
8 files changed, 867 insertions(+), 18 deletions(-)
diff --git a/packages/taler-harness/src/integrationtests/test-wallet-progress-token.ts b/packages/taler-harness/src/integrationtests/test-wallet-progress-token.ts
@@ -26,6 +26,8 @@ import {
TalerErrorCode,
TalerMerchantInstanceHttpClient,
TemplateType,
+ TransactionMajorState,
+ TransactionMinorState,
WalletNotification,
succeedOrThrow,
} from "@gnu-taler/taler-util";
@@ -41,6 +43,8 @@ import { GlobalTestState, waitMs } from "../harness/harness.js";
* Exercise the progressToken machinery of requests that talk to the network
* themselves: a request that has no transactionId still has to report failed
* attempts, retry on its own and be cancellable while it does so.
+ * Also exercise transaction-state waits through both API names, including
+ * cancelling the wait and retrying the transaction through its progress token.
*
* Uses the start-tc dev experiment to make the exchange answer with errors
* that only retrying can get past.
@@ -275,6 +279,135 @@ export async function runWalletProgressTokenTest(t: GlobalTestState) {
devExperimentUri: "taler://dev-experiment/stop-tc",
});
+ // Wait controls cross the daemon boundary. Cancelling the first wait must
+ // leave the withdrawal alive; a second wait can retry it and see it finish.
+ await walletClient.call(WalletApiOperation.SetExchangeTosAccepted, {
+ exchangeBaseUrl: exchange.baseUrl,
+ });
+ const accepted = await walletClient.call(
+ WalletApiOperation.AcceptBankIntegratedWithdrawal,
+ {
+ exchangeBaseUrl: exchange.baseUrl,
+ talerWithdrawUri: wop.taler_withdraw_uri,
+ },
+ );
+ await walletClient.call(WalletApiOperation.WaitTransactionState, {
+ transactionId: accepted.transactionId,
+ txState: {
+ major: TransactionMajorState.Pending,
+ minor: TransactionMinorState.BankConfirmTransfer,
+ },
+ timeout: { seconds: 30 },
+ });
+ const waitCancelled = walletClient
+ .call(WalletApiOperation.TestingWaitTransactionState, {
+ transactionId: accepted.transactionId,
+ txState: "final",
+ progressToken: "tok-wait-cancel",
+ timeout: { seconds: 60 },
+ })
+ .then(
+ () => undefined,
+ (e) => e,
+ );
+ await walletClient.waitForNotificationCond(
+ (n) =>
+ n.type === NotificationType.RequestProgressPhase &&
+ n.operation === "testingWaitTransactionState" &&
+ n.progressToken === "tok-wait-cancel" &&
+ n.phase === "delayed",
+ );
+ await walletClient.call(WalletApiOperation.CancelProgressToken, {
+ operation: "testingWaitTransactionState",
+ progressToken: "tok-wait-cancel",
+ });
+ const waitError = await waitCancelled;
+ t.assertTrue(waitError instanceof TalerError);
+ t.assertDeepEqual(
+ waitError.errorDetail.code,
+ TalerErrorCode.WALLET_CORE_REQUEST_CANCELLED,
+ );
+
+ // Make the next wallet-side bank query fail until its backoff is observable.
+ t.allowLog({
+ file: "wallet-w-progress-stderr.log",
+ level: "WARNING",
+ message: "reserve long-poll failed while waiting for bank confirmation: {",
+ maxOccurrences: 4,
+ justification:
+ "The test injects HTTP 500 responses until the withdrawal has retried twice, then explicitly retries it once before restoring the network.",
+ });
+ await walletClient.call(WalletApiOperation.ApplyDevExperiment, {
+ devExperimentUri: "taler://dev-experiment/start-tc?fake_500=1",
+ });
+ await userBank.confirmWithdrawalOperation(bankUser.username, {
+ withdrawalOperationId: wop.withdrawal_id,
+ });
+ const waitDone = walletClient.call(WalletApiOperation.WaitTransactionState, {
+ transactionId: accepted.transactionId,
+ txState: "final",
+ progressToken: "tok-wait-retry",
+ timeout: { seconds: 60 },
+ });
+ void waitDone.catch(() => {});
+ let sawBackoff = false;
+ for (let i = 0; i < 150; i++) {
+ const { tasks } = await walletClient.call(
+ WalletApiOperation.GetActiveTasks,
+ {},
+ );
+ sawBackoff = tasks.some(
+ (task) =>
+ task.transaction === accepted.transactionId &&
+ task.lastError != null &&
+ (task.retryCounter ?? 0) >= 2,
+ );
+ if (sawBackoff) break;
+ await waitMs(100);
+ }
+ t.assertTrue(sawBackoff, "withdrawal did not enter retry backoff");
+ await waitForNotified("the wait to report the transaction retry error", () =>
+ progressErrorsFor("tok-wait-retry").some(
+ (n) =>
+ n.type === NotificationType.RequestProgressError && n.retryCounter >= 2,
+ ),
+ );
+ const transaction = await walletClient.call(
+ WalletApiOperation.GetTransactionById,
+ {
+ transactionId: accepted.transactionId,
+ },
+ );
+ const waitErrors = progressErrorsFor("tok-wait-retry");
+ const latestError = waitErrors[waitErrors.length - 1];
+ t.assertTrue(latestError.type === NotificationType.RequestProgressError);
+ t.assertDeepEqual(latestError.operation, "waitTransactionState");
+ t.assertDeepEqual(latestError.error.code, transaction.error?.code);
+ t.assertTrue(typeof latestError.nextRetryDelay.d_us === "number");
+ const retryReset = walletClient.waitForNotificationCond(
+ (n) =>
+ n.type === NotificationType.TransactionStateTransition &&
+ n.transactionId === accepted.transactionId &&
+ n.causeHint === "shepherd-retry" &&
+ n.errorInfo == null,
+ );
+ await walletClient.call(WalletApiOperation.RetryProgressTokenNow, {
+ operation: "waitTransactionState",
+ progressToken: "tok-wait-retry",
+ });
+ await retryReset;
+ await walletClient.call(WalletApiOperation.ApplyDevExperiment, {
+ devExperimentUri: "taler://dev-experiment/stop-tc",
+ });
+ t.assertDeepEqual((await waitDone).txState.major, TransactionMajorState.Done);
+ await waitForNotified("the wait to report completion", () =>
+ phasesFor("tok-wait-retry").some(
+ (n) =>
+ n.type === NotificationType.RequestProgressPhase && n.phase === "done",
+ ),
+ );
+ const errorCountAtDone = progressErrorsFor("tok-wait-retry").length;
+
// ---------------------------------------------------------------------
// A peer-payment request carries a token through to completion. There is
// no cheap way to force a retry here (the peer contract endpoints answer a
@@ -349,6 +482,10 @@ export async function runWalletProgressTokenTest(t: GlobalTestState) {
await walletClient.call(WalletApiOperation.ApplyDevExperiment, {
devExperimentUri: "taler://dev-experiment/stop-tc",
});
+ t.assertDeepEqual(
+ progressErrorsFor("tok-wait-retry").length,
+ errorCountAtDone,
+ );
}
runWalletProgressTokenTest.suites = ["wallet"];
diff --git a/packages/taler-util/src/types-taler-wallet.test.ts b/packages/taler-util/src/types-taler-wallet.test.ts
@@ -21,6 +21,7 @@ import {
codecForGetDefaultExchangesRequest,
codecForListWithdrawalExchangeCandidatesRequest,
codecForTestingWaitTransactionRequest,
+ codecForWaitTransactionStateRequest,
matchTransactionState,
} from "./types-taler-wallet.js";
@@ -168,6 +169,7 @@ test("wait request codec accepts every form of txState", (t) => {
[{ major: "failed", minor: "*" }, { major: "done" }],
]) {
const req = codec.decode({ transactionId, txState });
+ assert.strictEqual(req.progressToken, undefined);
// The codec adds the optional properties as undefined, which
// JSON drops again.
assert.deepStrictEqual(
@@ -180,12 +182,14 @@ test("wait request codec accepts every form of txState", (t) => {
transactionId,
txState: "final",
logId: "l",
+ progressToken: "wait-progress",
timeout: { seconds: 5 },
requireError: true,
bailOnError: true,
bailStates: [{ major: "failed", minor: "*" }],
});
assert.strictEqual(full.logId, "l");
+ assert.strictEqual(full.progressToken, "wait-progress");
assert.strictEqual(full.timeout?.seconds, 5);
assert.strictEqual(full.bailOnError, true);
});
@@ -194,6 +198,7 @@ test("wait request codec rejects bad input", (t) => {
const codec = codecForTestingWaitTransactionRequest();
const transactionId = "txn:withdrawal:foo";
const bad = [
+ { transactionId, txState: "final", progressToken: 42 },
{ transactionId, txState: { major: "dnoe" } },
{ transactionId, txState: { major: "done", minor: "kyc-required" } },
{ transactionId, txState: "whenever" },
@@ -205,3 +210,10 @@ test("wait request codec rejects bad input", (t) => {
assert.throws(() => codec.decode(req), JSON.stringify(req));
}
});
+
+test("legacy wait request codec is an alias of the stable codec", () => {
+ assert.strictEqual(
+ codecForTestingWaitTransactionRequest,
+ codecForWaitTransactionStateRequest,
+ );
+});
diff --git a/packages/taler-util/src/types-taler-wallet.ts b/packages/taler-util/src/types-taler-wallet.ts
@@ -4097,10 +4097,24 @@ export interface TestingWaitBalanceRequest {
export const codecForTestingWaitBalanceRequest: () => Codec<TestingWaitBalanceRequest> =
codecForAny;
-export interface TestingWaitTransactionRequest {
+export interface WaitTransactionStateRequest {
transactionId: TransactionIdStr;
/**
+ * Receive request progress notifications and control this wait via
+ * cancelProgressToken/retryProgressTokenNow, using the operation name
+ * invoked ("waitTransactionState" or its legacy alias
+ * "testingWaitTransactionState"). Cancellation stops only the wait.
+ * Retry-now retries the transaction if its current state allows it,
+ * without restarting the wait or extending its timeout.
+ * Transaction errors are reported with their recorded retry counter and
+ * remaining delay. Without a recorded error retry, the counter is zero
+ * and the delay is "forever". Repeated observations of the same error
+ * and retry state do not produce duplicate notifications.
+ */
+ progressToken?: string;
+
+ /**
* Additional identifier that is used in the logs
* to easily find the status of the particular wait
* request.
@@ -4155,7 +4169,7 @@ export type TestingWaitTxStateSpec =
| "nonpending"
| "final";
-export interface TestingWaitTransactionStateResponse {
+export interface WaitTransactionStateResponse {
/**
* Which set of states ended the wait: the requested state
* or one of the bail states.
@@ -4219,10 +4233,11 @@ const codecForTestingWaitTxStateSpec = (): Codec<TestingWaitTxStateSpec> =>
codecForList(codecForTransactionStatePattern()),
);
-export const codecForTestingWaitTransactionRequest =
- (): Codec<TestingWaitTransactionRequest> =>
- buildCodecForObject<TestingWaitTransactionRequest>()
+export const codecForWaitTransactionStateRequest =
+ (): Codec<WaitTransactionStateRequest> =>
+ buildCodecForObject<WaitTransactionStateRequest>()
.property("transactionId", codecForTransactionIdStr())
+ .property("progressToken", codecOptional(codecForString()))
.property("logId", codecOptional(codecForString()))
.property("timeout", codecOptional(codecForDurationUnitSpec()))
.property("requireError", codecOptional(codecForBoolean()))
@@ -4232,7 +4247,17 @@ export const codecForTestingWaitTransactionRequest =
codecOptional(codecForList(codecForTransactionStatePattern())),
)
.property("bailOnError", codecOptional(codecForBoolean()))
- .build("TestingWaitTransactionRequest");
+ .build("WaitTransactionStateRequest");
+
+/** Legacy name for WaitTransactionStateRequest. */
+export type TestingWaitTransactionRequest = WaitTransactionStateRequest;
+
+/** Legacy name for WaitTransactionStateResponse. */
+export type TestingWaitTransactionStateResponse = WaitTransactionStateResponse;
+
+/** Legacy codec name. */
+export const codecForTestingWaitTransactionRequest =
+ codecForWaitTransactionStateRequest;
export interface TestingGetReserveHistoryRequest {
reservePub: string;
diff --git a/packages/taler-wallet-core/src/requests.ts b/packages/taler-wallet-core/src/requests.ts
@@ -265,7 +265,8 @@ import {
codecForTestingPlanMigrateExchangeBaseUrlRequest,
codecForTestingSetTimetravelRequest,
codecForTestingWaitBalanceRequest,
- codecForTestingWaitTransactionRequest,
+ codecForWaitTransactionStateRequest,
+ WaitTransactionStateRequest,
codecForTestingWaitExchangeReadyRequest,
codecForTestingWaitWalletKycRequest,
codecForTransactionByIdRequest,
@@ -666,6 +667,26 @@ function requireIdbBackend(
return idb;
}
+async function handleWaitTransactionState(
+ wex: WalletExecutionContext,
+ req: WaitTransactionStateRequest,
+ operation:
+ | WalletApiOperation.WaitTransactionState
+ | WalletApiOperation.TestingWaitTransactionState,
+) {
+ return runWithMaybeProgressContext(
+ wex,
+ operation,
+ req.progressToken,
+ async (pc) => {
+ if (pc) {
+ pc.onRetryNow = () => retryTransaction(wex, req.transactionId);
+ }
+ return await waitTransactionState(wex, req, operation);
+ },
+ );
+}
+
async function handlePrepareWithdrawExchange(
wex: WalletExecutionContext,
req: PrepareWithdrawExchangeRequest,
@@ -2889,9 +2910,23 @@ const handlers: { [T in WalletApiOperation]: HandlerWithValidator<T> } = {
codec: codecForStartRefundQueryRequest(),
handler: handleStartRefundQuery,
},
+ [WalletApiOperation.WaitTransactionState]: {
+ codec: codecForWaitTransactionStateRequest(),
+ handler: (wex, req) =>
+ handleWaitTransactionState(
+ wex,
+ req,
+ WalletApiOperation.WaitTransactionState,
+ ),
+ },
[WalletApiOperation.TestingWaitTransactionState]: {
- codec: codecForTestingWaitTransactionRequest(),
- handler: (wex, req) => waitTransactionState(wex, req),
+ codec: codecForWaitTransactionStateRequest(),
+ handler: (wex, req) =>
+ handleWaitTransactionState(
+ wex,
+ req,
+ WalletApiOperation.TestingWaitTransactionState,
+ ),
},
[WalletApiOperation.GetCurrencySpecification]: {
codec: codecForGetCurrencyInfoRequest(),
diff --git a/packages/taler-wallet-core/src/testing.test.ts b/packages/taler-wallet-core/src/testing.test.ts
@@ -0,0 +1,582 @@
+/*
+ This file is part of GNU Taler
+ (C) 2026 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/>
+ */
+import assert from "node:assert/strict";
+import { test, TestContext } from "node:test";
+import {
+ CancellationToken,
+ NotificationType,
+ TalerError,
+ TalerErrorCode,
+ TalerPreciseTimestamp,
+ TestingWaitTransactionRequest,
+ TestingWaitTransactionStateResponse,
+ TimerAPI,
+ TimerGroup,
+ TimerHandle,
+ Transaction,
+ TransactionAction,
+ TransactionIdStr,
+ TransactionMajorState,
+ WalletNotification,
+} from "@gnu-taler/taler-util";
+import { timestampPreciseToDb, WalletOperationRetry } from "./db/records.js";
+import { WalletDbTransaction } from "./db/transaction.js";
+import { ProgressContext } from "./progress.js";
+import { dispatchRequestInternal } from "./requests.js";
+import { WalletApiOperation } from "./wallet-api-types.js";
+import { WalletExecutionContext } from "./wallet.js";
+import { WithdrawTransactionContext } from "./withdraw.js";
+
+const transactionId = "txn:withdrawal:wait-test" as TransactionIdStr;
+const flush = () => new Promise<void>((resolve) => setImmediate(resolve));
+
+class ManualTimer implements TimerAPI {
+ now = 0;
+ timers = new Set<{ due: number; callback: () => void }>();
+ after(ms: number, callback: () => void): TimerHandle {
+ const timer = { due: this.now + ms, callback };
+ this.timers.add(timer);
+ return {
+ clear: () => {
+ this.timers.delete(timer);
+ },
+ unref() {},
+ };
+ }
+ every(): TimerHandle {
+ throw Error("unexpected interval");
+ }
+ async advance(ms: number): Promise<void> {
+ const end = this.now + ms;
+ while (true) {
+ const next = [...this.timers]
+ .filter((x) => x.due <= end)
+ .sort((a, b) => a.due - b.due)[0];
+ if (!next) break;
+ this.now = next.due;
+ this.timers.delete(next);
+ next.callback();
+ await flush();
+ }
+ this.now = end;
+ await flush();
+ }
+}
+
+function fixture(
+ t: TestContext,
+ operation = WalletApiOperation.WaitTransactionState,
+) {
+ const timer = new ManualTimer();
+ const notifications: WalletNotification[] = [];
+ const listeners = new Set<(n: WalletNotification) => void>();
+ const progressMap = new Map<string, ProgressContext>();
+ let current = {
+ transactionId,
+ txState: { major: TransactionMajorState.Pending },
+ stId: 1,
+ txActions: [TransactionAction.Retry],
+ } as Transaction;
+ let missing = false;
+ let retryRecord: WalletOperationRetry | undefined;
+ const retryLookups: string[] = [];
+ const resetTasks: string[] = [];
+ t.mock.method(
+ WithdrawTransactionContext.prototype,
+ "lookupFullTransaction",
+ async () => (missing ? undefined : current),
+ );
+ const db = {
+ async getWithdrawalGroup() {},
+ async getOperationRetry(taskId: string) {
+ retryLookups.push(taskId);
+ return retryRecord;
+ },
+ async getTransactionMeta() {},
+ async getLocalTransactionIdentifiers() {
+ return new Map();
+ },
+ } as unknown as WalletDbTransaction;
+ const ws = {
+ initCalled: true,
+ progressMap,
+ timerGroup: new TimerGroup(timer),
+ notify(n: WalletNotification) {
+ notifications.push(n);
+ for (const listener of listeners) listener(n);
+ },
+ addNotificationListener(listener: (n: WalletNotification) => void) {
+ listeners.add(listener);
+ return () => {
+ listeners.delete(listener);
+ };
+ },
+ };
+ function context() {
+ const cts = CancellationToken.create();
+ return {
+ ws,
+ cts,
+ cancellationToken: cts.token,
+ taskScheduler: {
+ async ensureRunning() {},
+ async resetTask(taskId: string) {
+ resetTasks.push(taskId);
+ },
+ },
+ async runWalletDbTx<T>(f: (tx: WalletDbTransaction) => Promise<T>) {
+ return f(db);
+ },
+ } as unknown as WalletExecutionContext;
+ }
+ function start(req: Partial<TestingWaitTransactionRequest> = {}) {
+ const wex = context();
+ const result = dispatchRequestInternal(wex, operation, {
+ transactionId,
+ txState: "final",
+ ...req,
+ }) as Promise<TestingWaitTransactionStateResponse>;
+ // Tests deliberately keep requests pending while manipulating time/state.
+ void result.catch(() => {});
+ return { wex, result };
+ }
+ function update(changes: Partial<Transaction>) {
+ const oldTxState = current.txState;
+ current = { ...current, ...changes } as Transaction;
+ ws.notify({
+ type: NotificationType.TransactionStateTransition,
+ transactionId,
+ causeHint: undefined,
+ oldTxState,
+ newTxState: current.txState,
+ newStId: current.stId,
+ });
+ }
+ const control = (op: WalletApiOperation, progressToken: string) =>
+ dispatchRequestInternal(context(), op, { operation, progressToken });
+ return {
+ timer,
+ db,
+ retryLookups,
+ setRetry(record: WalletOperationRetry | undefined) {
+ retryRecord = record;
+ },
+ errors: (token: string) =>
+ notifications.flatMap((n) =>
+ n.type === NotificationType.RequestProgressError &&
+ n.progressToken === token
+ ? [n]
+ : [],
+ ),
+ notifications,
+ listeners,
+ progressMap,
+ resetTasks,
+ start,
+ update,
+ setMissing() {
+ missing = true;
+ },
+ cancel: (token: string) =>
+ control(WalletApiOperation.CancelProgressToken, token),
+ retry: (token: string) =>
+ control(WalletApiOperation.RetryProgressTokenNow, token),
+ phases: (token: string) =>
+ notifications.flatMap((n) =>
+ n.type === NotificationType.RequestProgressPhase &&
+ n.progressToken === token
+ ? [n.phase]
+ : [],
+ ),
+ };
+}
+
+function errorCode(code: TalerErrorCode) {
+ return (error: unknown) =>
+ error instanceof TalerError && error.errorDetail.code === code;
+}
+
+test("wait reports delay phases and clears its deadline on target match", async (t) => {
+ const f = fixture(t);
+ const wait = f.start({ progressToken: "delayed", timeout: { seconds: 30 } });
+ await flush();
+ await f.timer.advance(5000);
+ assert.deepEqual(f.phases("delayed"), ["delayed"]);
+ await f.timer.advance(5000);
+ assert.deepEqual(f.phases("delayed"), ["delayed", "stalled"]);
+ f.update({ txState: { major: TransactionMajorState.Done }, stId: 100 });
+ assert.deepEqual(await wait.result, {
+ matched: "target",
+ txState: { major: TransactionMajorState.Done },
+ stId: 100,
+ });
+ assert.deepEqual(f.phases("delayed"), ["delayed", "stalled", "done"]);
+ assert.equal(f.listeners.size, 0);
+ assert.equal(f.progressMap.size, 0);
+ assert.equal(f.timer.timers.size, 0);
+ await f.timer.advance(30000);
+ assert.equal(wait.wex.cancellationToken.isCancelled, false);
+});
+
+test("immediate matches work with and without a token", async (t) => {
+ const f = fixture(t);
+ f.update({ txState: { major: TransactionMajorState.Done } });
+ for (const progressToken of [undefined, "immediate"]) {
+ const wait = f.start({ progressToken, timeout: { seconds: 30 } });
+ assert.equal((await wait.result).matched, "target");
+ await f.timer.advance(30000);
+ assert.equal(wait.wex.cancellationToken.isCancelled, false);
+ assert.equal(f.timer.timers.size, 0);
+ }
+ assert.deepEqual(f.phases("immediate"), ["done"]);
+ assert.equal(f.listeners.size, 0);
+});
+
+test("cancelling one wait leaves independent waits and the transaction running", async (t) => {
+ const f = fixture(t);
+ const first = f.start({ progressToken: "first", timeout: { seconds: 30 } });
+ const second = f.start({ progressToken: "second" });
+ await flush();
+ await f.cancel("first");
+ await assert.rejects(
+ first.result,
+ errorCode(TalerErrorCode.WALLET_CORE_REQUEST_CANCELLED),
+ );
+ assert.equal(f.listeners.size, 1);
+ assert.equal(f.progressMap.size, 1);
+ f.update({ txState: { major: TransactionMajorState.Done } });
+ assert.equal((await second.result).matched, "target");
+ await f.timer.advance(30000);
+ assert.deepEqual(f.phases("first"), ["done"]);
+ assert.deepEqual(f.phases("second"), ["done"]);
+ assert.equal(f.timer.timers.size, 0);
+ assert.equal(f.listeners.size, 0);
+ assert.equal(f.progressMap.size, 0);
+});
+
+test("replacing a token cancels the old wait without unregistering its replacement", async (t) => {
+ const f = fixture(t);
+ const old = f.start({ progressToken: "same" });
+ await flush();
+ const replacement = f.start({ progressToken: "same" });
+ await assert.rejects(
+ old.result,
+ errorCode(TalerErrorCode.WALLET_CORE_REQUEST_CANCELLED),
+ );
+ assert.equal(f.progressMap.size, 1);
+ await f.cancel("same");
+ await assert.rejects(
+ replacement.result,
+ errorCode(TalerErrorCode.WALLET_CORE_REQUEST_CANCELLED),
+ );
+ assert.equal(f.progressMap.size, 0);
+ assert.equal(f.listeners.size, 0);
+ await f.timer.advance(10000);
+ assert.deepEqual(f.phases("same"), ["done", "done"]);
+});
+
+test("timeout remains distinct from cancellation and removes the waiter", async (t) => {
+ const f = fixture(t);
+ for (const progressToken of [undefined, "timeout"]) {
+ const wait = f.start({ progressToken, timeout: { seconds: 1 } });
+ await flush();
+ await f.timer.advance(1000);
+ await assert.rejects(
+ wait.result,
+ errorCode(TalerErrorCode.GENERIC_TIMEOUT),
+ );
+ assert.equal(f.listeners.size, 0);
+ assert.equal(f.progressMap.size, 0);
+ }
+ await f.timer.advance(10000);
+ assert.deepEqual(f.phases("timeout"), ["done"]);
+ assert.equal(f.timer.timers.size, 0);
+});
+
+test("lookup failure cleans up progress and the deadline", async (t) => {
+ const f = fixture(t);
+ f.setMissing();
+ const wait = f.start({ progressToken: "missing", timeout: { seconds: 30 } });
+ await assert.rejects(
+ wait.result,
+ errorCode(TalerErrorCode.WALLET_TRANSACTION_NOT_FOUND),
+ );
+ await f.timer.advance(30000);
+ assert.equal(wait.wex.cancellationToken.isCancelled, false);
+ assert.deepEqual(f.phases("missing"), ["done"]);
+ assert.equal(f.listeners.size, 0);
+ assert.equal(f.progressMap.size, 0);
+ assert.equal(f.timer.timers.size, 0);
+});
+
+test("target/error and bail matching retain their precedence", async (t) => {
+ const f = fixture(t);
+ f.update({ txState: { major: TransactionMajorState.Done } });
+ const wait = f.start({
+ progressToken: "error",
+ requireError: true,
+ bailOnError: true,
+ });
+ await flush();
+ assert.equal(f.listeners.size, 1);
+ f.update({ error: { code: TalerErrorCode.GENERIC_TIMEOUT } });
+ assert.equal((await wait.result).matched, "target");
+ for (const req of [
+ { txState: 999, bailOnError: true },
+ {
+ txState: 999,
+ requireError: true,
+ bailStates: [{ major: TransactionMajorState.Done }],
+ },
+ ]) {
+ assert.equal(
+ (await f.start({ progressToken: "bail", ...req }).result).matched,
+ "bail",
+ );
+ }
+ assert.equal(f.progressMap.size, 0);
+ assert.equal(f.listeners.size, 0);
+});
+
+test("retry-now resets the transaction task without extending the wait deadline", async (t) => {
+ const f = fixture(t);
+ const wait = f.start({ progressToken: "retry", timeout: { seconds: 3 } });
+ await flush();
+ await f.timer.advance(2000);
+ await f.retry("retry");
+ assert.deepEqual(f.resetTasks, ["withdraw:wait-test"]);
+ assert.equal(f.listeners.size, 1);
+ await f.timer.advance(1000);
+ await assert.rejects(wait.result, errorCode(TalerErrorCode.GENERIC_TIMEOUT));
+ await f.timer.advance(10000);
+ assert.deepEqual(f.phases("retry"), ["done"]);
+});
+
+test("unsupported retry rejects the control request and leaves the wait active", async (t) => {
+ const f = fixture(t);
+ f.update({ txActions: [] });
+ const wait = f.start({ progressToken: "unsupported" });
+ await flush();
+ await assert.rejects(
+ f.retry("unsupported"),
+ errorCode(TalerErrorCode.WALLET_REQUEST_TRANSACTION_STATE_UNSUPPORTED),
+ );
+ assert.deepEqual(f.resetTasks, []);
+ assert.equal(f.listeners.size, 1);
+ f.update({ txState: { major: TransactionMajorState.Done } });
+ assert.equal((await wait.result).matched, "target");
+});
+
+for (const operation of [
+ WalletApiOperation.WaitTransactionState,
+ WalletApiOperation.TestingWaitTransactionState,
+]) {
+ test(`${operation} preserves the invoked name in progress and timeout errors`, async (t) => {
+ const f = fixture(t, operation);
+ const cancelled = f.start({ progressToken: "alias" });
+ await flush();
+ await f.retry("alias");
+ assert.deepEqual(f.resetTasks, ["withdraw:wait-test"]);
+ await f.cancel("alias");
+ await assert.rejects(
+ cancelled.result,
+ errorCode(TalerErrorCode.WALLET_CORE_REQUEST_CANCELLED),
+ );
+ const timed = f.start({
+ timeout: { seconds: 1 },
+ progressToken: "timeout-alias",
+ });
+ await flush();
+ await f.timer.advance(1000);
+ await assert.rejects(timed.result, (error: unknown) => {
+ assert.ok(error instanceof TalerError);
+ assert.equal(error.errorDetail.code, TalerErrorCode.GENERIC_TIMEOUT);
+ assert.equal(error.errorDetail.operation, operation);
+ return true;
+ });
+ for (const n of f.notifications) {
+ if (n.type === NotificationType.RequestProgressPhase)
+ assert.equal(n.operation, operation);
+ }
+ f.update({ txState: { major: TransactionMajorState.Done } });
+ assert.equal((await f.start().result).matched, "target");
+ });
+}
+
+const transactionError = {
+ code: TalerErrorCode.GENERIC_TIMEOUT,
+ hint: "transaction network timeout",
+};
+function retryRecord(
+ nextRetryMs: number,
+ retryCounter = 2,
+): WalletOperationRetry {
+ return {
+ id: "withdraw:wait-test",
+ lastError: transactionError,
+ retryInfo: {
+ firstTry: timestampPreciseToDb(TalerPreciseTimestamp.fromMilliseconds(0)),
+ nextRetry: timestampPreciseToDb(
+ TalerPreciseTimestamp.fromMilliseconds(nextRetryMs),
+ ),
+ retryCounter,
+ },
+ };
+}
+
+for (const operation of [
+ WalletApiOperation.WaitTransactionState,
+ WalletApiOperation.TestingWaitTransactionState,
+]) {
+ test(`${operation} reports transaction errors and retry changes without duplicate updates`, async (t) => {
+ const f = fixture(t, operation);
+ t.mock.method(Date.prototype, "getTime", () => f.timer.now);
+ f.setRetry(retryRecord(5000));
+ f.update({ error: transactionError });
+ const wait = f.start({ progressToken: "errors" });
+ await flush();
+ assert.deepEqual(f.errors("errors"), [
+ {
+ type: NotificationType.RequestProgressError,
+ operation,
+ progressToken: "errors",
+ error: transactionError,
+ nextRetryDelay: { d_us: 5_000_000 },
+ retryCounter: 2,
+ },
+ ]);
+ // Elapsed time and unrelated state changes do not identify a new attempt.
+ await f.timer.advance(1000);
+ f.update({ stId: 2 });
+ await flush();
+ assert.equal(f.errors("errors").length, 1);
+ f.setRetry(retryRecord(11000, 3));
+ f.update({ error: transactionError });
+ await flush();
+ assert.equal(f.errors("errors").length, 2);
+ assert.deepEqual(f.errors("errors")[1].nextRetryDelay, {
+ d_us: 10_000_000,
+ });
+ assert.equal(f.errors("errors")[1].retryCounter, 3);
+ // Clearing an error allows a later identical error to be reported again.
+ f.update({ error: undefined });
+ await flush();
+ f.update({ error: transactionError });
+ await flush();
+ assert.equal(f.errors("errors").length, 3);
+ f.update({ error: { ...transactionError, hint: "another error" } });
+ await flush();
+ assert.equal(f.errors("errors").length, 4);
+ assert.ok(f.retryLookups.every((id) => id === "withdraw:wait-test"));
+ await f.cancel("errors");
+ await assert.rejects(
+ wait.result,
+ errorCode(TalerErrorCode.WALLET_CORE_REQUEST_CANCELLED),
+ );
+ f.update({ error: transactionError });
+ await f.timer.advance(15000);
+ assert.equal(f.errors("errors").length, 4);
+ assert.equal(f.listeners.size, 0);
+ });
+}
+
+test("transaction error notifications require a progress token", async (t) => {
+ const f = fixture(t);
+ f.update({ error: transactionError });
+ const wait = f.start();
+ await flush();
+ assert.deepEqual(f.retryLookups, []);
+ assert.ok(
+ !f.notifications.some(
+ (n) => n.type === NotificationType.RequestProgressError,
+ ),
+ );
+ wait.wex.cts!.cancel();
+ await assert.rejects(
+ wait.result,
+ errorCode(TalerErrorCode.WALLET_CORE_REQUEST_CANCELLED),
+ );
+});
+
+test("errors without a recorded error retry report no scheduled retry", async (t) => {
+ const f = fixture(t);
+ f.update({ error: transactionError });
+ for (const record of [
+ undefined,
+ { ...retryRecord(1000), lastError: undefined },
+ ]) {
+ f.setRetry(record);
+ const wait = f.start({ progressToken: "unscheduled", bailOnError: true });
+ assert.equal((await wait.result).matched, "bail");
+ }
+ assert.equal(f.errors("unscheduled").length, 2);
+ for (const n of f.errors("unscheduled")) {
+ assert.deepEqual(n.nextRetryDelay, { d_us: "forever" });
+ assert.equal(n.retryCounter, 0);
+ }
+ assert.deepEqual(
+ f.notifications
+ .filter(
+ (n) =>
+ n.type === NotificationType.RequestProgressError ||
+ n.type === NotificationType.RequestProgressPhase,
+ )
+ .map((n) => n.type),
+ [
+ NotificationType.RequestProgressError,
+ NotificationType.RequestProgressPhase,
+ NotificationType.RequestProgressError,
+ NotificationType.RequestProgressPhase,
+ ],
+ );
+});
+
+test("an overdue transaction retry reports zero remaining delay", async (t) => {
+ const f = fixture(t);
+ t.mock.method(Date.prototype, "getTime", () => 2000);
+ f.setRetry(retryRecord(1000));
+ f.update({ error: transactionError });
+ assert.equal(
+ (
+ await f.start({
+ progressToken: "overdue",
+ txState: 1,
+ requireError: true,
+ }).result
+ ).matched,
+ "target",
+ );
+ assert.deepEqual(f.errors("overdue")[0].nextRetryDelay, { d_us: 0 });
+});
+
+test("a retry lookup finishing after the wait deadline emits no error after done", async (t) => {
+ const f = fixture(t);
+ let release!: (value: WalletOperationRetry) => void;
+ const lookup = new Promise<WalletOperationRetry>((resolve) => {
+ release = resolve;
+ });
+ t.mock.method(f.db, "getOperationRetry", () => lookup);
+ f.update({ error: transactionError });
+ const wait = f.start({ progressToken: "late", timeout: { seconds: 1 } });
+ await flush();
+ await f.timer.advance(1000);
+ await assert.rejects(wait.result, errorCode(TalerErrorCode.GENERIC_TIMEOUT));
+ assert.deepEqual(f.phases("late"), ["done"]);
+ release(retryRecord(2000));
+ await flush();
+ assert.deepEqual(f.errors("late"), []);
+ assert.equal(f.listeners.size, 0);
+});
diff --git a/packages/taler-wallet-core/src/testing.ts b/packages/taler-wallet-core/src/testing.ts
@@ -49,11 +49,12 @@ import {
TalerErrorCode,
TalerMerchantInstanceHttpClient,
TestingWaitBalanceRequest,
- TestingWaitTransactionRequest,
- TestingWaitTransactionStateResponse,
+ WaitTransactionStateRequest,
+ WaitTransactionStateResponse,
TestingWaitTxStateSpec,
TestPayArgs,
TestPayResult,
+ TimerHandle,
Transaction,
TransactionIdStr,
TransactionMajorState,
@@ -64,6 +65,7 @@ import {
import { HttpRequestLibrary } from "@gnu-taler/taler-util/http";
import { getBalanceDetail, getBalances } from "./balance.js";
import { genericWaitForState } from "./common.js";
+import { timestampAbsoluteFromDb } from "./db/records.js";
import { createDepositGroup } from "./deposits.js";
import { fetchFreshExchange } from "./exchanges.js";
import {
@@ -86,6 +88,7 @@ import { getRefreshesForTransaction } from "./refresh.js";
import {
getTransactionById,
getTransactions,
+ maybeTaskFromTransaction,
parseTransactionIdentifier,
} from "./transactions.js";
import type { WalletExecutionContext } from "./wallet.js";
@@ -661,8 +664,9 @@ function matchStateSpec(
*/
export async function waitTransactionState(
wex: WalletExecutionContext,
- req: TestingWaitTransactionRequest,
-): Promise<TestingWaitTransactionStateResponse> {
+ req: WaitTransactionStateRequest,
+ operation = "waitTransactionState",
+): Promise<WaitTransactionStateResponse> {
const transactionId = req.transactionId;
const txState = req.txState;
const logId = req.logId ?? "none";
@@ -672,18 +676,19 @@ export async function waitTransactionState(
)}) (start logId: ${logId})`,
);
let timeoutPromise;
+ let timeoutHandle: TimerHandle | undefined;
if (req.timeout != null) {
const durationMs = Duration.fromSpec(req.timeout).d_ms;
checkLogicInvariant(durationMs !== "forever");
timeoutPromise = new Promise<never>((resolve, reject) => {
- wex.ws.timerGroup.after(durationMs, () => {
+ timeoutHandle = wex.ws.timerGroup.after(durationMs, () => {
// Cancel the waiter.
wex.cts?.cancel();
reject(
TalerError.fromDetail(
TalerErrorCode.GENERIC_TIMEOUT,
{
- operation: "testingWaitTransactionState",
+ operation,
transactionId,
timeoutMs: durationMs,
},
@@ -696,7 +701,9 @@ export async function waitTransactionState(
// No timeout => never resolve!
timeoutPromise = new Promise<never>(() => {});
}
- let result: TestingWaitTransactionStateResponse | undefined;
+ let result: WaitTransactionStateResponse | undefined;
+ let lastProgressError: string | undefined;
+ const pc = req.progressToken != null ? wex.progressContext : undefined;
const waitPromise = genericWaitForState(wex, {
async checkState() {
const tx = await getTransactionById(wex, {
@@ -707,6 +714,41 @@ export async function waitTransactionState(
tx.txState,
)} (update logId: ${logId})`,
);
+ if (pc && tx.error != null) {
+ const taskId = maybeTaskFromTransaction(transactionId);
+ const retryRecord = taskId
+ ? await wex.runWalletDbTx((db) => db.getOperationRetry(taskId))
+ : undefined;
+ const retryInfo = retryRecord?.lastError
+ ? retryRecord.retryInfo
+ : undefined;
+ // Use the stored deadline, not its decreasing remaining duration, to
+ // avoid reporting the same failed attempt on every state update.
+ const errorKey = JSON.stringify([tx.error, retryInfo]);
+ if (
+ errorKey !== lastProgressError &&
+ !pc.finished &&
+ !wex.cancellationToken.isCancelled
+ ) {
+ lastProgressError = errorKey;
+ wex.ws.notify({
+ type: NotificationType.RequestProgressError,
+ operation: pc.operation,
+ progressToken: pc.progressToken,
+ error: tx.error,
+ nextRetryDelay: retryInfo
+ ? Duration.toTalerProtocolDuration(
+ AbsoluteTime.remaining(
+ timestampAbsoluteFromDb(retryInfo.nextRetry),
+ ),
+ )
+ : { d_us: "forever" },
+ retryCounter: retryInfo?.retryCounter ?? 0,
+ });
+ }
+ } else if (tx.error == null) {
+ lastProgressError = undefined;
+ }
if (!req.requireError || tx.error != null) {
if (matchStateSpec(tx, txState)) {
result = { matched: "target", txState: tx.txState, stId: tx.stId };
@@ -729,7 +771,11 @@ export async function waitTransactionState(
notif.type === NotificationType.TransactionStateTransition &&
notif.transactionId === transactionId,
});
- await Promise.race([timeoutPromise, waitPromise]);
+ try {
+ await Promise.race([timeoutPromise, waitPromise]);
+ } finally {
+ timeoutHandle?.clear();
+ }
checkLogicInvariant(result != null);
logger.info(
`done waiting for ${transactionId} to be in ${JSON.stringify(
diff --git a/packages/taler-wallet-core/src/transactions.ts b/packages/taler-wallet-core/src/transactions.ts
@@ -941,7 +941,7 @@ export function parseTransactionIdentifier(
}
}
-function maybeTaskFromTransaction(
+export function maybeTaskFromTransaction(
transactionId: string,
): TaskIdStr | undefined {
const parsedTx = parseTransactionIdentifier(transactionId);
diff --git a/packages/taler-wallet-core/src/wallet-api-types.ts b/packages/taler-wallet-core/src/wallet-api-types.ts
@@ -212,6 +212,8 @@ import {
TestingWaitExchangeStateRequest,
TestingWaitTransactionRequest,
TestingWaitTransactionStateResponse,
+ WaitTransactionStateRequest,
+ WaitTransactionStateResponse,
TestingWaitWalletKycRequest,
Transaction,
TransactionByIdRequest,
@@ -387,6 +389,8 @@ export enum WalletApiOperation {
ClearDb = "clearDb",
Recycle = "recycle",
+ WaitTransactionState = "waitTransactionState",
+
// Testing
ApplyDevExperiment = "applyDevExperiment",
TestingGetSampleTransactions = "testingGetSampleTransactions",
@@ -1653,6 +1657,13 @@ export type TestingWaitBalanceOp = {
/**
* Wait until a transaction is in a particular state.
*/
+export type WaitTransactionStateOp = {
+ op: WalletApiOperation.WaitTransactionState;
+ request: WaitTransactionStateRequest;
+ response: WaitTransactionStateResponse;
+};
+
+/** Legacy alias for waitTransactionState. */
export type TestingWaitTransactionStateOp = {
op: WalletApiOperation.TestingWaitTransactionState;
request: TestingWaitTransactionRequest;
@@ -2170,6 +2181,7 @@ export type WalletOperations = {
[WalletApiOperation.TestingWaitRefreshesFinal]: TestingWaitRefreshesFinalOp;
[WalletApiOperation.TestingSetTimetravel]: TestingSetTimetravelOp;
[WalletApiOperation.TestingGetDbStats]: TestingGetDbStats;
+ [WalletApiOperation.WaitTransactionState]: WaitTransactionStateOp;
[WalletApiOperation.TestingWaitTransactionState]: TestingWaitTransactionStateOp;
[WalletApiOperation.TestingWaitBalance]: TestingWaitBalanceOp;
[WalletApiOperation.TestingWaitExchangeState]: TestingWaitExchangeStateOp;