commit 5a3990cbfdf059eafd294bed501e6d87a6724b69
parent 2407efa2e23a38d57842c61a1004a005b84b2b17
Author: Florian Dold <florian@dold.me>
Date: Thu, 16 Apr 2026 17:53:57 +0200
wallet-core: unify default/preset exchanges
Diffstat:
6 files changed, 229 insertions(+), 134 deletions(-)
diff --git a/packages/taler-util/src/codec.ts b/packages/taler-util/src/codec.ts
@@ -421,6 +421,28 @@ export function codecForString(): Codec<string> {
};
}
+export function codecForStringUnion<T extends Array<string>>(
+ ...vals: [...T]
+): Codec<T[number]> {
+ return {
+ decode(x: any, c?: Context): T[number] {
+ if (typeof x !== "string") {
+ throw new DecodingError(
+ `expected string at ${renderContext(c)} but got ${typeof x}`,
+ );
+ }
+ if (!vals.includes(x)) {
+ throw new DecodingError(
+ `expected constant of ${JSON.stringify(vals)} at ${renderContext(
+ c,
+ )} but got ${x}`,
+ );
+ }
+ return x;
+ },
+ };
+}
+
/**
* Return a codec for a value that must be a string.
*/
@@ -629,9 +651,7 @@ export function codecForEither<T extends Array<Codec<unknown>>>(
if (logger.shouldLogTrace()) {
logger.trace(`offending value: ${j2s(x)}`);
}
- throw new DecodingError(
- `No alternative matched at ${renderContext(c)}`,
- );
+ throw new DecodingError(`No alternative matched at ${renderContext(c)}`);
},
};
}
diff --git a/packages/taler-util/src/types-taler-wallet.ts b/packages/taler-util/src/types-taler-wallet.ts
@@ -44,6 +44,7 @@ import {
codecForMap,
codecForNumber,
codecForString,
+ codecForStringUnion,
codecOptional,
renderContext,
} from "./codec.js";
@@ -61,7 +62,6 @@ import {
TalerProtocolDuration,
TalerProtocolTimestamp,
codecForAbsoluteTime,
- codecForPreciseTimestamp,
codecForTimestamp,
} from "./time.js";
import { BlindedDonationReceiptKeyPair } from "./types-donau.js";
@@ -1379,9 +1379,23 @@ export interface ListExchangesRequest {
*/
filterByScope?: ScopeInfo;
+ /**
+ * Filter results to only include exchanges
+ * with the given status.
+ */
filterByExchangeEntryStatus?: ExchangeEntryStatus;
+
+ /**
+ * Filter results to only include exchanges with the
+ * given type.
+ */
+ filterByType?: ExchangeType;
}
+export type ExchangeType = "demo" | "prod";
+
+export const codecForExchangeType = () => codecForStringUnion("demo", "prod");
+
export const codecForListExchangesRequest = (): Codec<ListExchangesRequest> =>
buildCodecForObject<ListExchangesRequest>()
.property("filterByScope", codecOptional(codecForScopeInfo()))
@@ -1389,6 +1403,7 @@ export const codecForListExchangesRequest = (): Codec<ListExchangesRequest> =>
"filterByExchangeEntryStatus",
codecOptional(codecForExchangeEntryStatus()),
)
+ .property("filterByType", codecOptional(codecForExchangeType()))
.build("ListExchangesRequest");
export interface ExchangeDetailedResponse {
@@ -1818,6 +1833,12 @@ export interface ExchangeListItem {
* to update the exchange info.
*/
lastUpdateErrorInfo?: OperationErrorInfo;
+
+ /**
+ * Currency spec for the currency offered
+ * by the exchange.
+ */
+ currencySpec: CurrencySpecification;
}
export interface ContactEntry {
@@ -1922,30 +1943,6 @@ export const codecForExchangeFullDetails = (): Codec<ExchangeFullDetails> =>
.property("globalFees", codecForList(codecForFeeDescription()))
.build("ExchangeFullDetails");
-export const codecForExchangeListItem = (): Codec<ExchangeListItem> =>
- buildCodecForObject<ExchangeListItem>()
- .property("currency", codecForString())
- .property("exchangeBaseUrl", codecForCanonBaseUrl())
- .property("masterPub", codecOptional(codecForString()))
- .property("paytoUris", codecForList(codecForString()))
- .property("tosStatus", codecForAny())
- .property("exchangeEntryStatus", codecForAny())
- .property("exchangeUpdateStatus", codecForAny())
- .property("ageRestrictionOptions", codecForList(codecForNumber()))
- .property("scopeInfo", codecForScopeInfo())
- .property("lastUpdateErrorInfo", codecForAny())
- .property("lastUpdateTimestamp", codecOptional(codecForPreciseTimestamp))
- .property("noFees", codecForBoolean())
- .property("peerPaymentsDisabled", codecForBoolean())
- .property("bankComplianceLanguage", codecOptional(codecForString()))
- .property("directDepositsDisabled", codecForBoolean())
- .build("ExchangeListItem");
-
-export const codecForExchangesListResponse = (): Codec<ExchangesListResponse> =>
- buildCodecForObject<ExchangesListResponse>()
- .property("exchanges", codecForList(codecForExchangeListItem()))
- .build("ExchangesListResponse");
-
export interface AcceptManualWithdrawalResult {
/**
* Payto URIs that can be used to fund the withdrawal.
@@ -3233,30 +3230,6 @@ export interface WithdrawUriInfoResponse {
possibleExchanges: ExchangeListItem[];
}
-export const codecForWithdrawUriInfoResponse =
- (): Codec<WithdrawUriInfoResponse> =>
- buildCodecForObject<WithdrawUriInfoResponse>()
- .property("operationId", codecForString())
- .property("confirmTransferUrl", codecOptional(codecForString()))
- .property(
- "status",
- codecForEither(
- codecForConstString("pending"),
- codecForConstString("selected"),
- codecForConstString("aborted"),
- codecForConstString("confirmed"),
- ),
- )
- .property("amount", codecOptional(codecForAmountString()))
- .property("maxAmount", codecOptional(codecForAmountString()))
- .property("wireFee", codecOptional(codecForAmountString()))
- .property("currency", codecForString())
- .property("editableAmount", codecForBoolean())
- .property("editableExchange", codecForBoolean())
- .property("defaultExchangeBaseUrl", codecOptional(codecForCanonBaseUrl()))
- .property("possibleExchanges", codecForList(codecForExchangeListItem()))
- .build("WithdrawUriInfoResponse");
-
export interface WalletCurrencyInfo {
trustedAuditors: {
currency: string;
diff --git a/packages/taler-wallet-core/src/db.ts b/packages/taler-wallet-core/src/db.ts
@@ -741,6 +741,17 @@ export interface ExchangeEntryRecord {
presetCurrencyHint?: string;
/**
+ * Currency spec for a preset exchange, relevant
+ * when we didn't contact a preset exchange yet.
+ */
+ presetCurrencySpec?: CurrencySpecification;
+
+ /**
+ * Type of the exchange, if it was a preset entry.
+ */
+ presetType?: string;
+
+ /**
* When did we confirm the last withdrawal from this exchange?
*
* Used mostly in the UI to suggest exchanges.
diff --git a/packages/taler-wallet-core/src/exchanges.ts b/packages/taler-wallet-core/src/exchanges.ts
@@ -33,6 +33,7 @@ import {
CancellationToken,
CoinRefreshRequest,
CoinStatus,
+ CurrencySpecification,
DeleteExchangeRequest,
DenomKeyType,
DenomLossEventType,
@@ -183,6 +184,7 @@ import {
import { WALLET_EXCHANGE_PROTOCOL_VERSION } from "./versions.js";
import {
InternalWalletState,
+ LegacyWalletTxHandle,
WalletExecutionContext,
walletExchangeClient,
} from "./wallet.js";
@@ -437,7 +439,7 @@ function getKycStatusFromReserveStatus(
async function makeExchangeListItem(
wex: WalletExecutionContext,
- tx: WalletIndexedDbTransaction,
+ tx: LegacyWalletTxHandle,
r: ExchangeEntryRecord,
exchangeDetails: ExchangeDetailsRecord | undefined,
reserveRec: ReserveRecord | undefined,
@@ -449,6 +451,26 @@ async function makeExchangeListItem(
scopeInfo = await internalGetExchangeScopeInfo(tx, exchangeDetails);
}
+ const currency =
+ exchangeDetails?.currency ?? r.presetCurrencyHint ?? "UNKNOWN";
+
+ scopeInfo = scopeInfo ?? {
+ type: ScopeType.Exchange,
+ currency,
+ url: r.baseUrl,
+ };
+
+ let currencySpec = (await tx.wtx.getCurrencyInfo(scopeInfo))?.currencySpec;
+
+ currencySpec = currencySpec ??
+ r.presetCurrencySpec ?? {
+ alt_unit_names: {},
+ name: currency,
+ num_fractional_input_digits: 2,
+ num_fractional_normal_digits: 2,
+ num_fractional_trailing_zero_digits: 2,
+ };
+
let walletKycStatus: ExchangeWalletKycStatus | undefined =
reserveRec && reserveRec.status
? getKycStatusFromReserveStatus(reserveRec.status)
@@ -483,11 +505,8 @@ async function makeExchangeListItem(
paytoUris: exchangeDetails?.wireInfo.accounts.map((x) => x.payto_uri) ?? [],
bankComplianceLanguage: exchangeDetails?.bankComplianceLanguage,
lastUpdateTimestamp: timestampOptionalPreciseFromDb(r.lastUpdate),
- scopeInfo: scopeInfo ?? {
- type: ScopeType.Exchange,
- currency: "UNKNOWN",
- url: r.baseUrl,
- },
+ currencySpec,
+ scopeInfo,
};
if (lastError) {
listItem.lastUpdateErrorInfo = {
@@ -751,16 +770,19 @@ async function validateGlobalFees(
}
/**
- * Add an exchange entry to the wallet database in the
+ * Add or update exchange entry to the wallet database.
+ * If it is a new entry, it is created in the
* entry state "preset".
*
* Returns the notification to the caller that should be emitted
* if the DB transaction succeeds.
*/
-export async function addPresetExchangeEntry(
+export async function putPresetExchangeEntry(
tx: WalletIndexedDbTransaction,
exchangeBaseUrl: string,
+ exchangeType: string,
currencyHint?: string,
+ currencySpec?: CurrencySpecification,
): Promise<void> {
let exchange = await tx.exchanges.get(exchangeBaseUrl);
if (!exchange) {
@@ -768,6 +790,8 @@ export async function addPresetExchangeEntry(
entryStatus: ExchangeEntryDbRecordStatus.Preset,
updateStatus: ExchangeEntryDbUpdateStatus.Initial,
baseUrl: exchangeBaseUrl,
+ presetType: exchangeType,
+ presetCurrencySpec: currencySpec,
presetCurrencyHint: currencyHint,
detailsPointer: undefined,
lastUpdate: undefined,
@@ -790,8 +814,12 @@ export async function addPresetExchangeEntry(
oldExchangeState: undefined,
newExchangeState: getExchangeState(r),
});
+ } else {
+ exchange.presetCurrencySpec = currencySpec;
+ exchange.presetCurrencyHint = currencyHint;
+ exchange.presetType = exchangeType;
+ await tx.exchanges.put(exchange);
}
- return;
}
async function provideExchangeRecordInTx(
@@ -2775,6 +2803,13 @@ export async function listExchanges(
);
checkDbInvariant(!!reserveRec, "reserve record not found");
}
+ if (
+ req.filterByType != null &&
+ exchangeRec.presetType != null &&
+ exchangeRec.presetType != req.filterByType
+ ) {
+ continue;
+ }
if (req.filterByScope) {
const inScope = await checkExchangeInScopeTx(
tx,
diff --git a/packages/taler-wallet-core/src/preset-exchanges.ts b/packages/taler-wallet-core/src/preset-exchanges.ts
@@ -0,0 +1,109 @@
+/*
+ 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 { CurrencySpecification, Logger } from "@gnu-taler/taler-util";
+import { ConfigRecordKey } from "./db.js";
+import { putPresetExchangeEntry } from "./exchanges.js";
+import { WalletExecutionContext } from "./wallet.js";
+
+interface BuiltinExchange {
+ exchangeBaseUrl: string;
+ currencyHint: string;
+ currencySpec?: CurrencySpecification;
+ type: "demo" | "prod";
+ versionAdded: number;
+}
+
+/**
+ * File-wide logger.
+ */
+const logger = new Logger("preset-exchanges.ts");
+
+/**
+ * Version of the defaults that ship with the code.
+ */
+const currentDefaultsVersion = 3;
+
+/**
+ * Exchanges that ship with wallet-core.
+ */
+const builtinExchanges: BuiltinExchange[] = [
+ {
+ exchangeBaseUrl: "https://exchange.demo.taler.net/",
+ type: "demo",
+ currencyHint: "KUDOS",
+ versionAdded: 1,
+ },
+ {
+ exchangeBaseUrl: "https://exchange.taler-ops.ch/",
+ currencyHint: "CHF",
+ type: "prod",
+ versionAdded: 3,
+ currencySpec: {
+ name: "Swiss francs",
+ common_amounts: ["CHF:5", "CHF:10", "CHF:25", "CHF:50"],
+ num_fractional_input_digits: 2,
+ num_fractional_normal_digits: 2,
+ num_fractional_trailing_zero_digits: 2,
+ alt_unit_names: {
+ "0": "Fr.",
+ "-2": "Rp.",
+ },
+ },
+ },
+];
+
+/**
+ * Insert the hard-coded defaults for exchanges, coins and
+ * auditors into the database, unless these defaults have
+ * already been applied.
+ */
+export async function fillDefaults(wex: WalletExecutionContext): Promise<void> {
+ await wex.runLegacyWalletDbTx(async (tx) => {
+ let appliedRec = await tx.config.get("currencyDefaultsApplied");
+ let appliedVersion = appliedRec ? !!appliedRec.value : 0;
+ if (appliedRec != null) {
+ if (appliedRec.value === true) {
+ appliedVersion = 1;
+ } else if (appliedRec.value == false) {
+ appliedVersion = 0;
+ } else if (typeof appliedRec.value === "number") {
+ appliedVersion = appliedRec.value;
+ } else {
+ logger.error("invalid value for currencyDefaultsApplied config");
+ appliedVersion = 0;
+ }
+ } else {
+ appliedVersion = 0;
+ }
+ for (const exch of builtinExchanges) {
+ if (exch.versionAdded < appliedVersion) {
+ continue;
+ }
+ await putPresetExchangeEntry(
+ tx,
+ exch.exchangeBaseUrl,
+ exch.type,
+ exch.currencyHint,
+ exch.currencySpec,
+ );
+ }
+ await tx.config.put({
+ key: ConfigRecordKey.CurrencyDefaultsApplied,
+ value: currentDefaultsVersion,
+ });
+ });
+}
diff --git a/packages/taler-wallet-core/src/wallet.ts b/packages/taler-wallet-core/src/wallet.ts
@@ -96,6 +96,7 @@ import {
GetQrCodesForPaytoRequest,
GetQrCodesForPaytoResponse,
HintNetworkAvailabilityRequest,
+ HostPortPath,
ImportDbFromFileRequest,
ImportDbRequest,
InitRequest,
@@ -141,6 +142,7 @@ import {
TalerPreciseTimestamp,
TalerProtocolTimestamp,
TalerUriAction,
+ TalerUris,
TestingCorruptWithdrawalCoinSelRequest,
TestingGetDenomStatsRequest,
TestingGetDenomStatsResponse,
@@ -333,7 +335,6 @@ import {
OutdatedExchangeError,
ReadyExchangeSummary,
acceptExchangeTermsOfService,
- addPresetExchangeEntry,
deleteExchange,
fetchFreshExchange,
forgetExchangeTermsOfService,
@@ -391,6 +392,7 @@ import {
checkPeerPushDebitV2,
initiatePeerPushDebit,
} from "./pay-peer-push-debit.js";
+import { fillDefaults } from "./preset-exchanges.js";
import {
AfterCommitInfo,
DbAccess,
@@ -526,60 +528,6 @@ export type NotificationListener = (n: WalletNotification) => void;
type CancelFn = () => void;
-const currentDefaultsVersion = 2;
-
-/**
- * Exchanges that ship with wallet-core.
- */
-const builtinExchanges = [
- {
- exchangeBaseUrl: "https://exchange.demo.taler.net/",
- currencyHint: "KUDOS",
- versionAdded: 1,
- },
- {
- exchangeBaseUrl: "https://exchange.taler-ops.ch/",
- currencyHint: "CHF",
- versionAdded: 2,
- },
-];
-
-/**
- * Insert the hard-coded defaults for exchanges, coins and
- * auditors into the database, unless these defaults have
- * already been applied.
- */
-async function fillDefaults(wex: WalletExecutionContext): Promise<void> {
- await wex.runLegacyWalletDbTx(async (tx) => {
- let appliedRec = await tx.config.get("currencyDefaultsApplied");
- let appliedVersion = appliedRec ? !!appliedRec.value : 0;
- if (appliedRec != null) {
- if (appliedRec.value === true) {
- appliedVersion = 1;
- } else if (appliedRec.value == false) {
- appliedVersion = 0;
- } else if (typeof appliedRec.value === "number") {
- appliedVersion = appliedRec.value;
- } else {
- logger.error("invalid value for currencyDefaultsApplied config");
- appliedVersion = 0;
- }
- } else {
- appliedVersion = 0;
- }
- for (const exch of builtinExchanges) {
- if (exch.versionAdded < currentDefaultsVersion) {
- continue;
- }
- await addPresetExchangeEntry(tx, exch.exchangeBaseUrl, exch.currencyHint);
- }
- await tx.config.put({
- key: ConfigRecordKey.CurrencyDefaultsApplied,
- value: currentDefaultsVersion,
- });
- });
-}
-
/**
* Incremented each time we want to re-materialize transactions.
*/
@@ -1639,19 +1587,19 @@ export async function handleAddGlobalCurrencyExchange(
return;
}
wex.ws.exchangeCache.clear();
- const info = await tx.currencyInfo.get(
+ const infoRec = await tx.currencyInfo.get(
stringifyScopeInfo({
type: ScopeType.Exchange,
currency: req.currency,
url: req.exchangeBaseUrl,
}),
);
- if (info) {
- info.scopeInfoStr = stringifyScopeInfo({
+ if (infoRec) {
+ infoRec.scopeInfoStr = stringifyScopeInfo({
type: ScopeType.Global,
currency: req.currency,
});
- await tx.currencyInfo.put(info);
+ await tx.currencyInfo.put(infoRec);
}
await tx.globalCurrencyExchanges.add({
currency: req.currency,
@@ -2017,21 +1965,20 @@ export async function handleGetDefaultExchanges(
_req: EmptyObject,
): Promise<GetDefaultExchangesResponse> {
const defaultExchanges: GetDefaultExchangesResponse["defaultExchanges"] = [];
- defaultExchanges.push({
- talerUri: "taler://withdraw-exchange/exchange.taler-ops.ch/",
- currency: "CHF",
- currencySpec: {
- name: "Swiss francs",
- common_amounts: ["CHF:5", "CHF:10", "CHF:25", "CHF:50"],
- num_fractional_input_digits: 2,
- num_fractional_normal_digits: 2,
- num_fractional_trailing_zero_digits: 2,
- alt_unit_names: {
- "0": "Fr.",
- "-2": "Rp.",
- },
- },
+ const myExchanges = await listExchanges(wex, {
+ filterByType: "prod",
});
+ for (const exch of myExchanges.exchanges) {
+ defaultExchanges.push({
+ currency: exch.currency,
+ currencySpec: exch.currencySpec,
+ talerUri: TalerUris.toString(
+ TalerUris.createTalerWithdrawExchange(
+ exch.exchangeBaseUrl as HostPortPath,
+ ),
+ ),
+ });
+ }
if (wex.ws.devExperimentState.fakeDefaultExchangeDemo) {
defaultExchanges.push({
talerUri: "taler://withdraw-exchange/exchange.demo.taler.net/",