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:
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;