commit 42e42055b74cf1f194263c42d935c503b993bd22
parent d84dadfe2787b4d4891010fee58fe2692fde9a25
Author: Florian Dold <dold@taler.net>
Date: Thu, 6 Aug 2026 14:21:37 +0200
wallet-core: let transaction state waits match the working flag and bail out
The wait can now ask for the working flag or the "final" category, give up
on states it will never leave, and says which of the two ended it. The
request is finally validated, since an unknown state used to wait forever,
and a timeout raises a proper error code instead of a plain exception.
Diffstat:
7 files changed, 442 insertions(+), 47 deletions(-)
diff --git a/packages/taler-util/src/codec.ts b/packages/taler-util/src/codec.ts
@@ -491,6 +491,36 @@ export function codecForStringUnion<T extends Array<string>>(
}
/**
+ * Return a codec for a value that must be one of the values of a
+ * string enum.
+ *
+ * Only usable with string enums: a numeric enum object also contains
+ * the reverse mapping, which would be accepted as a valid value.
+ */
+export function codecForStringEnum<T extends string>(
+ enumObj: Readonly<Record<string, T>>,
+): Codec<T> {
+ const vals = Object.values(enumObj);
+ return {
+ decode(x: any, c?: Context): T {
+ if (typeof x !== "string") {
+ throw new DecodingError(
+ `expected string at ${renderContext(c)} but got ${typeof x}`,
+ );
+ }
+ if (!vals.includes(x as T)) {
+ throw new DecodingError(
+ `expected one of ${JSON.stringify(vals)} at ${renderContext(
+ c,
+ )} but got ${x}`,
+ );
+ }
+ return x as T;
+ },
+ };
+}
+
+/**
* Return a codec for a string that must be an absolute http(s) URL.
*
* Parsing alone is not enough for the fields this guards: they are opened by
diff --git a/packages/taler-util/src/types-taler-wallet-transactions.ts b/packages/taler-util/src/types-taler-wallet-transactions.ts
@@ -251,6 +251,31 @@ export enum TransactionMinorState {
Abort = "abort",
}
+/**
+ * Is the transaction in a state that the wallet will never
+ * leave on its own?
+ *
+ * A dialog state is not final: it is waiting for the user, and the
+ * transaction continues once the user has decided.
+ *
+ * Note that the "nonfinal" filter of the transaction list is computed
+ * from the internal DB status instead, and that the wait helpers in
+ * wallet-core's testing.ts each use their own (older, mutually
+ * inconsistent) notion of finality.
+ */
+export function isFinalTransactionState(state: TransactionState): boolean {
+ switch (state.major) {
+ case TransactionMajorState.Done:
+ case TransactionMajorState.Failed:
+ case TransactionMajorState.Aborted:
+ case TransactionMajorState.Expired:
+ case TransactionMajorState.Deleted:
+ return true;
+ default:
+ return false;
+ }
+}
+
export enum TransactionAction {
Delete = "delete",
Suspend = "suspend",
diff --git a/packages/taler-util/src/types-taler-wallet.test.ts b/packages/taler-util/src/types-taler-wallet.test.ts
@@ -0,0 +1,169 @@
+/*
+ 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 Affero 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 Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+
+ SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+import assert from "node:assert";
+import { test } from "node:test";
+import {
+ codecForTestingWaitTransactionRequest,
+ matchTransactionState,
+} from "./types-taler-wallet.js";
+import {
+ isFinalTransactionState,
+ TransactionMajorState,
+ TransactionMinorState,
+} from "./types-taler-wallet-transactions.js";
+
+const pendingWithdraw = {
+ major: TransactionMajorState.Pending,
+ minor: TransactionMinorState.Withdraw,
+};
+
+test("state pattern without working flag ignores it", (t) => {
+ assert.strictEqual(
+ matchTransactionState(
+ { ...pendingWithdraw, working: true },
+ {
+ ...pendingWithdraw,
+ },
+ ),
+ true,
+ );
+ assert.strictEqual(
+ matchTransactionState(pendingWithdraw, { ...pendingWithdraw }),
+ true,
+ );
+});
+
+test("state pattern matches the working flag", (t) => {
+ const workingPat = { ...pendingWithdraw, working: true };
+ const idlePat = { ...pendingWithdraw, working: false };
+ const workingSt = { ...pendingWithdraw, working: true };
+ // An absent working flag counts as false.
+ const idleSt = pendingWithdraw;
+ assert.strictEqual(matchTransactionState(workingSt, workingPat), true);
+ assert.strictEqual(matchTransactionState(workingSt, idlePat), false);
+ assert.strictEqual(matchTransactionState(idleSt, workingPat), false);
+ assert.strictEqual(matchTransactionState(idleSt, idlePat), true);
+});
+
+test("working wildcard matches both", (t) => {
+ const pat = { ...pendingWithdraw, working: "*" as const };
+ assert.strictEqual(
+ matchTransactionState({ ...pendingWithdraw, working: true }, pat),
+ true,
+ );
+ assert.strictEqual(matchTransactionState(pendingWithdraw, pat), true);
+});
+
+test("omitted minor state in a pattern requires an absent minor state", (t) => {
+ assert.strictEqual(
+ matchTransactionState(pendingWithdraw, {
+ major: TransactionMajorState.Pending,
+ }),
+ false,
+ );
+ assert.strictEqual(
+ matchTransactionState(
+ { major: TransactionMajorState.Done },
+ { major: TransactionMajorState.Done },
+ ),
+ true,
+ );
+});
+
+test("working flag does not override major/minor mismatch", (t) => {
+ assert.strictEqual(
+ matchTransactionState(
+ { ...pendingWithdraw, working: true },
+ { major: TransactionMajorState.Aborting, minor: "*", working: true },
+ ),
+ false,
+ );
+});
+
+test("finality of transaction states", (t) => {
+ for (const major of [
+ TransactionMajorState.Done,
+ TransactionMajorState.Failed,
+ TransactionMajorState.Aborted,
+ TransactionMajorState.Expired,
+ TransactionMajorState.Deleted,
+ ]) {
+ assert.strictEqual(isFinalTransactionState({ major }), true, major);
+ }
+ for (const major of [
+ TransactionMajorState.Pending,
+ TransactionMajorState.Aborting,
+ TransactionMajorState.Finalizing,
+ TransactionMajorState.Dialog,
+ TransactionMajorState.Suspended,
+ TransactionMajorState.SuspendedAborting,
+ TransactionMajorState.SuspendedFinalizing,
+ ]) {
+ assert.strictEqual(isFinalTransactionState({ major }), false, major);
+ }
+});
+
+test("wait request codec accepts every form of txState", (t) => {
+ const codec = codecForTestingWaitTransactionRequest();
+ const transactionId = "txn:withdrawal:foo";
+ for (const txState of [
+ "nonpending",
+ "final",
+ 42,
+ { major: "done" },
+ { major: "*", minor: "*", working: true },
+ [{ major: "failed", minor: "*" }, { major: "done" }],
+ ]) {
+ const req = codec.decode({ transactionId, txState });
+ // The codec adds the optional properties as undefined, which
+ // JSON drops again.
+ assert.deepStrictEqual(
+ JSON.parse(JSON.stringify(req.txState)),
+ txState,
+ JSON.stringify(txState),
+ );
+ }
+ const full = codec.decode({
+ transactionId,
+ txState: "final",
+ logId: "l",
+ timeout: { seconds: 5 },
+ requireError: true,
+ bailOnError: true,
+ bailStates: [{ major: "failed", minor: "*" }],
+ });
+ assert.strictEqual(full.logId, "l");
+ assert.strictEqual(full.timeout?.seconds, 5);
+ assert.strictEqual(full.bailOnError, true);
+});
+
+test("wait request codec rejects bad input", (t) => {
+ const codec = codecForTestingWaitTransactionRequest();
+ const transactionId = "txn:withdrawal:foo";
+ const bad = [
+ { transactionId, txState: { major: "dnoe" } },
+ { transactionId, txState: { major: "done", minor: "kyc-required" } },
+ { transactionId, txState: "whenever" },
+ { transactionId, txState: { major: "done", working: "yes" } },
+ { transactionId: "withdrawal:foo", txState: "final" },
+ { transactionId },
+ ];
+ for (const req of bad) {
+ assert.throws(() => codec.decode(req), JSON.stringify(req));
+ }
+});
diff --git a/packages/taler-util/src/types-taler-wallet.ts b/packages/taler-util/src/types-taler-wallet.ts
@@ -44,6 +44,7 @@ import {
codecForMap,
codecForNumber,
codecForString,
+ codecForStringEnum,
codecForStringUnion,
codecOptional,
renderContext,
@@ -3761,6 +3762,35 @@ export const codecForTestingWaitExchangeReadyRequest =
export interface TransactionStatePattern {
major: TransactionMajorState | TransactionStateWildcard;
minor?: TransactionMinorState | TransactionStateWildcard;
+
+ /**
+ * Required value of the "working" flag of the transaction state.
+ * A transaction state without the flag counts as false.
+ *
+ * If left undefined, the flag is not taken into account
+ * when matching, i.e. it behaves like a wildcard.
+ */
+ working?: boolean | TransactionStateWildcard;
+}
+
+/**
+ * Does the transaction state match the pattern?
+ *
+ * Beware that a pattern without a minor state only matches states
+ * that have no minor state, whereas a pattern without the working
+ * flag matches regardless of the flag.
+ */
+export function matchTransactionState(
+ st: TransactionState,
+ pat: TransactionStatePattern,
+): boolean {
+ return (
+ (pat.major === "*" || st.major === pat.major) &&
+ (pat.minor === "*" || st.minor === pat.minor) &&
+ (pat.working === undefined ||
+ pat.working === "*" ||
+ pat.working === !!st.working)
+ );
}
export interface TestingWaitBalanceRequest {
@@ -3795,13 +3825,120 @@ export interface TestingWaitTransactionRequest {
*/
requireError?: boolean;
- txState:
- | TransactionStatePattern
- | TransactionStatePattern[]
- | number
- | "nonpending";
+ txState: TestingWaitTxStateSpec;
+
+ /**
+ * States that end the wait even though they are not the
+ * state that was waited for. The response says which of the
+ * two sets matched.
+ *
+ * Without this, a transaction that ends up in a state it will
+ * never leave keeps the caller waiting until the timeout.
+ */
+ bailStates?: TransactionStatePattern[];
+
+ /**
+ * End the wait as soon as an error is recorded for the transaction.
+ *
+ * Beware that this includes transient errors of retried operations,
+ * which are cleared again once the operation succeeds.
+ */
+ bailOnError?: boolean;
}
+/**
+ * State(s) to wait for.
+ *
+ * A plain pattern or a list of patterns (matching any of them),
+ * a wallet-internal numeric state ID, or one of the shorthands
+ * for a category of states.
+ */
+export type TestingWaitTxStateSpec =
+ | TransactionStatePattern
+ | TransactionStatePattern[]
+ | number
+ | "nonpending"
+ | "final";
+
+export interface TestingWaitTransactionStateResponse {
+ /**
+ * Which set of states ended the wait: the requested state
+ * or one of the bail states.
+ */
+ matched: "target" | "bail";
+
+ /**
+ * State that ended the wait.
+ */
+ txState: TransactionState;
+
+ /**
+ * Wallet-internal state ID, only used for debugging and testing.
+ */
+ stId: number;
+}
+
+const codecForDurationUnitSpec = (): Codec<DurationUnitSpec> =>
+ buildCodecForObject<DurationUnitSpec>()
+ .property("seconds", codecOptional(codecForNumber()))
+ .property("minutes", codecOptional(codecForNumber()))
+ .property("hours", codecOptional(codecForNumber()))
+ .property("days", codecOptional(codecForNumber()))
+ .property("months", codecOptional(codecForNumber()))
+ .property("years", codecOptional(codecForNumber()))
+ .build("DurationUnitSpec");
+
+export const codecForTransactionStatePattern =
+ (): Codec<TransactionStatePattern> =>
+ buildCodecForObject<TransactionStatePattern>()
+ .property(
+ "major",
+ codecForEither(
+ codecForStringEnum(TransactionMajorState),
+ codecForConstString("*"),
+ ),
+ )
+ .property(
+ "minor",
+ codecOptional(
+ codecForEither(
+ codecForStringEnum(TransactionMinorState),
+ codecForConstString("*"),
+ ),
+ ),
+ )
+ .property(
+ "working",
+ codecOptional(
+ codecForEither(codecForBoolean(), codecForConstString("*")),
+ ),
+ )
+ .build("TransactionStatePattern");
+
+const codecForTestingWaitTxStateSpec = (): Codec<TestingWaitTxStateSpec> =>
+ codecForEither(
+ codecForConstString("nonpending"),
+ codecForConstString("final"),
+ codecForNumber(),
+ codecForTransactionStatePattern(),
+ codecForList(codecForTransactionStatePattern()),
+ );
+
+export const codecForTestingWaitTransactionRequest =
+ (): Codec<TestingWaitTransactionRequest> =>
+ buildCodecForObject<TestingWaitTransactionRequest>()
+ .property("transactionId", codecForTransactionIdStr())
+ .property("logId", codecOptional(codecForString()))
+ .property("timeout", codecOptional(codecForDurationUnitSpec()))
+ .property("requireError", codecOptional(codecForBoolean()))
+ .property("txState", codecForTestingWaitTxStateSpec())
+ .property(
+ "bailStates",
+ codecOptional(codecForList(codecForTransactionStatePattern())),
+ )
+ .property("bailOnError", codecOptional(codecForBoolean()))
+ .build("TestingWaitTransactionRequest");
+
export interface TestingGetReserveHistoryRequest {
reservePub: string;
exchangeBaseUrl: string;
diff --git a/packages/taler-wallet-core/src/requests.ts b/packages/taler-wallet-core/src/requests.ts
@@ -250,6 +250,7 @@ import {
codecForTestingPlanMigrateExchangeBaseUrlRequest,
codecForTestingSetTimetravelRequest,
codecForTestingWaitBalanceRequest,
+ codecForTestingWaitTransactionRequest,
codecForTestingWaitExchangeReadyRequest,
codecForTestingWaitWalletKycRequest,
codecForTransactionByIdRequest,
@@ -2758,11 +2759,8 @@ const handlers: { [T in WalletApiOperation]: HandlerWithValidator<T> } = {
handler: handleStartRefundQuery,
},
[WalletApiOperation.TestingWaitTransactionState]: {
- codec: codecForAny(),
- handler: async (wex, req) => {
- await waitTransactionState(wex, req);
- return {};
- },
+ codec: codecForTestingWaitTransactionRequest(),
+ handler: (wex, req) => waitTransactionState(wex, req),
},
[WalletApiOperation.GetCurrencySpecification]: {
codec: codecForGetCurrencyInfoRequest(),
diff --git a/packages/taler-wallet-core/src/testing.ts b/packages/taler-wallet-core/src/testing.ts
@@ -36,23 +36,28 @@ import {
Duration,
IntegrationTestArgs,
IntegrationTestV2Args,
+ isFinalTransactionState,
j2s,
Logger,
+ matchTransactionState,
NotificationType,
Paytos,
Result,
succeedOrThrow,
TalerCorebankApiClient,
+ TalerError,
+ TalerErrorCode,
TalerMerchantInstanceHttpClient,
TestingWaitBalanceRequest,
TestingWaitTransactionRequest,
+ TestingWaitTransactionStateResponse,
+ TestingWaitTxStateSpec,
TestPayArgs,
TestPayResult,
+ Transaction,
TransactionIdStr,
TransactionMajorState,
TransactionMinorState,
- TransactionState,
- TransactionStatePattern,
TransactionType,
WithdrawTestBalanceRequest,
} from "@gnu-taler/taler-util";
@@ -436,6 +441,11 @@ async function runIntegrationTestImpl(
/**
* Wait until all transactions are in a final state.
+ *
+ * Note that the notion of finality used here predates
+ * isFinalTransactionState and differs from it: a transaction in a
+ * dialog or suspended-finalizing state counts as final. Changing that
+ * would change the behavior of many tests, so it is left alone.
*/
export async function waitUntilAllTransactionsFinal(
wex: WalletExecutionContext,
@@ -495,6 +505,9 @@ export async function waitTasksDone(
/**
* Wait until all chosen transactions are in a final state.
+ *
+ * Uses the same older notion of finality as
+ * waitUntilAllTransactionsFinal, not isFinalTransactionState.
*/
export async function waitUntilGivenTransactionsFinal(
wex: WalletExecutionContext,
@@ -605,7 +618,7 @@ async function waitUntilTransactionPendingReady(
wex: WalletExecutionContext,
transactionId: string,
): Promise<void> {
- return await waitTransactionState(wex, {
+ await waitTransactionState(wex, {
transactionId: transactionId as TransactionIdStr,
txState: {
major: TransactionMajorState.Pending,
@@ -614,14 +627,31 @@ async function waitUntilTransactionPendingReady(
});
}
-function matchState(
- st: TransactionState,
- pat: TransactionStatePattern,
+/**
+ * Does the transaction match the state(s) that were waited for?
+ */
+function matchStateSpec(
+ tx: Transaction,
+ spec: TestingWaitTxStateSpec,
): boolean {
- return (
- (pat.major === "*" || st.major === pat.major) &&
- (pat.minor === "*" || st.minor === pat.minor)
- );
+ if (spec === "nonpending") {
+ return tx.txState.major !== TransactionMajorState.Pending;
+ }
+ if (spec === "final") {
+ return isFinalTransactionState(tx.txState);
+ }
+ if (typeof spec === "number") {
+ return tx.stId === spec;
+ }
+ if (Array.isArray(spec)) {
+ for (const pat of spec) {
+ if (matchTransactionState(tx.txState, pat)) {
+ return true;
+ }
+ }
+ return false;
+ }
+ return matchTransactionState(tx.txState, spec);
}
/**
@@ -630,7 +660,7 @@ function matchState(
export async function waitTransactionState(
wex: WalletExecutionContext,
req: TestingWaitTransactionRequest,
-): Promise<void> {
+): Promise<TestingWaitTransactionStateResponse> {
const transactionId = req.transactionId;
const txState = req.txState;
const logId = req.logId ?? "none";
@@ -643,17 +673,28 @@ export async function waitTransactionState(
if (req.timeout != null) {
const durationMs = Duration.fromSpec(req.timeout).d_ms;
checkLogicInvariant(durationMs !== "forever");
- timeoutPromise = new Promise((resolve, reject) => {
+ timeoutPromise = new Promise<never>((resolve, reject) => {
wex.ws.timerGroup.after(durationMs, () => {
// Cancel the waiter.
wex.cts?.cancel();
- reject(new Error(`waiting timed out (timeout logId: ${logId}`));
+ reject(
+ TalerError.fromDetail(
+ TalerErrorCode.GENERIC_TIMEOUT,
+ {
+ operation: "testingWaitTransactionState",
+ transactionId,
+ timeoutMs: durationMs,
+ },
+ `timed out waiting for transaction state (logId: ${logId})`,
+ ),
+ );
});
});
} else {
// No timeout => never resolve!
- timeoutPromise = new Promise(() => {});
+ timeoutPromise = new Promise<never>(() => {});
}
+ let result: TestingWaitTransactionStateResponse | undefined;
const waitPromise = genericWaitForState(wex, {
async checkState() {
const tx = await getTransactionById(wex, {
@@ -664,42 +705,36 @@ export async function waitTransactionState(
tx.txState,
)} (update logId: ${logId})`,
);
- if (req.requireError) {
- if (tx.error == null) {
- return false;
+ if (!req.requireError || tx.error != null) {
+ if (matchStateSpec(tx, txState)) {
+ result = { matched: "target", txState: tx.txState, stId: tx.stId };
+ return true;
}
}
- if (txState === "nonpending") {
- switch (tx.txState.major) {
- case TransactionMajorState.Pending:
- return false;
- default:
- return true;
- }
+ // Bail conditions are checked without requireError: they are
+ // about giving up, not about the state that was asked for.
+ if (req.bailStates != null && matchStateSpec(tx, req.bailStates)) {
+ result = { matched: "bail", txState: tx.txState, stId: tx.stId };
+ return true;
}
- if (Array.isArray(txState)) {
- for (const myState of txState) {
- if (matchState(tx.txState, myState)) {
- return true;
- }
- }
- return false;
- } else if (typeof txState === "number") {
- return tx.stId === txState;
- } else {
- return matchState(tx.txState, txState);
+ if (req.bailOnError && tx.error != null) {
+ result = { matched: "bail", txState: tx.txState, stId: tx.stId };
+ return true;
}
+ return false;
},
filterNotification: (notif) =>
notif.type === NotificationType.TransactionStateTransition &&
notif.transactionId === transactionId,
});
await Promise.race([timeoutPromise, waitPromise]);
+ checkLogicInvariant(result != null);
logger.info(
`done waiting for ${transactionId} to be in ${JSON.stringify(
txState,
- )} (done logId: ${logId})`,
+ )} (matched: ${result.matched}, done logId: ${logId})`,
);
+ return result;
}
export async function waitUntilTransactionWithAssociatedRefreshesFinal(
diff --git a/packages/taler-wallet-core/src/wallet-api-types.ts b/packages/taler-wallet-core/src/wallet-api-types.ts
@@ -198,6 +198,7 @@ import {
TestingWaitExchangeReadyRequest,
TestingWaitExchangeStateRequest,
TestingWaitTransactionRequest,
+ TestingWaitTransactionStateResponse,
TestingWaitWalletKycRequest,
Transaction,
TransactionByIdRequest,
@@ -1573,7 +1574,7 @@ export type TestingWaitBalanceOp = {
export type TestingWaitTransactionStateOp = {
op: WalletApiOperation.TestingWaitTransactionState;
request: TestingWaitTransactionRequest;
- response: EmptyObject;
+ response: TestingWaitTransactionStateResponse;
};
/**