taler-typescript-core

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

commit 93e8f3df9703dd003e2ba2573f3e64cf6ddd5f9e
parent a11ac4deb887b6d10041c90ce8e65ea61d723365
Author: Florian Dold <dold@taler.net>
Date:   Thu, 23 Jul 2026 01:49:13 +0200

wallet: use typed exchange client for refresh

Diffstat:
Mpackages/taler-util/src/http-client/exchange-client.ts | 64+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-----
Mpackages/taler-util/src/types-taler-exchange.ts | 10++++++++--
Mpackages/taler-wallet-core/src/refresh.ts | 161+++++++++++++++++++++++++++++++------------------------------------------------
3 files changed, 130 insertions(+), 105 deletions(-)

diff --git a/packages/taler-util/src/http-client/exchange-client.ts b/packages/taler-util/src/http-client/exchange-client.ts @@ -44,11 +44,13 @@ import { import { encodeCrock } from "../taler-crypto.js"; import { AccessToken, + CoinHistoryResponse, EddsaPublicKeyString, EddsaSignatureString, LongPollParams, OfficerSession, PaginationParams, + codecForCoinHistoryResponse, } from "../types-taler-common.js"; import { AccountKycStatus, @@ -67,7 +69,9 @@ import { ExchangePurseMergeRequest, ExchangePurseStatus, ExchangeRefreshRevealRequestV2, + ExchangeRefundRequest, ExchangeReservePurseRequest, + ExchangeRevealMeltResponseV2, ExchangeTransferList, ExchangeVersionResponse, ExchangeWithdrawRequest, @@ -101,6 +105,7 @@ import { codecForExchangeMergeConflictResponse, codecForExchangeMergeSuccessResponse, codecForExchangePurseStatus, + codecForExchangeRevealMeltResponseV2, codecForExchangeTransferList, codecForExchangeWithdrawResponse, codecForKycProcessClientInformation, @@ -1603,6 +1608,46 @@ export class TalerExchangeHttpClient { } } + /** + * https://docs.taler.net/core/api-exchange.html#post--coins-$COIN_PUB-refund + * + * Returns the raw HTTP status as the failure case (never throws on a + * documented non-2xx status), so the caller can decide how to react. + */ + async refundCoin( + coinPub: string, + body: ExchangeRefundRequest, + ): Promise<OperationOk<undefined> | OperationFail<HttpStatusCode>> { + const resp = await this.fetch(`coins/${coinPub}/refund`, { + method: "POST", + body, + }); + if (resp.status === HttpStatusCode.Ok) { + return opEmptySuccess(resp); + } + return opKnownHttpFailure(resp.status, resp); + } + + /** + * https://docs.taler.net/core/api-exchange.html#get--coins-$COIN_PUB-history + */ + async getCoinHistory( + coinPub: string, + signature: string, + ): Promise<OperationOk<CoinHistoryResponse>> { + const resp = await this.fetch(`coins/${coinPub}/history`, { + headers: { + "Taler-Coin-History-Signature": signature, + }, + }); + switch (resp.status) { + case HttpStatusCode.Ok: + return opSuccessFromHttp(resp, codecForCoinHistoryResponse()); + default: + return opUnknownHttpFailure(resp); + } + } + async withdraw(args: { body: ExchangeWithdrawRequest; }): Promise< @@ -1633,18 +1678,24 @@ export class TalerExchangeHttpClient { async postMelt(args: { body: ExchangeMeltRequestV2; }): Promise< - OperationOk<ExchangeMeltResponse> | OperationFail<HttpStatusCode.Forbidden> + | OperationOk<ExchangeMeltResponse> + | OperationFail<HttpStatusCode.Forbidden> + | OperationFail<HttpStatusCode.NotFound> + | OperationFail<HttpStatusCode.Gone> + | OperationFail<HttpStatusCode.Conflict> > { const url = new URL(`melt`, this.baseUrl); const resp = await this.fetch(url, { method: "POST", body: args.body, }); - // FIXME: Some documented cases are missing. switch (resp.status) { case HttpStatusCode.Ok: return opSuccessFromHttp(resp, codecForExchangeMeltResponse()); case HttpStatusCode.Forbidden: + case HttpStatusCode.NotFound: + case HttpStatusCode.Gone: + case HttpStatusCode.Conflict: return opKnownHttpFailure(resp.status, resp); default: return opUnknownHttpFailure(resp); @@ -1654,19 +1705,22 @@ export class TalerExchangeHttpClient { async postRevealMelt(args: { body: ExchangeRefreshRevealRequestV2; }): Promise< - | OperationOk<ExchangeWithdrawResponse> + | OperationOk<ExchangeRevealMeltResponseV2> | OperationFail<HttpStatusCode.Forbidden> + | OperationFail<HttpStatusCode.Gone> + | OperationFail<HttpStatusCode.Conflict> > { const url = new URL(`reveal-melt`, this.baseUrl); const resp = await this.fetch(url, { method: "POST", body: args.body, }); - // FIXME: Some documented cases are missing. switch (resp.status) { case HttpStatusCode.Ok: - return opSuccessFromHttp(resp, codecForExchangeWithdrawResponse()); + return opSuccessFromHttp(resp, codecForExchangeRevealMeltResponseV2()); case HttpStatusCode.Forbidden: + case HttpStatusCode.Gone: + case HttpStatusCode.Conflict: return opKnownHttpFailure(resp.status, resp); default: return opUnknownHttpFailure(resp); diff --git a/packages/taler-util/src/types-taler-exchange.ts b/packages/taler-util/src/types-taler-exchange.ts @@ -1168,9 +1168,15 @@ export const codecForExchangeWithdrawResponse = .property("ev_sigs", codecForList(codecForBlindedDenominationSignature())) .build("WithdrawResponse"); +/** + * Response to a POST /reveal-melt request. Structurally identical to + * {@link ExchangeWithdrawResponse} (an array of blinded signatures). + */ +export type ExchangeRevealMeltResponseV2 = ExchangeWithdrawResponse; + export const codecForExchangeRevealMeltResponseV2 = - (): Codec<ExchangeWithdrawResponse> => - buildCodecForObject<ExchangeWithdrawResponse>() + (): Codec<ExchangeRevealMeltResponseV2> => + buildCodecForObject<ExchangeRevealMeltResponseV2>() .property("ev_sigs", codecForList(codecForBlindedDenominationSignature())) .build("ExchangeRevealMeltResponseV2"); diff --git a/packages/taler-wallet-core/src/refresh.ts b/packages/taler-wallet-core/src/refresh.ts @@ -31,9 +31,6 @@ import { BlindedDenominationSignature, checkDbInvariant, checkLogicInvariant, - codecForCoinHistoryResponse, - codecForExchangeMeltResponse, - codecForExchangeRevealMeltResponseV2, CoinRefreshRequest, CoinStatus, DenominationInfo, @@ -67,16 +64,12 @@ import { TransactionMajorState, TransactionState, TransactionType, - URL, } from "@gnu-taler/taler-util"; import { HttpResponse, - readSuccessResponseJsonOrThrow, - readTalerErrorResponse, throwUnexpectedRequestError, } from "@gnu-taler/taler-util/http"; import { - cancelableFetch, constructTaskIdentifier, genericWaitForState, getGenericRecordHandle, @@ -118,6 +111,7 @@ import { EXCHANGE_COINS_LOCK, getDenomInfo, WalletExecutionContext, + walletExchangeClient, } from "./wallet.js"; import { getWithdrawableDenomsTx, @@ -714,7 +708,6 @@ async function refreshMelt( } } - const reqUrl = new URL(`melt`, oldCoin.exchangeBaseUrl); const meltReqBody: ExchangeMeltRequestV2 = { old_coin_pub: oldCoin.coinPub, old_denom_pub_h: oldCoin.denomPubHash, @@ -726,52 +719,44 @@ async function refreshMelt( denoms_h: newDenomsFlat, value_with_fee: Amounts.stringify(derived.meltValueWithFee), }; - const resp = await wex.ws.runSequentialized( - [EXCHANGE_COINS_LOCK], - async () => { - return await cancelableFetch(wex, reqUrl, { - method: "POST", - body: meltReqBody, - timeout: getRefreshRequestTimeout(refreshGroup), - }); - }, + const exchangeClient = walletExchangeClient( + oldCoin.exchangeBaseUrl, + wex, + getRefreshRequestTimeout(refreshGroup), + ); + const resp = await wex.ws.runSequentialized([EXCHANGE_COINS_LOCK], async () => + exchangeClient.postMelt({ body: meltReqBody }), ); - switch (resp.status) { - case HttpStatusCode.NotFound: { - const errDetail = await readTalerErrorResponse(resp); - await handleRefreshMeltNotFound(ctx, coinIndex, resp, errDetail); + switch (resp.case) { + case HttpStatusCode.NotFound: + await handleRefreshMeltNotFound( + ctx, + coinIndex, + resp.response, + resp.detail!, + ); return; - } - case HttpStatusCode.Gone: { - const errDetail = await readTalerErrorResponse(resp); - await handleRefreshMeltGone(ctx, coinIndex, errDetail); + case HttpStatusCode.Gone: + await handleRefreshMeltGone(ctx, coinIndex, resp.detail!); return; - } - case HttpStatusCode.Conflict: { - const errDetail = await readTalerErrorResponse(resp); + case HttpStatusCode.Conflict: await handleRefreshMeltConflict( ctx, refreshGroup, coinIndex, - errDetail, + resp.detail!, derived.meltValueWithFee, oldCoin, ); return; - } - case HttpStatusCode.Ok: + case "ok": break; - default: { - const errDetail = await readTalerErrorResponse(resp); - throwUnexpectedRequestError(resp, errDetail); - } + default: + throwUnexpectedRequestError(resp.response, resp.detail!); } - const meltResponse = await readSuccessResponseJsonOrThrow( - resp, - codecForExchangeMeltResponse(), - ); + const meltResponse = resp.body; // FIXME: Check exchange's signature. @@ -861,34 +846,29 @@ async function handleRefreshMeltConflict( const refundReq = refreshGroup.refundRequests[coinIndex]; if (refundReq != null) { - const refundUrl = new URL( - `coins/${oldCoin.coinPub}/refund`, + logger.trace(`Doing deposit in refresh for coin ${coinIndex}`); + const exchangeClient = walletExchangeClient( oldCoin.exchangeBaseUrl, + ctx.wex, ); - logger.trace(`Doing deposit in refresh for coin ${coinIndex}`); - const httpResp = await cancelableFetch(ctx.wex, refundUrl, { - method: "POST", - body: refundReq, - }); - switch (httpResp.status) { - case HttpStatusCode.Ok: - await ctx.wex.runWalletDbTx(async (tx) => { - const rg = await tx.getRefreshGroup(refreshGroup.refreshGroupId); - if (!rg || rg.operationStatus != RefreshOperationStatus.Pending) { - return; - } - delete rg.refundRequests[coinIndex]; - await tx.upsertRefreshGroup(rg); - }); - break; - default: - // FIXME: Store the error somewhere in the DB? - logger.warn( - `Refund request during refresh failed: ${j2s( - await readTalerErrorResponse(httpResp), - )}`, - ); - break; + const refundResp = await exchangeClient.refundCoin( + oldCoin.coinPub, + refundReq, + ); + if (refundResp.case === "ok") { + await ctx.wex.runWalletDbTx(async (tx) => { + const rg = await tx.getRefreshGroup(refreshGroup.refreshGroupId); + if (!rg || rg.operationStatus != RefreshOperationStatus.Pending) { + return; + } + delete rg.refundRequests[coinIndex]; + await tx.upsertRefreshGroup(rg); + }); + } else { + // FIXME: Store the error somewhere in the DB? + logger.warn( + `Refund request during refresh failed: ${j2s(refundResp.detail)}`, + ); } return; } @@ -899,21 +879,13 @@ async function handleRefreshMeltConflict( startOffset: 0, }); - const historyUrl = new URL( - `coins/${oldCoin.coinPub}/history`, - oldCoin.exchangeBaseUrl, + const exchangeClient = walletExchangeClient(oldCoin.exchangeBaseUrl, ctx.wex); + const historyResp = await exchangeClient.getCoinHistory( + oldCoin.coinPub, + historySig.sig, ); - const historyResp = await cancelableFetch(ctx.wex, historyUrl, { - headers: { - "Taler-Coin-History-Signature": historySig.sig, - }, - }); - - const historyJson = await readSuccessResponseJsonOrThrow( - historyResp, - codecForCoinHistoryResponse(), - ); + const historyJson = historyResp.body; logger.info(`coin history: ${j2s(historyJson)}`); // FIXME: If response seems wrong, report to auditor (in the future!); @@ -1101,34 +1073,27 @@ async function refreshReveal( signatures: derived.signatures.filter((v, i) => i != norevealIndex), age_commitment: oldCoin.ageCommitmentProof?.commitment?.publicKeys, }; - const reqUrl = new URL(`reveal-melt`, oldCoin.exchangeBaseUrl); + const exchangeClient = walletExchangeClient( + oldCoin.exchangeBaseUrl, + wex, + getRefreshRequestTimeout(refreshGroup), + ); const resp = await wex.ws.runSequentialized([EXCHANGE_COINS_LOCK], async () => - cancelableFetch(wex, reqUrl, { - body: req, - method: "POST", - timeout: getRefreshRequestTimeout(refreshGroup), - }), + exchangeClient.postRevealMelt({ body: req }), ); - switch (resp.status) { - case HttpStatusCode.Ok: + switch (resp.case) { + case "ok": break; case HttpStatusCode.Conflict: - case HttpStatusCode.Gone: { - const errDetail = await readTalerErrorResponse(resp); - await handleRefreshRevealError(ctx, coinIndex, errDetail); + case HttpStatusCode.Gone: + await handleRefreshRevealError(ctx, coinIndex, resp.detail!); return; - } - default: { - const errDetail = await readTalerErrorResponse(resp); - throwUnexpectedRequestError(resp, errDetail); - } + default: + throwUnexpectedRequestError(resp.response, resp.detail!); } - const reveal = await readSuccessResponseJsonOrThrow( - resp, - codecForExchangeRevealMeltResponseV2(), - ); + const reveal = resp.body; resEvSigs = reveal.ev_sigs; planchets = derived.planchets;