taler-typescript-core

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

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:
Mpackages/taler-util/src/codec.ts | 26+++++++++++++++++++++++---
Mpackages/taler-util/src/types-taler-wallet.ts | 71++++++++++++++++++++++-------------------------------------------------
Mpackages/taler-wallet-core/src/db.ts | 11+++++++++++
Mpackages/taler-wallet-core/src/exchanges.ts | 53++++++++++++++++++++++++++++++++++++++++++++---------
Apackages/taler-wallet-core/src/preset-exchanges.ts | 109+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mpackages/taler-wallet-core/src/wallet.ts | 93+++++++++++++++++--------------------------------------------------------------
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/",