commit c3b21df23776c687e9e12d1b46dd94f00383553e
parent b97d40494ebfb80a6bb459277fc00403ee6866a3
Author: Florian Dold <dold@taler.net>
Date: Thu, 6 Aug 2026 18:26:32 +0200
util: let the exchange /keys client cherry-pick
The exchange only leaves denominations out when last_issue_date names one it
still offers, and answers with the full list otherwise, so a stale value costs
bandwidth but never correctness.
Issue: https://bugs.taler.net/n/11715
Diffstat:
2 files changed, 103 insertions(+), 2 deletions(-)
diff --git a/packages/taler-util/src/http-client/exchange-client.ts b/packages/taler-util/src/http-client/exchange-client.ts
@@ -303,13 +303,22 @@ export class TalerExchangeHttpClient {
* PARTIALLY IMPLEMENTED!!
*/
async getKeys(
- opts: { noCache?: boolean } = {},
+ opts: { noCache?: boolean; lastIssueDate?: number } = {},
): Promise<OperationOk<ExchangeKeysResponse> | OperationFail<HttpStatusCode.NotFound>> {
const headers: Record<string, string> = {};
if (opts.noCache) {
headers["cache-control"] = "no-cache";
}
- const resp = await this.fetch("keys", { headers });
+ const url = new URL("keys", this.baseUrl);
+ if (opts.lastIssueDate != null) {
+ // Cherry-picking: the exchange leaves out every denomination that
+ // started before this timestamp. It only does so when the value is
+ // exactly the stamp_start of a denomination it still offers, and
+ // answers with the full list otherwise, so a stale value costs
+ // bandwidth but never correctness.
+ url.searchParams.set("last_issue_date", String(opts.lastIssueDate));
+ }
+ const resp = await this.fetch(url, { headers });
switch (resp.status) {
case HttpStatusCode.Ok:
return opSuccessFromHttp(resp, codecForExchangeKeysResponse());
diff --git a/packages/taler-util/src/http-client/exchange-keys.test.ts b/packages/taler-util/src/http-client/exchange-keys.test.ts
@@ -0,0 +1,92 @@
+/*
+ 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 {
+ HeadersImpl,
+ HttpRequestLibrary,
+ HttpRequestOptions,
+ HttpResponse,
+} from "../http-common.js";
+import { TalerExchangeHttpClient } from "./exchange-client.js";
+
+/**
+ * Records the URL and headers of the last request and answers with an empty
+ * 204, which is enough to get back out of the client.
+ */
+class RecordingHttpLib implements HttpRequestLibrary {
+ lastUrl: string | undefined;
+ lastHeaders: { [name: string]: string | undefined } | undefined;
+ async fetch(url: string, opt?: HttpRequestOptions): Promise<HttpResponse> {
+ this.lastUrl = url;
+ this.lastHeaders = opt?.headers;
+ return {
+ requestUrl: url,
+ requestMethod: opt?.method ?? "GET",
+ status: 204,
+ headers: new HeadersImpl(),
+ async json() {
+ return {};
+ },
+ async text() {
+ return "";
+ },
+ async bytes() {
+ return new Uint8Array();
+ },
+ };
+ }
+}
+
+test("getKeys asks for the whole response by default", async (t) => {
+ const lib = new RecordingHttpLib();
+ const client = new TalerExchangeHttpClient("https://exchange.example.com/", {
+ httpClient: lib,
+ });
+ // The 204 is not a valid /keys response; only the request matters here.
+ await client.getKeys().catch(() => undefined);
+ assert.strictEqual(lib.lastUrl, "https://exchange.example.com/keys");
+});
+
+test("getKeys passes the cherry-picking date as last_issue_date", async (t) => {
+ const lib = new RecordingHttpLib();
+ const client = new TalerExchangeHttpClient("https://exchange.example.com/", {
+ httpClient: lib,
+ });
+ await client.getKeys({ lastIssueDate: 1750000000 }).catch(() => undefined);
+ // Seconds since the epoch, as an unsigned integer -- not an ISO string and
+ // not milliseconds, either of which the exchange answers with a 400.
+ assert.strictEqual(
+ lib.lastUrl,
+ "https://exchange.example.com/keys?last_issue_date=1750000000",
+ );
+});
+
+test("getKeys combines cherry-picking with a cache break", async (t) => {
+ const lib = new RecordingHttpLib();
+ const client = new TalerExchangeHttpClient("https://exchange.example.com/", {
+ httpClient: lib,
+ });
+ await client
+ .getKeys({ lastIssueDate: 1750000000, noCache: true })
+ .catch(() => undefined);
+ assert.strictEqual(
+ lib.lastUrl,
+ "https://exchange.example.com/keys?last_issue_date=1750000000",
+ );
+ assert.strictEqual(lib.lastHeaders?.["cache-control"], "no-cache");
+});