taler-typescript-core

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

commit 2e23b6a46db646baa332065ce25344bfec1e1eab
parent cc452bb4cc64188ffcd20a80364efb4d55f9915b
Author: Florian Dold <dold@taler.net>
Date:   Sat, 22 Aug 2026 20:16:22 +0200

wallet-core: defer deposit tracking long polls until deadline

Issue: https://bugs.taler.net/n/10690

Diffstat:
Mpackages/taler-harness/src/integrationtests/test-deposit-merge.ts | 42+++++++++++++++---------------------------
Mpackages/taler-util/src/http-client/exchange-client.ts | 12++++++++++--
Apackages/taler-util/src/http-client/exchange-track-deposit.test.ts | 76++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mpackages/taler-wallet-core/src/deposits.test.ts | 42++++++++++++++++++++++++++++++++++++++++++
Mpackages/taler-wallet-core/src/deposits.ts | 40++++++++++++++++++++++++++++++++++++++++
5 files changed, 183 insertions(+), 29 deletions(-)

diff --git a/packages/taler-harness/src/integrationtests/test-deposit-merge.ts b/packages/taler-harness/src/integrationtests/test-deposit-merge.ts @@ -41,6 +41,17 @@ export async function runDepositMergeTest(t: GlobalTestState) { const { walletClient, bankClient, exchange } = await createSimpleTestkudosEnvironmentV3(t); + async function setTimeOffset(minutes: number): Promise<void> { + const offsetMs = Duration.toMilliseconds(Duration.fromSpec({ minutes })); + await exchange.stop(); + exchange.setTimetravel(offsetMs); + await exchange.start(); + await exchange.pingUntilAvailable(); + await walletClient.call(WalletApiOperation.TestingSetTimetravel, { + offsetMs, + }); + } + // Withdraw digital cash into the wallet. const withdrawalResult = await withdrawViaBankV3(t, { @@ -86,11 +97,7 @@ export async function runDepositMergeTest(t: GlobalTestState) { }, }); - await exchange.stop(); - // @ts-ignore duration is not forever - exchange.setTimetravel(Duration.fromSpec({ minutes: 1 }).d_ms); - await exchange.start(); - await exchange.pingUntilAvailable(); + await setTimeOffset(1); // total time: 1 minute t.logStep("done with first timetravel"); @@ -126,11 +133,7 @@ export async function runDepositMergeTest(t: GlobalTestState) { }); t.logStep("done with second deposit (track)"); - await exchange.stop(); - // @ts-ignore duration is not forever - exchange.setTimetravel(Duration.fromSpec({ minutes: 2 }).d_ms); - await exchange.start(); - await exchange.pingUntilAvailable(); + await setTimeOffset(2); // total time: 2 minute /** @@ -159,18 +162,9 @@ export async function runDepositMergeTest(t: GlobalTestState) { minor: TransactionMinorState.Track, }, }); - await exchange.stop(); - // @ts-ignore duration is not forever - exchange.setTimetravel(Duration.fromSpec({ minutes: 3 }).d_ms); - await exchange.start(); - await exchange.pingUntilAvailable(); + await setTimeOffset(3); // total time: 3 minute - // Tracking is a finalization phase, not a failed operation on which the - // user-facing Retry action is valid. Wake all scheduled tracking tasks - // after restarting the exchange. - await walletClient.call(WalletApiOperation.TestingResetAllRetries, {}); - /** * check deposit tx after 3 minute, all pending */ @@ -201,15 +195,9 @@ export async function runDepositMergeTest(t: GlobalTestState) { /////////////////////////////////////////// - await exchange.stop(); - // @ts-ignore duration is not forever - exchange.setTimetravel(Duration.fromSpec({ minutes: 6 }).d_ms); - await exchange.start(); - await exchange.pingUntilAvailable(); + await setTimeOffset(6); // total time: 6 minute - await walletClient.call(WalletApiOperation.TestingResetAllRetries, {}); - await walletClient.call(WalletApiOperation.TestingWaitTransactionState, { transactionId: d1Id, txState: { diff --git a/packages/taler-util/src/http-client/exchange-client.ts b/packages/taler-util/src/http-client/exchange-client.ts @@ -1877,6 +1877,11 @@ export class TalerExchangeHttpClient { contractTermsHash: string; coinPub: string; merchantSig: string; + /** + * Wait for the deposit status to change instead of returning the current + * status immediately. Defaults to true for backwards compatibility. + */ + longpoll?: boolean; }): Promise< | OperationOk<TrackTransactionWired> | OperationAlternative<HttpStatusCode.Accepted, TrackTransactionAccepted> @@ -1886,8 +1891,11 @@ export class TalerExchangeHttpClient { this.baseUrl, ); url.searchParams.set("merchant_sig", args.merchantSig); - url.searchParams.set("lpt", "1"); - const resp = await this.fetch(url, {}, true); + const longpoll = args.longpoll ?? true; + if (longpoll) { + url.searchParams.set("lpt", "1"); + } + const resp = await this.fetch(url, {}, longpoll); switch (resp.status) { case HttpStatusCode.Ok: return opSuccessFromHttp(resp, codecForTackTransactionWired()); diff --git a/packages/taler-util/src/http-client/exchange-track-deposit.test.ts b/packages/taler-util/src/http-client/exchange-track-deposit.test.ts @@ -0,0 +1,76 @@ +/* + 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"; +import { test } from "node:test"; +import { FakeHttpLib } from "../http-fake.js"; +import { HttpStatusCode } from "../http-status-codes.js"; +import { TalerProtocolTimestamp } from "../time.js"; +import { TalerExchangeHttpClient } from "./exchange-client.js"; + +const accepted = { + status: HttpStatusCode.Accepted, + body: { + kyc_ok: true, + execution_time: TalerProtocolTimestamp.fromSeconds(1_760_000_000), + }, +}; + +const request = { + wireHash: "wire-hash", + merchantPub: "merchant-pub", + contractTermsHash: "contract-terms-hash", + coinPub: "coin-pub", + merchantSig: "merchant-signature", +}; + +test("trackDeposit can return the current status without long polling", async () => { + const http = new FakeHttpLib().on( + "GET", + "/deposits/wire-hash/merchant-pub/contract-terms-hash/coin-pub", + accepted, + ); + const client = new TalerExchangeHttpClient("https://exchange.example/", { + httpClient: http, + }); + + await client.trackDeposit({ ...request, longpoll: false }); + + const url = new URL(http.lastRequest!.url); + assert.strictEqual( + url.searchParams.get("merchant_sig"), + "merchant-signature", + ); + assert.strictEqual(url.searchParams.has("lpt"), false); + assert.strictEqual(url.searchParams.has("timeout_ms"), false); +}); + +test("trackDeposit long polls by default", async () => { + const http = new FakeHttpLib().on( + "GET", + "/deposits/wire-hash/merchant-pub/contract-terms-hash/coin-pub", + accepted, + ); + const client = new TalerExchangeHttpClient("https://exchange.example/", { + httpClient: http, + }); + + await client.trackDeposit(request); + + const url = new URL(http.lastRequest!.url); + assert.strictEqual(url.searchParams.get("lpt"), "1"); + assert.strictEqual(url.searchParams.has("timeout_ms"), true); +}); diff --git a/packages/taler-wallet-core/src/deposits.test.ts b/packages/taler-wallet-core/src/deposits.test.ts @@ -15,8 +15,10 @@ */ import { + AbsoluteTime, HttpStatusCode, TalerErrorCode, + TalerProtocolTimestamp, TransactionMajorState, TransactionMinorState, } from "@gnu-taler/taler-util"; @@ -35,8 +37,48 @@ import { computeDepositTransactionStatus, depositRefundStatusIsRetryable, reconstructDepositRefundRequests, + testing_getDepositTrackingTiming, } from "./deposits.js"; +test("deposit tracking waits at a future wire deadline", () => { + const timing = testing_getDepositTrackingTiming( + TalerProtocolTimestamp.fromSeconds(3), + AbsoluteTime.fromMilliseconds(2_000), + ); + + assert.strictEqual(timing.longpoll, false); + assert.strictEqual(timing.runAgainAt?.t_ms, 3_000); +}); + +test("deposit tracking long polls at or after the wire deadline", () => { + const deadline = TalerProtocolTimestamp.fromSeconds(2); + + assert.deepStrictEqual( + testing_getDepositTrackingTiming( + deadline, + AbsoluteTime.fromMilliseconds(2_000), + ), + { longpoll: true }, + ); + assert.deepStrictEqual( + testing_getDepositTrackingTiming( + deadline, + AbsoluteTime.fromMilliseconds(3_000), + ), + { longpoll: true }, + ); +}); + +test("deposit tracking preserves immediate polling without contract terms", () => { + assert.deepStrictEqual( + testing_getDepositTrackingTiming( + undefined, + AbsoluteTime.fromMilliseconds(2_000), + ), + { longpoll: true }, + ); +}); + test("deposit abort retries transient refund responses", () => { assert.strictEqual( depositRefundStatusIsRetryable(HttpStatusCode.RequestTimeout), diff --git a/packages/taler-wallet-core/src/deposits.ts b/packages/taler-wallet-core/src/deposits.ts @@ -1912,6 +1912,25 @@ interface DepositTrackingProgressUpdate { wiredCoin?: { id: string; value: WalletDepositTrackingInfo }; } +interface DepositTrackingTiming { + longpoll: boolean; + runAgainAt?: AbsoluteTime; +} + +function getDepositTrackingTiming( + wireDeadline: TalerProtocolTimestamp | undefined, + now: AbsoluteTime, +): DepositTrackingTiming { + if (!wireDeadline) { + return { longpoll: true }; + } + const deadline = AbsoluteTime.fromProtocolTimestamp(wireDeadline); + if (AbsoluteTime.cmp(now, deadline) < 0) { + return { longpoll: false, runAgainAt: deadline }; + } + return { longpoll: true }; +} + function applyDepositTrackingProgress( dg: WalletDepositGroup, progress: DepositTrackingProgressUpdate[], @@ -1936,6 +1955,7 @@ function applyDepositTrackingProgress( export const testing_applyDepositTrackingProgress = applyDepositTrackingProgress; export const testing_applyKycRequiredTransition = applyKycRequiredTransition; +export const testing_getDepositTrackingTiming = getDepositTrackingTiming; async function processDepositGroupTrack( wex: WalletExecutionContext, @@ -1973,11 +1993,26 @@ async function processDepositGroupTrack( ); return { coinsByPub, + contractTerms: await tx.getContractTerms(depositGroup.contractTermsHash), exchangeDetailsByUrl: new Map( exchangeBaseUrls.map((baseUrl, i) => [baseUrl, details[i]]), ), }; }); + let wireDeadline: TalerProtocolTimestamp | undefined; + if (trackingInputs.contractTerms) { + wireDeadline = codecForMerchantContractTerms().decode( + trackingInputs.contractTerms.contractTermsRaw, + ).wire_transfer_deadline; + } else { + logger.warn( + `contract terms for deposit group ${depositGroupId} are missing, tracking immediately`, + ); + } + const trackingTiming = getDepositTrackingTiming( + wireDeadline, + AbsoluteTime.now(), + ); let wireType: string | undefined; const getWireType = (): string => { if (wireType !== undefined) { @@ -2037,6 +2072,7 @@ async function processDepositGroupTrack( depositGroup, coinPub, exchangeBaseUrl, + trackingTiming.longpoll, ); logger.trace(`track response: ${j2s(track)}`); if (track.type === "accepted") { @@ -2102,6 +2138,8 @@ async function processDepositGroupTrack( const allWired = await flushProgress(); if (allWired) { return TaskRunResult.finished(); + } else if (trackingTiming.runAgainAt) { + return TaskRunResult.runAgainAt(trackingTiming.runAgainAt); } else { return TaskRunResult.longpollReturnedPending(); } @@ -2504,6 +2542,7 @@ async function trackDeposit( depositGroup: WalletDepositGroup, coinPub: string, exchangeUrl: string, + longpoll: boolean, ): Promise<TrackTransaction> { const wireHash = hashWire( depositGroup.wire.payto_uri, @@ -2525,6 +2564,7 @@ async function trackDeposit( contractTermsHash: depositGroup.contractTermsHash, coinPub, merchantSig: sigResp.sig, + longpoll, }); logger.trace(`deposits response status: ${trackResp.response.status}`); switch (trackResp.case) {