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