taler-typescript-core

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

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:
Mpackages/taler-harness/src/integrationtests/test-wallet-progress-token.ts | 137+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mpackages/taler-util/src/types-taler-wallet.test.ts | 12++++++++++++
Mpackages/taler-util/src/types-taler-wallet.ts | 37+++++++++++++++++++++++++++++++------
Mpackages/taler-wallet-core/src/requests.ts | 41++++++++++++++++++++++++++++++++++++++---
Apackages/taler-wallet-core/src/testing.test.ts | 582++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mpackages/taler-wallet-core/src/testing.ts | 62++++++++++++++++++++++++++++++++++++++++++++++++++++++--------
Mpackages/taler-wallet-core/src/transactions.ts | 2+-
Mpackages/taler-wallet-core/src/wallet-api-types.ts | 12++++++++++++
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;