taler-typescript-core

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

commit 6a1ebc106552be354906630c916a4639d05435a8
parent 94e39e6b62c1ccf6f97dea8b7d359b229e6a03e1
Author: Florian Dold <dold@taler.net>
Date:   Thu, 23 Jul 2026 03:14:52 +0200

wallet: use typed exchange client for /keys and /terms

Diffstat:
Mpackages/taler-util/src/http-client/exchange-client.ts | 40++++++++++++++++++++++++++++++++++++++--
Mpackages/taler-wallet-core/src/exchanges.ts | 103++++++++++++-------------------------------------------------------------------
2 files changed, 53 insertions(+), 90 deletions(-)

diff --git a/packages/taler-util/src/http-client/exchange-client.ts b/packages/taler-util/src/http-client/exchange-client.ts @@ -286,8 +286,14 @@ export class TalerExchangeHttpClient { * * PARTIALLY IMPLEMENTED!! */ - async getKeys(): Promise<OperationOk<ExchangeKeysResponse>> { - const resp = await this.fetch("keys"); + async getKeys( + opts: { noCache?: boolean } = {}, + ): Promise<OperationOk<ExchangeKeysResponse>> { + const headers: Record<string, string> = {}; + if (opts.noCache) { + headers["cache-control"] = "no-cache"; + } + const resp = await this.fetch("keys", { headers }); switch (resp.status) { case HttpStatusCode.Ok: return opSuccessFromHttp(resp, codecForExchangeKeysResponse()); @@ -1575,6 +1581,36 @@ export class TalerExchangeHttpClient { * https://docs.taler.net/core/api-exchange.html#withdrawal */ /** + * Metadata about the exchange's terms of service: the current + * `taler-terms-version` etag. (A GET is used since not every HTTP backend + * supports HEAD.) + * + * https://docs.taler.net/core/api-exchange.html#terms-of-service + */ + async getTermsMeta(): Promise< + | OperationOk<{ etag: string }> + | OperationFail<HttpStatusCode.NotFound> + | OperationFail<HttpStatusCode.NotImplemented> + > { + const resp = await this.fetch("terms"); + switch (resp.status) { + case HttpStatusCode.Ok: { + const etag = resp.headers.get("taler-terms-version") || "unknown"; + return opFixedSuccess(resp, { etag }); + } + // The body of these responses is not necessarily a Taler error JSON + // (e.g. the exchange returns 501 "not configured" as plain text), so we + // must not try to parse it. + case HttpStatusCode.NotFound: + return opKnownFailure(resp, HttpStatusCode.NotFound); + case HttpStatusCode.NotImplemented: + return opKnownFailure(resp, HttpStatusCode.NotImplemented); + default: + return opUnknownHttpFailure(resp); + } + } + + /** * https://docs.taler.net/core/api-exchange.html#get--reserves-$RESERVE_PUB */ async getReserveStatus( diff --git a/packages/taler-wallet-core/src/exchanges.ts b/packages/taler-wallet-core/src/exchanges.ts @@ -98,7 +98,6 @@ import { assertUnreachable, checkDbInvariant, checkLogicInvariant, - codecForExchangeKeysResponse, durationMul, encodeCrock, getRandomBytes, @@ -111,9 +110,6 @@ import { } from "@gnu-taler/taler-util"; import { HttpRequestLibrary, - expectSuccessResponseOrThrow, - readSuccessResponseJsonOrThrow, - readTalerErrorResponse, throwUnexpectedRequestError, } from "@gnu-taler/taler-util/http"; import { @@ -122,7 +118,6 @@ import { TaskIdentifiers, TaskRunResult, TransactionContext, - cancelableFetch, constructTaskIdentifier, genericWaitForState, getAutoRefreshExecuteThreshold, @@ -847,85 +842,26 @@ async function downloadExchangeKeysInfo( cancellationToken: CancellationToken, noCache: boolean, ): Promise<ExchangeKeysDownloadResult> { - const keysUrl = new URL("keys", baseUrl); - - const headers: Record<string, string> = {}; - if (noCache) { - headers["cache-control"] = "no-cache"; - } - const resp = await http.fetch(keysUrl.href, { + const exchangeClient = new TalerExchangeHttpClient(baseUrl, { + httpClient: http, + cancelationToken: cancellationToken, timeout, - cancellationToken, - headers, }); + const resp = await exchangeClient.getKeys({ noCache }); logger.trace("got response to /keys request"); - await expectSuccessResponseOrThrow(resp); - - // We must make sure to parse out the protocol version - // before we validate the body. - // Otherwise the parser might complain with a hard to understand - // message about some other field, when it is just a version - // incompatibility. - - let keysJson; - - try { - keysJson = await resp.json(); - } catch (e) { - throw TalerError.fromDetail( - TalerErrorCode.WALLET_RECEIVED_MALFORMED_RESPONSE, - { - requestUrl: resp.requestUrl, - requestMethod: resp.requestMethod, - httpStatusCode: resp.status, - validationError: e instanceof Error ? e.message : String(e), - }, - "Couldn't parse JSON format from response", - ); - } - const protocolVersion = keysJson.version; - if (typeof protocolVersion !== "string") { - throw TalerError.fromDetail( - TalerErrorCode.WALLET_RECEIVED_MALFORMED_RESPONSE, - { - requestUrl: resp.requestUrl, - requestMethod: resp.requestMethod, - httpStatusCode: resp.status, - validationError: "version field missing", - }, - "bad exchange, does not even specify protocol version", - ); - } + const exchangeKeysResponseParsed = resp.body; - const versionRes = LibtoolVersion.compare( - WALLET_EXCHANGE_PROTOCOL_VERSION, - protocolVersion, - ); - if (!versionRes) { - throw TalerError.fromDetail( - TalerErrorCode.WALLET_RECEIVED_MALFORMED_RESPONSE, - { - requestUrl: resp.requestUrl, - httpStatusCode: resp.status, - requestMethod: resp.requestMethod, - }, - "exchange protocol version malformed", - ); - } - if (!versionRes.compatible) { + if ( + !TalerExchangeHttpClient.isCompatible(exchangeKeysResponseParsed.version) + ) { return { type: "version-incompatible", - exchangeProtocolVersion: protocolVersion, + exchangeProtocolVersion: exchangeKeysResponseParsed.version, }; } - const exchangeKeysResponseParsed = await readSuccessResponseJsonOrThrow( - resp, - codecForExchangeKeysResponse(), - ); - if (exchangeKeysResponseParsed.denominations.length === 0) { throw TalerError.fromDetail( TalerErrorCode.WALLET_EXCHANGE_DENOMINATIONS_INSUFFICIENT, @@ -952,27 +888,18 @@ async function downloadTosMeta( exchangeBaseUrl: string, ): Promise<TosMetaResult> { logger.trace(`downloading exchange tos metadata for ${exchangeBaseUrl}`); - const reqUrl = new URL("terms", exchangeBaseUrl); - - // FIXME: We can/should make a HEAD request here. - // Not sure if qtart supports it at the moment. - const resp = await cancelableFetch(wex, reqUrl); + const exchangeClient = walletExchangeClient(exchangeBaseUrl, wex); + const resp = await exchangeClient.getTermsMeta(); - switch (resp.status) { + switch (resp.case) { + case "ok": + return { type: "ok", etag: resp.body.etag }; case HttpStatusCode.NotFound: case HttpStatusCode.NotImplemented: return { type: "not-found" }; - case HttpStatusCode.Ok: - break; default: - throwUnexpectedRequestError(resp, await readTalerErrorResponse(resp)); + return assertUnreachable(resp); } - - const etag = resp.headers.get("taler-terms-version") || "unknown"; - return { - type: "ok", - etag, - }; } /**