summaryrefslogtreecommitdiff
path: root/packages/taler-wallet-core/src/exchanges.ts
diff options
context:
space:
mode:
Diffstat (limited to 'packages/taler-wallet-core/src/exchanges.ts')
-rw-r--r--packages/taler-wallet-core/src/exchanges.ts2584
1 files changed, 2584 insertions, 0 deletions
diff --git a/packages/taler-wallet-core/src/exchanges.ts b/packages/taler-wallet-core/src/exchanges.ts
new file mode 100644
index 000000000..9072a4e6e
--- /dev/null
+++ b/packages/taler-wallet-core/src/exchanges.ts
@@ -0,0 +1,2584 @@
+/*
+ This file is part of GNU Taler
+ (C) 2019 GNUnet e.V.
+
+ 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/>
+ */
+
+/**
+ * @fileoverview
+ * Implementation of exchange entry management in wallet-core.
+ * The details of exchange entry management are specified in DD48.
+ */
+
+/**
+ * Imports.
+ */
+import {
+ AbsoluteTime,
+ AgeRestriction,
+ Amount,
+ Amounts,
+ AsyncFlag,
+ CancellationToken,
+ CoinRefreshRequest,
+ CoinStatus,
+ DeleteExchangeRequest,
+ DenomKeyType,
+ DenomLossEventType,
+ DenomOperationMap,
+ DenominationInfo,
+ DenominationPubKey,
+ Duration,
+ EddsaPublicKeyString,
+ ExchangeAuditor,
+ ExchangeDetailedResponse,
+ ExchangeGlobalFees,
+ ExchangeListItem,
+ ExchangeSignKeyJson,
+ ExchangeTosStatus,
+ ExchangeWireAccount,
+ ExchangesListResponse,
+ FeeDescription,
+ GetExchangeEntryByUrlRequest,
+ GetExchangeResourcesResponse,
+ GetExchangeTosResult,
+ GlobalFees,
+ LibtoolVersion,
+ Logger,
+ NotificationType,
+ OperationErrorInfo,
+ Recoup,
+ RefreshReason,
+ ScopeInfo,
+ ScopeType,
+ TalerError,
+ TalerErrorCode,
+ TalerErrorDetail,
+ TalerPreciseTimestamp,
+ TalerProtocolDuration,
+ TalerProtocolTimestamp,
+ TransactionIdStr,
+ TransactionMajorState,
+ TransactionState,
+ TransactionType,
+ URL,
+ WalletNotification,
+ WireFee,
+ WireFeeMap,
+ WireFeesJson,
+ WireInfo,
+ assertUnreachable,
+ checkDbInvariant,
+ codecForExchangeKeysJson,
+ durationMul,
+ encodeCrock,
+ getRandomBytes,
+ hashDenomPub,
+ j2s,
+ makeErrorDetail,
+ parsePaytoUri,
+} from "@gnu-taler/taler-util";
+import {
+ HttpRequestLibrary,
+ getExpiry,
+ readSuccessResponseJsonOrThrow,
+ readSuccessResponseTextOrThrow,
+} from "@gnu-taler/taler-util/http";
+import {
+ PendingTaskType,
+ TaskIdStr,
+ TaskIdentifiers,
+ TaskRunResult,
+ TaskRunResultType,
+ TransactionContext,
+ computeDbBackoff,
+ constructTaskIdentifier,
+ getAutoRefreshExecuteThreshold,
+ getExchangeEntryStatusFromRecord,
+ getExchangeState,
+ getExchangeTosStatusFromRecord,
+ getExchangeUpdateStatusFromRecord,
+} from "./common.js";
+import {
+ DenomLossEventRecord,
+ DenomLossStatus,
+ DenominationRecord,
+ DenominationVerificationStatus,
+ ExchangeDetailsRecord,
+ ExchangeEntryDbRecordStatus,
+ ExchangeEntryDbUpdateStatus,
+ ExchangeEntryRecord,
+ WalletDbReadOnlyTransaction,
+ WalletDbReadWriteTransaction,
+ WalletStoresV1,
+ timestampAbsoluteFromDb,
+ timestampOptionalPreciseFromDb,
+ timestampPreciseFromDb,
+ timestampPreciseToDb,
+ timestampProtocolFromDb,
+ timestampProtocolToDb,
+} from "./db.js";
+import {
+ createTimeline,
+ isWithdrawableDenom,
+ selectBestForOverlappingDenominations,
+ selectMinimumFee,
+} from "./denominations.js";
+import { DbReadOnlyTransaction } from "./query.js";
+import { createRecoupGroup } from "./recoup.js";
+import { createRefreshGroup } from "./refresh.js";
+import {
+ constructTransactionIdentifier,
+ notifyTransition,
+} from "./transactions.js";
+import { WALLET_EXCHANGE_PROTOCOL_VERSION } from "./versions.js";
+import { InternalWalletState, WalletExecutionContext } from "./wallet.js";
+
+const logger = new Logger("exchanges.ts");
+
+function getExchangeRequestTimeout(): Duration {
+ return Duration.fromSpec({
+ seconds: 15,
+ });
+}
+
+interface ExchangeTosDownloadResult {
+ tosText: string;
+ tosEtag: string;
+ tosContentType: string;
+ tosContentLanguage: string | undefined;
+ tosAvailableLanguages: string[];
+}
+
+async function downloadExchangeWithTermsOfService(
+ wex: WalletExecutionContext,
+ exchangeBaseUrl: string,
+ http: HttpRequestLibrary,
+ timeout: Duration,
+ acceptFormat: string,
+ acceptLanguage: string | undefined,
+): Promise<ExchangeTosDownloadResult> {
+ logger.trace(`downloading exchange tos (type ${acceptFormat})`);
+ const reqUrl = new URL("terms", exchangeBaseUrl);
+ const headers: {
+ Accept: string;
+ "Accept-Language"?: string;
+ } = {
+ Accept: acceptFormat,
+ };
+
+ if (acceptLanguage) {
+ headers["Accept-Language"] = acceptLanguage;
+ }
+
+ const resp = await http.fetch(reqUrl.href, {
+ headers,
+ timeout,
+ cancellationToken: wex.cancellationToken,
+ });
+ const tosText = await readSuccessResponseTextOrThrow(resp);
+ const tosEtag = resp.headers.get("etag") || "unknown";
+ const tosContentLanguage = resp.headers.get("content-language") || undefined;
+ const tosContentType = resp.headers.get("content-type") || "text/plain";
+ const availLangStr = resp.headers.get("avail-languages") || "";
+ // Work around exchange bug that reports the same language multiple times.
+ const availLangSet = new Set<string>(
+ availLangStr.split(",").map((x) => x.trim()),
+ );
+ const tosAvailableLanguages = [...availLangSet];
+
+ return {
+ tosText,
+ tosEtag,
+ tosContentType,
+ tosContentLanguage,
+ tosAvailableLanguages,
+ };
+}
+
+/**
+ * Get exchange details from the database.
+ */
+async function getExchangeRecordsInternal(
+ tx: WalletDbReadOnlyTransaction<["exchanges", "exchangeDetails"]>,
+ exchangeBaseUrl: string,
+): Promise<ExchangeDetailsRecord | undefined> {
+ const r = await tx.exchanges.get(exchangeBaseUrl);
+ if (!r) {
+ logger.warn(`no exchange found for ${exchangeBaseUrl}`);
+ return;
+ }
+ const dp = r.detailsPointer;
+ if (!dp) {
+ logger.warn(`no exchange details pointer for ${exchangeBaseUrl}`);
+ return;
+ }
+ const { currency, masterPublicKey } = dp;
+ const details = await tx.exchangeDetails.indexes.byPointer.get([
+ r.baseUrl,
+ currency,
+ masterPublicKey,
+ ]);
+ if (!details) {
+ logger.warn(
+ `no exchange details with pointer ${j2s(dp)} for ${exchangeBaseUrl}`,
+ );
+ }
+ return details;
+}
+
+export async function getExchangeScopeInfo(
+ tx: WalletDbReadOnlyTransaction<
+ [
+ "exchanges",
+ "exchangeDetails",
+ "globalCurrencyExchanges",
+ "globalCurrencyAuditors",
+ ]
+ >,
+ exchangeBaseUrl: string,
+ currency: string,
+): Promise<ScopeInfo> {
+ const det = await getExchangeRecordsInternal(tx, exchangeBaseUrl);
+ if (!det) {
+ return {
+ type: ScopeType.Exchange,
+ currency: currency,
+ url: exchangeBaseUrl,
+ };
+ }
+ return internalGetExchangeScopeInfo(tx, det);
+}
+
+async function internalGetExchangeScopeInfo(
+ tx: WalletDbReadOnlyTransaction<
+ ["globalCurrencyExchanges", "globalCurrencyAuditors"]
+ >,
+ exchangeDetails: ExchangeDetailsRecord,
+): Promise<ScopeInfo> {
+ const globalExchangeRec =
+ await tx.globalCurrencyExchanges.indexes.byCurrencyAndUrlAndPub.get([
+ exchangeDetails.currency,
+ exchangeDetails.exchangeBaseUrl,
+ exchangeDetails.masterPublicKey,
+ ]);
+ if (globalExchangeRec) {
+ return {
+ currency: exchangeDetails.currency,
+ type: ScopeType.Global,
+ };
+ } else {
+ for (const aud of exchangeDetails.auditors) {
+ const globalAuditorRec =
+ await tx.globalCurrencyAuditors.indexes.byCurrencyAndUrlAndPub.get([
+ exchangeDetails.currency,
+ aud.auditor_url,
+ aud.auditor_pub,
+ ]);
+ if (globalAuditorRec) {
+ return {
+ currency: exchangeDetails.currency,
+ type: ScopeType.Auditor,
+ url: aud.auditor_url,
+ };
+ }
+ }
+ }
+ return {
+ currency: exchangeDetails.currency,
+ type: ScopeType.Exchange,
+ url: exchangeDetails.exchangeBaseUrl,
+ };
+}
+
+async function makeExchangeListItem(
+ tx: WalletDbReadOnlyTransaction<
+ ["globalCurrencyExchanges", "globalCurrencyAuditors"]
+ >,
+ r: ExchangeEntryRecord,
+ exchangeDetails: ExchangeDetailsRecord | undefined,
+ lastError: TalerErrorDetail | undefined,
+): Promise<ExchangeListItem> {
+ const lastUpdateErrorInfo: OperationErrorInfo | undefined = lastError
+ ? {
+ error: lastError,
+ }
+ : undefined;
+
+ let scopeInfo: ScopeInfo | undefined = undefined;
+
+ if (exchangeDetails) {
+ scopeInfo = await internalGetExchangeScopeInfo(tx, exchangeDetails);
+ }
+
+ return {
+ exchangeBaseUrl: r.baseUrl,
+ masterPub: exchangeDetails?.masterPublicKey,
+ noFees: r.noFees ?? false,
+ peerPaymentsDisabled: r.peerPaymentsDisabled ?? false,
+ currency: exchangeDetails?.currency ?? r.presetCurrencyHint ?? "UNKNOWN",
+ exchangeUpdateStatus: getExchangeUpdateStatusFromRecord(r),
+ exchangeEntryStatus: getExchangeEntryStatusFromRecord(r),
+ tosStatus: getExchangeTosStatusFromRecord(r),
+ ageRestrictionOptions: exchangeDetails?.ageMask
+ ? AgeRestriction.getAgeGroupsFromMask(exchangeDetails.ageMask)
+ : [],
+ paytoUris: exchangeDetails?.wireInfo.accounts.map((x) => x.payto_uri) ?? [],
+ lastUpdateTimestamp: timestampOptionalPreciseFromDb(r.lastUpdate),
+ lastUpdateErrorInfo,
+ scopeInfo: scopeInfo ?? {
+ type: ScopeType.Exchange,
+ currency: "UNKNOWN",
+ url: r.baseUrl,
+ },
+ };
+}
+
+export interface ExchangeWireDetails {
+ currency: string;
+ masterPublicKey: EddsaPublicKeyString;
+ wireInfo: WireInfo;
+ exchangeBaseUrl: string;
+ auditors: ExchangeAuditor[];
+ globalFees: ExchangeGlobalFees[];
+}
+
+export async function getExchangeWireDetailsInTx(
+ tx: WalletDbReadOnlyTransaction<["exchanges", "exchangeDetails"]>,
+ exchangeBaseUrl: string,
+): Promise<ExchangeWireDetails | undefined> {
+ const det = await getExchangeRecordsInternal(tx, exchangeBaseUrl);
+ if (!det) {
+ return undefined;
+ }
+ return {
+ currency: det.currency,
+ masterPublicKey: det.masterPublicKey,
+ wireInfo: det.wireInfo,
+ exchangeBaseUrl: det.exchangeBaseUrl,
+ auditors: det.auditors,
+ globalFees: det.globalFees,
+ };
+}
+
+export async function lookupExchangeByUri(
+ wex: WalletExecutionContext,
+ req: GetExchangeEntryByUrlRequest,
+): Promise<ExchangeListItem> {
+ return await wex.db.runReadOnlyTx(
+ {
+ storeNames: [
+ "exchanges",
+ "exchangeDetails",
+ "operationRetries",
+ "globalCurrencyAuditors",
+ "globalCurrencyExchanges",
+ ],
+ },
+ async (tx) => {
+ const exchangeRec = await tx.exchanges.get(req.exchangeBaseUrl);
+ if (!exchangeRec) {
+ throw Error("exchange not found");
+ }
+ const exchangeDetails = await getExchangeRecordsInternal(
+ tx,
+ exchangeRec.baseUrl,
+ );
+ const opRetryRecord = await tx.operationRetries.get(
+ TaskIdentifiers.forExchangeUpdate(exchangeRec),
+ );
+ return await makeExchangeListItem(
+ tx,
+ exchangeRec,
+ exchangeDetails,
+ opRetryRecord?.lastError,
+ );
+ },
+ );
+}
+
+/**
+ * Mark the current ToS version as accepted by the user.
+ */
+export async function acceptExchangeTermsOfService(
+ wex: WalletExecutionContext,
+ exchangeBaseUrl: string,
+): Promise<void> {
+ const notif = await wex.db.runReadWriteTx(
+ { storeNames: ["exchangeDetails", "exchanges"] },
+ async (tx) => {
+ const exch = await tx.exchanges.get(exchangeBaseUrl);
+ if (exch && exch.tosCurrentEtag) {
+ const oldExchangeState = getExchangeState(exch);
+ exch.tosAcceptedEtag = exch.tosCurrentEtag;
+ exch.tosAcceptedTimestamp = timestampPreciseToDb(
+ TalerPreciseTimestamp.now(),
+ );
+ await tx.exchanges.put(exch);
+ const newExchangeState = getExchangeState(exch);
+ wex.ws.exchangeCache.clear();
+ return {
+ type: NotificationType.ExchangeStateTransition,
+ exchangeBaseUrl,
+ newExchangeState: newExchangeState,
+ oldExchangeState: oldExchangeState,
+ } satisfies WalletNotification;
+ }
+ return undefined;
+ },
+ );
+ if (notif) {
+ wex.ws.notify(notif);
+ }
+}
+
+/**
+ * Mark the current ToS version as accepted by the user.
+ */
+export async function forgetExchangeTermsOfService(
+ wex: WalletExecutionContext,
+ exchangeBaseUrl: string,
+): Promise<void> {
+ const notif = await wex.db.runReadWriteTx(
+ { storeNames: ["exchangeDetails", "exchanges"] },
+ async (tx) => {
+ const exch = await tx.exchanges.get(exchangeBaseUrl);
+ if (exch) {
+ const oldExchangeState = getExchangeState(exch);
+ exch.tosAcceptedEtag = undefined;
+ exch.tosAcceptedTimestamp = undefined;
+ await tx.exchanges.put(exch);
+ const newExchangeState = getExchangeState(exch);
+ wex.ws.exchangeCache.clear();
+ return {
+ type: NotificationType.ExchangeStateTransition,
+ exchangeBaseUrl,
+ newExchangeState: newExchangeState,
+ oldExchangeState: oldExchangeState,
+ } satisfies WalletNotification;
+ }
+ return undefined;
+ },
+ );
+ if (notif) {
+ wex.ws.notify(notif);
+ }
+}
+
+/**
+ * Validate wire fees and wire accounts.
+ *
+ * Throw an exception if they are invalid.
+ */
+async function validateWireInfo(
+ wex: WalletExecutionContext,
+ versionCurrent: number,
+ wireInfo: ExchangeKeysDownloadResult,
+ masterPublicKey: string,
+): Promise<WireInfo> {
+ for (const a of wireInfo.accounts) {
+ logger.trace("validating exchange acct");
+ let isValid = false;
+ if (wex.ws.config.testing.insecureTrustExchange) {
+ isValid = true;
+ } else {
+ const { valid: v } = await wex.ws.cryptoApi.isValidWireAccount({
+ masterPub: masterPublicKey,
+ paytoUri: a.payto_uri,
+ sig: a.master_sig,
+ versionCurrent,
+ conversionUrl: a.conversion_url,
+ creditRestrictions: a.credit_restrictions,
+ debitRestrictions: a.debit_restrictions,
+ });
+ isValid = v;
+ }
+ if (!isValid) {
+ throw Error("exchange acct signature invalid");
+ }
+ }
+ logger.trace("account validation done");
+ const feesForType: WireFeeMap = {};
+ for (const wireMethod of Object.keys(wireInfo.wireFees)) {
+ const feeList: WireFee[] = [];
+ for (const x of wireInfo.wireFees[wireMethod]) {
+ const startStamp = x.start_date;
+ const endStamp = x.end_date;
+ const fee: WireFee = {
+ closingFee: Amounts.stringify(x.closing_fee),
+ endStamp,
+ sig: x.sig,
+ startStamp,
+ wireFee: Amounts.stringify(x.wire_fee),
+ };
+ let isValid = false;
+ if (wex.ws.config.testing.insecureTrustExchange) {
+ isValid = true;
+ } else {
+ const { valid: v } = await wex.ws.cryptoApi.isValidWireFee({
+ masterPub: masterPublicKey,
+ type: wireMethod,
+ wf: fee,
+ });
+ isValid = v;
+ }
+ if (!isValid) {
+ throw Error("exchange wire fee signature invalid");
+ }
+ feeList.push(fee);
+ }
+ feesForType[wireMethod] = feeList;
+ }
+
+ return {
+ accounts: wireInfo.accounts,
+ feesForType,
+ };
+}
+
+/**
+ * Validate global fees.
+ *
+ * Throw an exception if they are invalid.
+ */
+async function validateGlobalFees(
+ wex: WalletExecutionContext,
+ fees: GlobalFees[],
+ masterPub: string,
+): Promise<ExchangeGlobalFees[]> {
+ const egf: ExchangeGlobalFees[] = [];
+ for (const gf of fees) {
+ logger.trace("validating exchange global fees");
+ let isValid = false;
+ if (wex.ws.config.testing.insecureTrustExchange) {
+ isValid = true;
+ } else {
+ const { valid: v } = await wex.cryptoApi.isValidGlobalFees({
+ masterPub,
+ gf,
+ });
+ isValid = v;
+ }
+
+ if (!isValid) {
+ throw Error("exchange global fees signature invalid: " + gf.master_sig);
+ }
+ egf.push({
+ accountFee: Amounts.stringify(gf.account_fee),
+ historyFee: Amounts.stringify(gf.history_fee),
+ purseFee: Amounts.stringify(gf.purse_fee),
+ startDate: gf.start_date,
+ endDate: gf.end_date,
+ signature: gf.master_sig,
+ historyTimeout: gf.history_expiration,
+ purseLimit: gf.purse_account_limit,
+ purseTimeout: gf.purse_timeout,
+ });
+ }
+
+ return egf;
+}
+
+/**
+ * Add an exchange entry to the wallet database in the
+ * entry state "preset".
+ *
+ * Returns the notification to the caller that should be emitted
+ * if the DB transaction succeeds.
+ */
+export async function addPresetExchangeEntry(
+ tx: WalletDbReadWriteTransaction<["exchanges"]>,
+ exchangeBaseUrl: string,
+ currencyHint?: string,
+): Promise<{ notification?: WalletNotification }> {
+ let exchange = await tx.exchanges.get(exchangeBaseUrl);
+ if (!exchange) {
+ const r: ExchangeEntryRecord = {
+ entryStatus: ExchangeEntryDbRecordStatus.Preset,
+ updateStatus: ExchangeEntryDbUpdateStatus.Initial,
+ baseUrl: exchangeBaseUrl,
+ presetCurrencyHint: currencyHint,
+ detailsPointer: undefined,
+ lastUpdate: undefined,
+ lastKeysEtag: undefined,
+ nextRefreshCheckStamp: timestampPreciseToDb(
+ AbsoluteTime.toPreciseTimestamp(AbsoluteTime.never()),
+ ),
+ nextUpdateStamp: timestampPreciseToDb(
+ AbsoluteTime.toPreciseTimestamp(AbsoluteTime.never()),
+ ),
+ tosAcceptedEtag: undefined,
+ tosAcceptedTimestamp: undefined,
+ tosCurrentEtag: undefined,
+ };
+ await tx.exchanges.put(r);
+ return {
+ notification: {
+ type: NotificationType.ExchangeStateTransition,
+ exchangeBaseUrl: exchangeBaseUrl,
+ // Exchange did not exist yet
+ oldExchangeState: undefined,
+ newExchangeState: getExchangeState(r),
+ },
+ };
+ }
+ return {};
+}
+
+async function provideExchangeRecordInTx(
+ ws: InternalWalletState,
+ tx: WalletDbReadWriteTransaction<["exchanges", "exchangeDetails"]>,
+ baseUrl: string,
+): Promise<{
+ exchange: ExchangeEntryRecord;
+ exchangeDetails: ExchangeDetailsRecord | undefined;
+ notification?: WalletNotification;
+}> {
+ let notification: WalletNotification | undefined = undefined;
+ let exchange = await tx.exchanges.get(baseUrl);
+ if (!exchange) {
+ const r: ExchangeEntryRecord = {
+ entryStatus: ExchangeEntryDbRecordStatus.Ephemeral,
+ updateStatus: ExchangeEntryDbUpdateStatus.InitialUpdate,
+ baseUrl: baseUrl,
+ detailsPointer: undefined,
+ lastUpdate: undefined,
+ nextUpdateStamp: timestampPreciseToDb(
+ AbsoluteTime.toPreciseTimestamp(AbsoluteTime.never()),
+ ),
+ nextRefreshCheckStamp: timestampPreciseToDb(
+ AbsoluteTime.toPreciseTimestamp(AbsoluteTime.never()),
+ ),
+ // The first update should always be done in a way that ignores the cache,
+ // so that removing and re-adding an exchange works properly, even
+ // if /keys is cached in the browser.
+ cachebreakNextUpdate: true,
+ lastKeysEtag: undefined,
+ tosAcceptedEtag: undefined,
+ tosAcceptedTimestamp: undefined,
+ tosCurrentEtag: undefined,
+ };
+ await tx.exchanges.put(r);
+ exchange = r;
+ notification = {
+ type: NotificationType.ExchangeStateTransition,
+ exchangeBaseUrl: r.baseUrl,
+ oldExchangeState: undefined,
+ newExchangeState: getExchangeState(r),
+ };
+ }
+ const exchangeDetails = await getExchangeRecordsInternal(tx, baseUrl);
+ return { exchange, exchangeDetails, notification };
+}
+
+export interface ExchangeKeysDownloadResult {
+ baseUrl: string;
+ masterPublicKey: string;
+ currency: string;
+ auditors: ExchangeAuditor[];
+ currentDenominations: DenominationRecord[];
+ protocolVersion: string;
+ signingKeys: ExchangeSignKeyJson[];
+ reserveClosingDelay: TalerProtocolDuration;
+ expiry: TalerProtocolTimestamp;
+ recoup: Recoup[];
+ listIssueDate: TalerProtocolTimestamp;
+ globalFees: GlobalFees[];
+ accounts: ExchangeWireAccount[];
+ wireFees: { [methodName: string]: WireFeesJson[] };
+}
+
+/**
+ * Download and validate an exchange's /keys data.
+ */
+async function downloadExchangeKeysInfo(
+ baseUrl: string,
+ http: HttpRequestLibrary,
+ timeout: Duration,
+ 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, {
+ timeout,
+ cancellationToken,
+ headers,
+ });
+
+ logger.info("got response to /keys request");
+
+ // 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.
+
+ const keysJson = await resp.json();
+
+ const protocolVersion = keysJson.version;
+ if (typeof protocolVersion !== "string") {
+ throw Error("bad exchange, does not even specify protocol version");
+ }
+
+ 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) {
+ throw TalerError.fromDetail(
+ TalerErrorCode.WALLET_EXCHANGE_PROTOCOL_VERSION_INCOMPATIBLE,
+ {
+ exchangeProtocolVersion: protocolVersion,
+ walletProtocolVersion: WALLET_EXCHANGE_PROTOCOL_VERSION,
+ },
+ "exchange protocol version not compatible with wallet",
+ );
+ }
+
+ const exchangeKeysJsonUnchecked = await readSuccessResponseJsonOrThrow(
+ resp,
+ codecForExchangeKeysJson(),
+ );
+
+ if (exchangeKeysJsonUnchecked.denominations.length === 0) {
+ throw TalerError.fromDetail(
+ TalerErrorCode.WALLET_EXCHANGE_DENOMINATIONS_INSUFFICIENT,
+ {
+ exchangeBaseUrl: baseUrl,
+ },
+ "exchange doesn't offer any denominations",
+ );
+ }
+
+ const currency = exchangeKeysJsonUnchecked.currency;
+
+ const currentDenominations: DenominationRecord[] = [];
+
+ for (const denomGroup of exchangeKeysJsonUnchecked.denominations) {
+ switch (denomGroup.cipher) {
+ case "RSA":
+ case "RSA+age_restricted": {
+ let ageMask = 0;
+ if (denomGroup.cipher === "RSA+age_restricted") {
+ ageMask = denomGroup.age_mask;
+ }
+ for (const denomIn of denomGroup.denoms) {
+ const denomPub: DenominationPubKey = {
+ age_mask: ageMask,
+ cipher: DenomKeyType.Rsa,
+ rsa_public_key: denomIn.rsa_pub,
+ };
+ const denomPubHash = encodeCrock(hashDenomPub(denomPub));
+ const value = Amounts.parseOrThrow(denomGroup.value);
+ const rec: DenominationRecord = {
+ denomPub,
+ denomPubHash,
+ exchangeBaseUrl: baseUrl,
+ exchangeMasterPub: exchangeKeysJsonUnchecked.master_public_key,
+ isOffered: true,
+ isRevoked: false,
+ isLost: denomIn.lost ?? false,
+ value: Amounts.stringify(value),
+ currency: value.currency,
+ stampExpireDeposit: timestampProtocolToDb(
+ denomIn.stamp_expire_deposit,
+ ),
+ stampExpireLegal: timestampProtocolToDb(denomIn.stamp_expire_legal),
+ stampExpireWithdraw: timestampProtocolToDb(
+ denomIn.stamp_expire_withdraw,
+ ),
+ stampStart: timestampProtocolToDb(denomIn.stamp_start),
+ verificationStatus: DenominationVerificationStatus.Unverified,
+ masterSig: denomIn.master_sig,
+ fees: {
+ feeDeposit: Amounts.stringify(denomGroup.fee_deposit),
+ feeRefresh: Amounts.stringify(denomGroup.fee_refresh),
+ feeRefund: Amounts.stringify(denomGroup.fee_refund),
+ feeWithdraw: Amounts.stringify(denomGroup.fee_withdraw),
+ },
+ };
+ currentDenominations.push(rec);
+ }
+ break;
+ }
+ case "CS+age_restricted":
+ case "CS":
+ logger.warn("Clause-Schnorr denominations not supported");
+ continue;
+ default:
+ logger.warn(
+ `denomination type ${(denomGroup as any).cipher} not supported`,
+ );
+ continue;
+ }
+ }
+
+ return {
+ masterPublicKey: exchangeKeysJsonUnchecked.master_public_key,
+ currency,
+ baseUrl: exchangeKeysJsonUnchecked.base_url,
+ auditors: exchangeKeysJsonUnchecked.auditors,
+ currentDenominations,
+ protocolVersion: exchangeKeysJsonUnchecked.version,
+ signingKeys: exchangeKeysJsonUnchecked.signkeys,
+ reserveClosingDelay: exchangeKeysJsonUnchecked.reserve_closing_delay,
+ expiry: AbsoluteTime.toProtocolTimestamp(
+ getExpiry(resp, {
+ minDuration: Duration.fromSpec({ hours: 1 }),
+ }),
+ ),
+ recoup: exchangeKeysJsonUnchecked.recoup ?? [],
+ listIssueDate: exchangeKeysJsonUnchecked.list_issue_date,
+ globalFees: exchangeKeysJsonUnchecked.global_fees,
+ accounts: exchangeKeysJsonUnchecked.accounts,
+ wireFees: exchangeKeysJsonUnchecked.wire_fees,
+ };
+}
+
+async function downloadTosFromAcceptedFormat(
+ wex: WalletExecutionContext,
+ baseUrl: string,
+ timeout: Duration,
+ acceptedFormat?: string[],
+ acceptLanguage?: string,
+): Promise<ExchangeTosDownloadResult> {
+ let tosFound: ExchangeTosDownloadResult | undefined;
+ // Remove this when exchange supports multiple content-type in accept header
+ if (acceptedFormat)
+ for (const format of acceptedFormat) {
+ const resp = await downloadExchangeWithTermsOfService(
+ wex,
+ baseUrl,
+ wex.http,
+ timeout,
+ format,
+ acceptLanguage,
+ );
+ if (resp.tosContentType === format) {
+ tosFound = resp;
+ break;
+ }
+ }
+ if (tosFound !== undefined) {
+ return tosFound;
+ }
+ // If none of the specified format was found try text/plain
+ return await downloadExchangeWithTermsOfService(
+ wex,
+ baseUrl,
+ wex.http,
+ timeout,
+ "text/plain",
+ acceptLanguage,
+ );
+}
+
+/**
+ * Transition an exchange into an updating state.
+ *
+ * If the update is forced, the exchange is put into an updating state
+ * even if the old information should still be up to date.
+ *
+ * If the exchange entry doesn't exist,
+ * a new ephemeral entry is created.
+ */
+async function startUpdateExchangeEntry(
+ wex: WalletExecutionContext,
+ exchangeBaseUrl: string,
+ options: { forceUpdate?: boolean } = {},
+): Promise<void> {
+ logger.info(
+ `starting update of exchange entry ${exchangeBaseUrl}, forced=${
+ options.forceUpdate ?? false
+ }`,
+ );
+
+ const { notification } = await wex.db.runReadWriteTx(
+ { storeNames: ["exchanges", "exchangeDetails"] },
+ async (tx) => {
+ wex.ws.exchangeCache.clear();
+ return provideExchangeRecordInTx(wex.ws, tx, exchangeBaseUrl);
+ },
+ );
+
+ logger.trace("created exchange record");
+
+ if (notification) {
+ wex.ws.notify(notification);
+ }
+
+ const { oldExchangeState, newExchangeState, taskId } =
+ await wex.db.runReadWriteTx(
+ { storeNames: ["exchanges", "operationRetries"] },
+ async (tx) => {
+ const r = await tx.exchanges.get(exchangeBaseUrl);
+ if (!r) {
+ throw Error("exchange not found");
+ }
+ const oldExchangeState = getExchangeState(r);
+ switch (r.updateStatus) {
+ case ExchangeEntryDbUpdateStatus.UnavailableUpdate:
+ r.cachebreakNextUpdate = options.forceUpdate;
+ break;
+ case ExchangeEntryDbUpdateStatus.Suspended:
+ r.cachebreakNextUpdate = options.forceUpdate;
+ break;
+ case ExchangeEntryDbUpdateStatus.ReadyUpdate:
+ r.cachebreakNextUpdate = options.forceUpdate;
+ break;
+ case ExchangeEntryDbUpdateStatus.Ready: {
+ const nextUpdateTimestamp = AbsoluteTime.fromPreciseTimestamp(
+ timestampPreciseFromDb(r.nextUpdateStamp),
+ );
+ // Only update if entry is outdated or update is forced.
+ if (
+ options.forceUpdate ||
+ AbsoluteTime.isExpired(nextUpdateTimestamp)
+ ) {
+ r.updateStatus = ExchangeEntryDbUpdateStatus.ReadyUpdate;
+ r.cachebreakNextUpdate = options.forceUpdate;
+ }
+ break;
+ }
+ case ExchangeEntryDbUpdateStatus.Initial:
+ r.cachebreakNextUpdate = options.forceUpdate;
+ r.updateStatus = ExchangeEntryDbUpdateStatus.InitialUpdate;
+ break;
+ case ExchangeEntryDbUpdateStatus.InitialUpdate:
+ r.cachebreakNextUpdate = options.forceUpdate;
+ break;
+ }
+ wex.ws.exchangeCache.clear();
+ await tx.exchanges.put(r);
+ const newExchangeState = getExchangeState(r);
+ // Reset retries for updating the exchange entry.
+ const taskId = TaskIdentifiers.forExchangeUpdate(r);
+ await tx.operationRetries.delete(taskId);
+ return { oldExchangeState, newExchangeState, taskId };
+ },
+ );
+ wex.ws.notify({
+ type: NotificationType.ExchangeStateTransition,
+ exchangeBaseUrl,
+ newExchangeState: newExchangeState,
+ oldExchangeState: oldExchangeState,
+ });
+ await wex.taskScheduler.resetTaskRetries(taskId);
+}
+
+/**
+ * Basic information about an exchange in a ready state.
+ */
+export interface ReadyExchangeSummary {
+ exchangeBaseUrl: string;
+ currency: string;
+ masterPub: string;
+ tosStatus: ExchangeTosStatus;
+ tosAcceptedEtag: string | undefined;
+ tosCurrentEtag: string | undefined;
+ wireInfo: WireInfo;
+ protocolVersionRange: string;
+ tosAcceptedTimestamp: TalerPreciseTimestamp | undefined;
+ scopeInfo: ScopeInfo;
+}
+
+async function internalWaitReadyExchange(
+ wex: WalletExecutionContext,
+ canonUrl: string,
+ exchangeNotifFlag: AsyncFlag,
+ options: {
+ cancellationToken?: CancellationToken;
+ forceUpdate?: boolean;
+ expectedMasterPub?: string;
+ } = {},
+): Promise<ReadyExchangeSummary> {
+ const operationId = constructTaskIdentifier({
+ tag: PendingTaskType.ExchangeUpdate,
+ exchangeBaseUrl: canonUrl,
+ });
+ while (true) {
+ if (wex.cancellationToken.isCancelled) {
+ throw Error("cancelled");
+ }
+ logger.info(`waiting for ready exchange ${canonUrl}`);
+ const { exchange, exchangeDetails, retryInfo, scopeInfo } =
+ await wex.db.runReadOnlyTx(
+ {
+ storeNames: [
+ "exchanges",
+ "exchangeDetails",
+ "operationRetries",
+ "globalCurrencyAuditors",
+ "globalCurrencyExchanges",
+ ],
+ },
+ async (tx) => {
+ const exchange = await tx.exchanges.get(canonUrl);
+ const exchangeDetails = await getExchangeRecordsInternal(
+ tx,
+ canonUrl,
+ );
+ const retryInfo = await tx.operationRetries.get(operationId);
+ let scopeInfo: ScopeInfo | undefined = undefined;
+ if (exchange && exchangeDetails) {
+ scopeInfo = await internalGetExchangeScopeInfo(tx, exchangeDetails);
+ }
+ return { exchange, exchangeDetails, retryInfo, scopeInfo };
+ },
+ );
+
+ if (!exchange) {
+ throw Error("exchange entry does not exist anymore");
+ }
+
+ let ready = false;
+
+ switch (exchange.updateStatus) {
+ case ExchangeEntryDbUpdateStatus.Ready:
+ ready = true;
+ break;
+ case ExchangeEntryDbUpdateStatus.ReadyUpdate:
+ // If the update is forced,
+ // we wait until we're in a full "ready" state,
+ // as we're not happy with the stale information.
+ if (!options.forceUpdate) {
+ ready = true;
+ }
+ break;
+ case ExchangeEntryDbUpdateStatus.UnavailableUpdate:
+ throw TalerError.fromDetail(
+ TalerErrorCode.WALLET_EXCHANGE_UNAVAILABLE,
+ {
+ exchangeBaseUrl: canonUrl,
+ innerError: retryInfo?.lastError,
+ },
+ );
+ default: {
+ if (retryInfo) {
+ throw TalerError.fromDetail(
+ TalerErrorCode.WALLET_EXCHANGE_UNAVAILABLE,
+ {
+ exchangeBaseUrl: canonUrl,
+ innerError: retryInfo?.lastError,
+ },
+ );
+ }
+ }
+ }
+
+ if (!ready) {
+ logger.info("waiting for exchange update notification");
+ await exchangeNotifFlag.wait();
+ logger.info("done waiting for exchange update notification");
+ exchangeNotifFlag.reset();
+ continue;
+ }
+
+ if (!exchangeDetails) {
+ throw Error("invariant failed");
+ }
+
+ if (!scopeInfo) {
+ throw Error("invariant failed");
+ }
+
+ const res: ReadyExchangeSummary = {
+ currency: exchangeDetails.currency,
+ exchangeBaseUrl: canonUrl,
+ masterPub: exchangeDetails.masterPublicKey,
+ tosStatus: getExchangeTosStatusFromRecord(exchange),
+ tosAcceptedEtag: exchange.tosAcceptedEtag,
+ wireInfo: exchangeDetails.wireInfo,
+ protocolVersionRange: exchangeDetails.protocolVersionRange,
+ tosCurrentEtag: exchange.tosCurrentEtag,
+ tosAcceptedTimestamp: timestampOptionalPreciseFromDb(
+ exchange.tosAcceptedTimestamp,
+ ),
+ scopeInfo,
+ };
+
+ if (options.expectedMasterPub) {
+ if (res.masterPub !== options.expectedMasterPub) {
+ throw Error(
+ "public key of the exchange does not match expected public key",
+ );
+ }
+ }
+ return res;
+ }
+}
+
+/**
+ * Ensure that a fresh exchange entry exists for the given
+ * exchange base URL.
+ *
+ * The cancellation token can be used to abort waiting for the
+ * updated exchange entry.
+ *
+ * If an exchange entry for the database doesn't exist in the
+ * DB, it will be added ephemerally.
+ *
+ * If the expectedMasterPub is given and does not match the actual
+ * master pub, an exception will be thrown. However, the exchange
+ * will still have been added as an ephemeral exchange entry.
+ */
+export async function fetchFreshExchange(
+ wex: WalletExecutionContext,
+ baseUrl: string,
+ options: {
+ cancellationToken?: CancellationToken;
+ forceUpdate?: boolean;
+ expectedMasterPub?: string;
+ } = {},
+): Promise<ReadyExchangeSummary> {
+ if (!options.forceUpdate) {
+ const cachedResp = wex.ws.exchangeCache.get(baseUrl);
+ if (cachedResp) {
+ return cachedResp;
+ }
+ } else {
+ wex.ws.exchangeCache.clear();
+ }
+
+ await wex.taskScheduler.ensureRunning();
+
+ await startUpdateExchangeEntry(wex, baseUrl, {
+ forceUpdate: options.forceUpdate,
+ });
+
+ const resp = await waitReadyExchange(wex, baseUrl, options);
+ wex.ws.exchangeCache.put(baseUrl, resp);
+ return resp;
+}
+
+async function waitReadyExchange(
+ wex: WalletExecutionContext,
+ canonUrl: string,
+ options: {
+ forceUpdate?: boolean;
+ expectedMasterPub?: string;
+ } = {},
+): Promise<ReadyExchangeSummary> {
+ logger.trace(`waiting for exchange ${canonUrl} to become ready`);
+ // FIXME: We should use Symbol.dispose magic here for cleanup!
+
+ const exchangeNotifFlag = new AsyncFlag();
+ // Raise exchangeNotifFlag whenever we get a notification
+ // about our exchange.
+ const cancelNotif = wex.ws.addNotificationListener((notif) => {
+ if (
+ notif.type === NotificationType.ExchangeStateTransition &&
+ notif.exchangeBaseUrl === canonUrl
+ ) {
+ logger.info(`raising update notification: ${j2s(notif)}`);
+ exchangeNotifFlag.raise();
+ }
+ });
+
+ const unregisterOnCancelled = wex.cancellationToken.onCancelled(() => {
+ cancelNotif();
+ exchangeNotifFlag.raise();
+ });
+
+ try {
+ const res = await internalWaitReadyExchange(
+ wex,
+ canonUrl,
+ exchangeNotifFlag,
+ options,
+ );
+ logger.info("done waiting for ready exchange");
+ return res;
+ } finally {
+ unregisterOnCancelled();
+ cancelNotif();
+ }
+}
+
+function checkPeerPaymentsDisabled(
+ keysInfo: ExchangeKeysDownloadResult,
+): boolean {
+ const now = AbsoluteTime.now();
+ for (let gf of keysInfo.globalFees) {
+ const isActive = AbsoluteTime.isBetween(
+ now,
+ AbsoluteTime.fromProtocolTimestamp(gf.start_date),
+ AbsoluteTime.fromProtocolTimestamp(gf.end_date),
+ );
+ if (!isActive) {
+ continue;
+ }
+ return false;
+ }
+ // No global fees, we can't do p2p payments!
+ return true;
+}
+
+function checkNoFees(keysInfo: ExchangeKeysDownloadResult): boolean {
+ for (const gf of keysInfo.globalFees) {
+ if (!Amounts.isZero(gf.account_fee)) {
+ return false;
+ }
+ if (!Amounts.isZero(gf.history_fee)) {
+ return false;
+ }
+ if (!Amounts.isZero(gf.purse_fee)) {
+ return false;
+ }
+ }
+ for (const denom of keysInfo.currentDenominations) {
+ if (!Amounts.isZero(denom.fees.feeWithdraw)) {
+ return false;
+ }
+ if (!Amounts.isZero(denom.fees.feeDeposit)) {
+ return false;
+ }
+ if (!Amounts.isZero(denom.fees.feeRefund)) {
+ return false;
+ }
+ if (!Amounts.isZero(denom.fees.feeRefresh)) {
+ return false;
+ }
+ }
+ for (const wft of Object.values(keysInfo.wireFees)) {
+ for (const wf of wft) {
+ if (!Amounts.isZero(wf.wire_fee)) {
+ return false;
+ }
+ }
+ }
+ return true;
+}
+
+/**
+ * Update an exchange entry in the wallet's database
+ * by fetching the /keys and /wire information.
+ * Optionally link the reserve entry to the new or existing
+ * exchange entry in then DB.
+ */
+export async function updateExchangeFromUrlHandler(
+ wex: WalletExecutionContext,
+ exchangeBaseUrl: string,
+): Promise<TaskRunResult> {
+ logger.trace(`updating exchange info for ${exchangeBaseUrl}`);
+
+ const oldExchangeRec = await wex.db.runReadOnlyTx(
+ { storeNames: ["exchanges"] },
+ async (tx) => {
+ return tx.exchanges.get(exchangeBaseUrl);
+ },
+ );
+
+ if (!oldExchangeRec) {
+ logger.info(`not updating exchange ${exchangeBaseUrl}, no record in DB`);
+ return TaskRunResult.finished();
+ }
+
+ let updateRequestedExplicitly = false;
+
+ switch (oldExchangeRec.updateStatus) {
+ case ExchangeEntryDbUpdateStatus.Suspended:
+ logger.info(`not updating exchange in status "suspended"`);
+ return TaskRunResult.finished();
+ case ExchangeEntryDbUpdateStatus.Initial:
+ logger.info(`not updating exchange in status "initial"`);
+ return TaskRunResult.finished();
+ case ExchangeEntryDbUpdateStatus.InitialUpdate:
+ case ExchangeEntryDbUpdateStatus.ReadyUpdate:
+ updateRequestedExplicitly = true;
+ break;
+ case ExchangeEntryDbUpdateStatus.UnavailableUpdate:
+ // Only retry when scheduled to respect backoff
+ break;
+ case ExchangeEntryDbUpdateStatus.Ready:
+ break;
+ default:
+ assertUnreachable(oldExchangeRec.updateStatus);
+ }
+
+ let refreshCheckNecessary = true;
+
+ if (!updateRequestedExplicitly) {
+ // If the update wasn't requested explicitly,
+ // check if we really need to update.
+
+ let nextUpdateStamp = timestampAbsoluteFromDb(
+ oldExchangeRec.nextUpdateStamp,
+ );
+
+ let nextRefreshCheckStamp = timestampAbsoluteFromDb(
+ oldExchangeRec.nextRefreshCheckStamp,
+ );
+
+ let updateNecessary = true;
+
+ if (
+ !AbsoluteTime.isNever(nextUpdateStamp) &&
+ !AbsoluteTime.isExpired(nextUpdateStamp)
+ ) {
+ logger.info(
+ `exchange update for ${exchangeBaseUrl} not necessary, scheduled for ${AbsoluteTime.toIsoString(
+ nextUpdateStamp,
+ )}`,
+ );
+ updateNecessary = false;
+ }
+
+ if (
+ !AbsoluteTime.isNever(nextRefreshCheckStamp) &&
+ !AbsoluteTime.isExpired(nextRefreshCheckStamp)
+ ) {
+ logger.info(
+ `exchange refresh check for ${exchangeBaseUrl} not necessary, scheduled for ${AbsoluteTime.toIsoString(
+ nextRefreshCheckStamp,
+ )}`,
+ );
+ refreshCheckNecessary = false;
+ }
+
+ if (!(updateNecessary || refreshCheckNecessary)) {
+ logger.trace("update not necessary, running again later");
+ return TaskRunResult.runAgainAt(
+ AbsoluteTime.min(nextUpdateStamp, nextRefreshCheckStamp),
+ );
+ }
+ }
+
+ // When doing the auto-refresh check, we always update
+ // the key info before that.
+
+ logger.trace("updating exchange /keys info");
+
+ const timeout = getExchangeRequestTimeout();
+
+ const keysInfo = await downloadExchangeKeysInfo(
+ exchangeBaseUrl,
+ wex.http,
+ timeout,
+ wex.cancellationToken,
+ oldExchangeRec.cachebreakNextUpdate ?? false,
+ );
+
+ logger.trace("validating exchange wire info");
+
+ const version = LibtoolVersion.parseVersion(keysInfo.protocolVersion);
+ if (!version) {
+ // Should have been validated earlier.
+ throw Error("unexpected invalid version");
+ }
+
+ const wireInfo = await validateWireInfo(
+ wex,
+ version.current,
+ keysInfo,
+ keysInfo.masterPublicKey,
+ );
+
+ const globalFees = await validateGlobalFees(
+ wex,
+ keysInfo.globalFees,
+ keysInfo.masterPublicKey,
+ );
+
+ if (keysInfo.baseUrl != exchangeBaseUrl) {
+ logger.warn("exchange base URL mismatch");
+ const errorDetail: TalerErrorDetail = makeErrorDetail(
+ TalerErrorCode.WALLET_EXCHANGE_BASE_URL_MISMATCH,
+ {
+ urlWallet: exchangeBaseUrl,
+ urlExchange: keysInfo.baseUrl,
+ },
+ );
+ return {
+ type: TaskRunResultType.Error,
+ errorDetail,
+ };
+ }
+
+ logger.trace("finished validating exchange /wire info");
+
+ // We download the text/plain version here,
+ // because that one needs to exist, and we
+ // will get the current etag from the response.
+ const tosDownload = await downloadTosFromAcceptedFormat(
+ wex,
+ exchangeBaseUrl,
+ timeout,
+ ["text/plain"],
+ );
+
+ logger.trace("updating exchange info in database");
+
+ let ageMask = 0;
+ for (const x of keysInfo.currentDenominations) {
+ if (
+ isWithdrawableDenom(x, wex.ws.config.testing.denomselAllowLate) &&
+ x.denomPub.age_mask != 0
+ ) {
+ ageMask = x.denomPub.age_mask;
+ break;
+ }
+ }
+ let noFees = checkNoFees(keysInfo);
+ let peerPaymentsDisabled = checkPeerPaymentsDisabled(keysInfo);
+
+ const updated = await wex.db.runReadWriteTx(
+ {
+ storeNames: [
+ "exchanges",
+ "exchangeDetails",
+ "exchangeSignKeys",
+ "denominations",
+ "coins",
+ "refreshGroups",
+ "recoupGroups",
+ "coinAvailability",
+ "denomLossEvents",
+ ],
+ },
+ async (tx) => {
+ const r = await tx.exchanges.get(exchangeBaseUrl);
+ if (!r) {
+ logger.warn(`exchange ${exchangeBaseUrl} no longer present`);
+ return;
+ }
+
+ wex.ws.refreshCostCache.clear();
+ wex.ws.exchangeCache.clear();
+ wex.ws.denomInfoCache.clear();
+
+ const oldExchangeState = getExchangeState(r);
+ const existingDetails = await getExchangeRecordsInternal(tx, r.baseUrl);
+ let detailsPointerChanged = false;
+ if (!existingDetails) {
+ detailsPointerChanged = true;
+ }
+ let detailsIncompatible = false;
+ if (existingDetails) {
+ if (existingDetails.masterPublicKey !== keysInfo.masterPublicKey) {
+ detailsIncompatible = true;
+ detailsPointerChanged = true;
+ }
+ if (existingDetails.currency !== keysInfo.currency) {
+ detailsIncompatible = true;
+ detailsPointerChanged = true;
+ }
+ // FIXME: We need to do some consistency checks!
+ }
+ if (detailsIncompatible) {
+ logger.warn(
+ `exchange ${r.baseUrl} has incompatible data in /keys, not updating`,
+ );
+ // We don't support this gracefully right now.
+ // See https://bugs.taler.net/n/8576
+ r.updateStatus = ExchangeEntryDbUpdateStatus.UnavailableUpdate;
+ r.updateRetryCounter = (r.updateRetryCounter ?? 0) + 1;
+ r.nextUpdateStamp = computeDbBackoff(r.updateRetryCounter);
+ r.nextRefreshCheckStamp = timestampPreciseToDb(
+ AbsoluteTime.toPreciseTimestamp(AbsoluteTime.never()),
+ );
+ r.cachebreakNextUpdate = true;
+ await tx.exchanges.put(r);
+ return {
+ oldExchangeState,
+ newExchangeState: getExchangeState(r),
+ };
+ }
+ r.updateRetryCounter = 0;
+ const newDetails: ExchangeDetailsRecord = {
+ auditors: keysInfo.auditors,
+ currency: keysInfo.currency,
+ masterPublicKey: keysInfo.masterPublicKey,
+ protocolVersionRange: keysInfo.protocolVersion,
+ reserveClosingDelay: keysInfo.reserveClosingDelay,
+ globalFees,
+ exchangeBaseUrl: r.baseUrl,
+ wireInfo,
+ ageMask,
+ };
+ r.noFees = noFees;
+ r.peerPaymentsDisabled = peerPaymentsDisabled;
+ r.tosCurrentEtag = tosDownload.tosEtag;
+ if (existingDetails?.rowId) {
+ newDetails.rowId = existingDetails.rowId;
+ }
+ r.lastUpdate = timestampPreciseToDb(TalerPreciseTimestamp.now());
+ r.nextUpdateStamp = timestampPreciseToDb(
+ AbsoluteTime.toPreciseTimestamp(
+ AbsoluteTime.fromProtocolTimestamp(keysInfo.expiry),
+ ),
+ );
+ // New denominations might be available.
+ r.nextRefreshCheckStamp = timestampPreciseToDb(
+ TalerPreciseTimestamp.now(),
+ );
+ if (detailsPointerChanged) {
+ r.detailsPointer = {
+ currency: newDetails.currency,
+ masterPublicKey: newDetails.masterPublicKey,
+ updateClock: timestampPreciseToDb(TalerPreciseTimestamp.now()),
+ };
+ }
+
+ r.updateStatus = ExchangeEntryDbUpdateStatus.Ready;
+ r.cachebreakNextUpdate = false;
+ await tx.exchanges.put(r);
+ logger.info(`putting new exchange details in DB: ${j2s(newDetails)}`);
+ const drRowId = await tx.exchangeDetails.put(newDetails);
+ checkDbInvariant(typeof drRowId.key === "number");
+
+ for (const sk of keysInfo.signingKeys) {
+ // FIXME: validate signing keys before inserting them
+ await tx.exchangeSignKeys.put({
+ exchangeDetailsRowId: drRowId.key,
+ masterSig: sk.master_sig,
+ signkeyPub: sk.key,
+ stampEnd: timestampProtocolToDb(sk.stamp_end),
+ stampExpire: timestampProtocolToDb(sk.stamp_expire),
+ stampStart: timestampProtocolToDb(sk.stamp_start),
+ });
+ }
+
+ // In the future: Filter out old denominations by index
+ const allOldDenoms =
+ await tx.denominations.indexes.byExchangeBaseUrl.getAll(
+ exchangeBaseUrl,
+ );
+ const oldDenomByDph = new Map<string, DenominationRecord>();
+ for (const denom of allOldDenoms) {
+ oldDenomByDph.set(denom.denomPubHash, denom);
+ }
+
+ logger.trace("updating denominations in database");
+ const currentDenomSet = new Set<string>(
+ keysInfo.currentDenominations.map((x) => x.denomPubHash),
+ );
+
+ for (const currentDenom of keysInfo.currentDenominations) {
+ const oldDenom = oldDenomByDph.get(currentDenom.denomPubHash);
+ if (oldDenom) {
+ // FIXME: Do consistency check, report to auditor if necessary.
+ // See https://bugs.taler.net/n/8594
+
+ // Mark lost denominations as lost.
+ if (currentDenom.isLost && !oldDenom.isLost) {
+ logger.warn(
+ `marking denomination ${currentDenom.denomPubHash} of ${exchangeBaseUrl} as lost`,
+ );
+ oldDenom.isLost = true;
+ await tx.denominations.put(currentDenom);
+ }
+ } else {
+ await tx.denominations.put(currentDenom);
+ }
+ }
+
+ // Update list issue date for all denominations,
+ // and mark non-offered denominations as such.
+ for (const x of allOldDenoms) {
+ if (!currentDenomSet.has(x.denomPubHash)) {
+ // FIXME: Here, an auditor report should be created, unless
+ // the denomination is really legally expired.
+ if (x.isOffered) {
+ x.isOffered = false;
+ logger.info(
+ `setting denomination ${x.denomPubHash} to offered=false`,
+ );
+ }
+ } else {
+ if (!x.isOffered) {
+ x.isOffered = true;
+ logger.info(
+ `setting denomination ${x.denomPubHash} to offered=true`,
+ );
+ }
+ }
+ await tx.denominations.put(x);
+ }
+
+ logger.trace("done updating denominations in database");
+
+ const denomLossResult = await handleDenomLoss(
+ wex,
+ tx,
+ newDetails.currency,
+ exchangeBaseUrl,
+ );
+
+ await handleRecoup(wex, tx, exchangeBaseUrl, keysInfo.recoup);
+
+ const newExchangeState = getExchangeState(r);
+
+ return {
+ exchange: r,
+ exchangeDetails: newDetails,
+ oldExchangeState,
+ newExchangeState,
+ denomLossResult,
+ };
+ },
+ );
+
+ if (!updated) {
+ throw Error("something went wrong with updating the exchange");
+ }
+
+ if (updated.denomLossResult) {
+ for (const notif of updated.denomLossResult.notifications) {
+ wex.ws.notify(notif);
+ }
+ }
+
+ logger.trace("done updating exchange info in database");
+
+ logger.trace(`doing auto-refresh check for '${exchangeBaseUrl}'`);
+
+ let minCheckThreshold = AbsoluteTime.addDuration(
+ AbsoluteTime.now(),
+ Duration.fromSpec({ days: 1 }),
+ );
+
+ if (refreshCheckNecessary) {
+ // Do auto-refresh.
+ await wex.db.runReadWriteTx(
+ {
+ storeNames: [
+ "coins",
+ "denominations",
+ "coinAvailability",
+ "refreshGroups",
+ "refreshSessions",
+ "exchanges",
+ ],
+ },
+ async (tx) => {
+ const exchange = await tx.exchanges.get(exchangeBaseUrl);
+ if (!exchange || !exchange.detailsPointer) {
+ return;
+ }
+ const coins = await tx.coins.indexes.byBaseUrl
+ .iter(exchangeBaseUrl)
+ .toArray();
+ const refreshCoins: CoinRefreshRequest[] = [];
+ for (const coin of coins) {
+ if (coin.status !== CoinStatus.Fresh) {
+ continue;
+ }
+ const denom = await tx.denominations.get([
+ exchangeBaseUrl,
+ coin.denomPubHash,
+ ]);
+ if (!denom) {
+ logger.warn("denomination not in database");
+ continue;
+ }
+ const executeThreshold =
+ getAutoRefreshExecuteThresholdForDenom(denom);
+ if (AbsoluteTime.isExpired(executeThreshold)) {
+ refreshCoins.push({
+ coinPub: coin.coinPub,
+ amount: denom.value,
+ });
+ } else {
+ const checkThreshold = getAutoRefreshCheckThreshold(denom);
+ minCheckThreshold = AbsoluteTime.min(
+ minCheckThreshold,
+ checkThreshold,
+ );
+ }
+ }
+ if (refreshCoins.length > 0) {
+ const res = await createRefreshGroup(
+ wex,
+ tx,
+ exchange.detailsPointer?.currency,
+ refreshCoins,
+ RefreshReason.Scheduled,
+ undefined,
+ );
+ logger.trace(
+ `created refresh group for auto-refresh (${res.refreshGroupId})`,
+ );
+ }
+ logger.trace(
+ `next refresh check at ${AbsoluteTime.toIsoString(
+ minCheckThreshold,
+ )}`,
+ );
+ exchange.nextRefreshCheckStamp = timestampPreciseToDb(
+ AbsoluteTime.toPreciseTimestamp(minCheckThreshold),
+ );
+ wex.ws.exchangeCache.clear();
+ await tx.exchanges.put(exchange);
+ },
+ );
+ }
+
+ wex.ws.notify({
+ type: NotificationType.ExchangeStateTransition,
+ exchangeBaseUrl,
+ newExchangeState: updated.newExchangeState,
+ oldExchangeState: updated.oldExchangeState,
+ });
+
+ // Next invocation will cause the task to be run again
+ // at the necessary time.
+ return TaskRunResult.progress();
+}
+
+interface DenomLossResult {
+ notifications: WalletNotification[];
+}
+
+async function handleDenomLoss(
+ wex: WalletExecutionContext,
+ tx: WalletDbReadWriteTransaction<
+ ["coinAvailability", "denominations", "denomLossEvents", "coins"]
+ >,
+ currency: string,
+ exchangeBaseUrl: string,
+): Promise<DenomLossResult> {
+ const coinAvailabilityRecs =
+ await tx.coinAvailability.indexes.byExchangeBaseUrl.getAll(exchangeBaseUrl);
+ const denomsVanished: string[] = [];
+ const denomsUnoffered: string[] = [];
+ const denomsExpired: string[] = [];
+ let amountVanished = Amount.zeroOfCurrency(currency);
+ let amountExpired = Amount.zeroOfCurrency(currency);
+ let amountUnoffered = Amount.zeroOfCurrency(currency);
+
+ const result: DenomLossResult = {
+ notifications: [],
+ };
+
+ for (const coinAv of coinAvailabilityRecs) {
+ if (coinAv.freshCoinCount <= 0) {
+ continue;
+ }
+ const n = coinAv.freshCoinCount;
+ const denom = await tx.denominations.get([
+ coinAv.exchangeBaseUrl,
+ coinAv.denomPubHash,
+ ]);
+ const timestampExpireDeposit = !denom
+ ? undefined
+ : timestampAbsoluteFromDb(denom.stampExpireDeposit);
+ if (!denom) {
+ // Remove availability
+ coinAv.freshCoinCount = 0;
+ coinAv.visibleCoinCount = 0;
+ await tx.coinAvailability.put(coinAv);
+ denomsVanished.push(coinAv.denomPubHash);
+ const total = Amount.from(coinAv.value).mult(n);
+ amountVanished = amountVanished.add(total);
+ } else if (!denom.isOffered) {
+ // Remove availability
+ coinAv.freshCoinCount = 0;
+ coinAv.visibleCoinCount = 0;
+ await tx.coinAvailability.put(coinAv);
+ denomsUnoffered.push(coinAv.denomPubHash);
+ const total = Amount.from(coinAv.value).mult(n);
+ amountUnoffered = amountUnoffered.add(total);
+ } else if (
+ timestampExpireDeposit &&
+ AbsoluteTime.isExpired(timestampExpireDeposit)
+ ) {
+ // Remove availability
+ coinAv.freshCoinCount = 0;
+ coinAv.visibleCoinCount = 0;
+ await tx.coinAvailability.put(coinAv);
+ denomsExpired.push(coinAv.denomPubHash);
+ const total = Amount.from(coinAv.value).mult(n);
+ amountExpired = amountExpired.add(total);
+ } else {
+ // Denomination is still fine!
+ continue;
+ }
+
+ logger.warn(`denomination ${coinAv.denomPubHash} is a loss`);
+
+ const coins = await tx.coins.indexes.byDenomPubHash.getAll(
+ coinAv.denomPubHash,
+ );
+ for (const coin of coins) {
+ switch (coin.status) {
+ case CoinStatus.Fresh:
+ case CoinStatus.FreshSuspended: {
+ coin.status = CoinStatus.DenomLoss;
+ await tx.coins.put(coin);
+ break;
+ }
+ }
+ }
+ }
+
+ if (denomsVanished.length > 0) {
+ const denomLossEventId = encodeCrock(getRandomBytes(32));
+ await tx.denomLossEvents.add({
+ denomLossEventId,
+ amount: amountVanished.toString(),
+ currency,
+ exchangeBaseUrl,
+ denomPubHashes: denomsVanished,
+ eventType: DenomLossEventType.DenomVanished,
+ status: DenomLossStatus.Done,
+ timestampCreated: timestampPreciseToDb(TalerPreciseTimestamp.now()),
+ });
+ const transactionId = constructTransactionIdentifier({
+ tag: TransactionType.DenomLoss,
+ denomLossEventId,
+ });
+ result.notifications.push({
+ type: NotificationType.TransactionStateTransition,
+ transactionId,
+ oldTxState: {
+ major: TransactionMajorState.None,
+ },
+ newTxState: {
+ major: TransactionMajorState.Done,
+ },
+ });
+ result.notifications.push({
+ type: NotificationType.BalanceChange,
+ hintTransactionId: transactionId,
+ });
+ }
+
+ if (denomsUnoffered.length > 0) {
+ const denomLossEventId = encodeCrock(getRandomBytes(32));
+ await tx.denomLossEvents.add({
+ denomLossEventId,
+ amount: amountUnoffered.toString(),
+ currency,
+ exchangeBaseUrl,
+ denomPubHashes: denomsUnoffered,
+ eventType: DenomLossEventType.DenomUnoffered,
+ status: DenomLossStatus.Done,
+ timestampCreated: timestampPreciseToDb(TalerPreciseTimestamp.now()),
+ });
+ const transactionId = constructTransactionIdentifier({
+ tag: TransactionType.DenomLoss,
+ denomLossEventId,
+ });
+ result.notifications.push({
+ type: NotificationType.TransactionStateTransition,
+ transactionId,
+ oldTxState: {
+ major: TransactionMajorState.None,
+ },
+ newTxState: {
+ major: TransactionMajorState.Done,
+ },
+ });
+ result.notifications.push({
+ type: NotificationType.BalanceChange,
+ hintTransactionId: transactionId,
+ });
+ }
+
+ if (denomsExpired.length > 0) {
+ const denomLossEventId = encodeCrock(getRandomBytes(32));
+ await tx.denomLossEvents.add({
+ denomLossEventId,
+ amount: amountExpired.toString(),
+ currency,
+ exchangeBaseUrl,
+ denomPubHashes: denomsUnoffered,
+ eventType: DenomLossEventType.DenomExpired,
+ status: DenomLossStatus.Done,
+ timestampCreated: timestampPreciseToDb(TalerPreciseTimestamp.now()),
+ });
+ const transactionId = constructTransactionIdentifier({
+ tag: TransactionType.DenomLoss,
+ denomLossEventId,
+ });
+ result.notifications.push({
+ type: NotificationType.TransactionStateTransition,
+ transactionId,
+ oldTxState: {
+ major: TransactionMajorState.None,
+ },
+ newTxState: {
+ major: TransactionMajorState.Done,
+ },
+ });
+ result.notifications.push({
+ type: NotificationType.BalanceChange,
+ hintTransactionId: transactionId,
+ });
+ }
+
+ return result;
+}
+
+export function computeDenomLossTransactionStatus(
+ rec: DenomLossEventRecord,
+): TransactionState {
+ switch (rec.status) {
+ case DenomLossStatus.Aborted:
+ return {
+ major: TransactionMajorState.Aborted,
+ };
+ case DenomLossStatus.Done:
+ return {
+ major: TransactionMajorState.Done,
+ };
+ }
+}
+
+export class DenomLossTransactionContext implements TransactionContext {
+ get taskId(): TaskIdStr | undefined {
+ return undefined;
+ }
+ transactionId: TransactionIdStr;
+
+ abortTransaction(): Promise<void> {
+ throw new Error("Method not implemented.");
+ }
+ suspendTransaction(): Promise<void> {
+ throw new Error("Method not implemented.");
+ }
+ resumeTransaction(): Promise<void> {
+ throw new Error("Method not implemented.");
+ }
+ failTransaction(): Promise<void> {
+ throw new Error("Method not implemented.");
+ }
+ async deleteTransaction(): Promise<void> {
+ const transitionInfo = await this.wex.db.runReadWriteTx(
+ { storeNames: ["denomLossEvents"] },
+ async (tx) => {
+ const rec = await tx.denomLossEvents.get(this.denomLossEventId);
+ if (rec) {
+ const oldTxState = computeDenomLossTransactionStatus(rec);
+ await tx.denomLossEvents.delete(this.denomLossEventId);
+ return {
+ oldTxState,
+ newTxState: {
+ major: TransactionMajorState.Deleted,
+ },
+ };
+ }
+ return undefined;
+ },
+ );
+ notifyTransition(this.wex, this.transactionId, transitionInfo);
+ }
+
+ constructor(
+ private wex: WalletExecutionContext,
+ public denomLossEventId: string,
+ ) {
+ this.transactionId = constructTransactionIdentifier({
+ tag: TransactionType.DenomLoss,
+ denomLossEventId,
+ });
+ }
+}
+
+async function handleRecoup(
+ wex: WalletExecutionContext,
+ tx: WalletDbReadWriteTransaction<
+ ["denominations", "coins", "recoupGroups", "refreshGroups"]
+ >,
+ exchangeBaseUrl: string,
+ recoup: Recoup[],
+): Promise<void> {
+ // Handle recoup
+ const recoupDenomList = recoup;
+ const newlyRevokedCoinPubs: string[] = [];
+ logger.trace("recoup list from exchange", recoupDenomList);
+ for (const recoupInfo of recoupDenomList) {
+ const oldDenom = await tx.denominations.get([
+ exchangeBaseUrl,
+ recoupInfo.h_denom_pub,
+ ]);
+ if (!oldDenom) {
+ // We never even knew about the revoked denomination, all good.
+ continue;
+ }
+ if (oldDenom.isRevoked) {
+ // We already marked the denomination as revoked,
+ // this implies we revoked all coins
+ logger.trace("denom already revoked");
+ continue;
+ }
+ logger.info("revoking denom", recoupInfo.h_denom_pub);
+ oldDenom.isRevoked = true;
+ await tx.denominations.put(oldDenom);
+ const affectedCoins = await tx.coins.indexes.byDenomPubHash.getAll(
+ recoupInfo.h_denom_pub,
+ );
+ for (const ac of affectedCoins) {
+ newlyRevokedCoinPubs.push(ac.coinPub);
+ }
+ }
+ if (newlyRevokedCoinPubs.length != 0) {
+ logger.info("recouping coins", newlyRevokedCoinPubs);
+ await createRecoupGroup(wex, tx, exchangeBaseUrl, newlyRevokedCoinPubs);
+ }
+}
+
+function getAutoRefreshExecuteThresholdForDenom(
+ d: DenominationRecord,
+): AbsoluteTime {
+ return getAutoRefreshExecuteThreshold({
+ stampExpireWithdraw: timestampProtocolFromDb(d.stampExpireWithdraw),
+ stampExpireDeposit: timestampProtocolFromDb(d.stampExpireDeposit),
+ });
+}
+
+/**
+ * Timestamp after which the wallet would do the next check for an auto-refresh.
+ */
+function getAutoRefreshCheckThreshold(d: DenominationRecord): AbsoluteTime {
+ const expireWithdraw = AbsoluteTime.fromProtocolTimestamp(
+ timestampProtocolFromDb(d.stampExpireWithdraw),
+ );
+ const expireDeposit = AbsoluteTime.fromProtocolTimestamp(
+ timestampProtocolFromDb(d.stampExpireDeposit),
+ );
+ const delta = AbsoluteTime.difference(expireWithdraw, expireDeposit);
+ const deltaDiv = durationMul(delta, 0.75);
+ return AbsoluteTime.addDuration(expireWithdraw, deltaDiv);
+}
+
+/**
+ * Find a payto:// URI of the exchange that is of one
+ * of the given target types.
+ *
+ * Throws if no matching account was found.
+ */
+export async function getExchangePaytoUri(
+ wex: WalletExecutionContext,
+ exchangeBaseUrl: string,
+ supportedTargetTypes: string[],
+): Promise<string> {
+ // We do the update here, since the exchange might not even exist
+ // yet in our database.
+ const details = await wex.db.runReadOnlyTx(
+ { storeNames: ["exchanges", "exchangeDetails"] },
+ async (tx) => {
+ return getExchangeRecordsInternal(tx, exchangeBaseUrl);
+ },
+ );
+ const accounts = details?.wireInfo.accounts ?? [];
+ for (const account of accounts) {
+ const res = parsePaytoUri(account.payto_uri);
+ if (!res) {
+ continue;
+ }
+ if (supportedTargetTypes.includes(res.targetType)) {
+ return account.payto_uri;
+ }
+ }
+ throw Error(
+ `no matching account found at exchange ${exchangeBaseUrl} for wire types ${j2s(
+ supportedTargetTypes,
+ )}`,
+ );
+}
+
+/**
+ * Get the exchange ToS in the requested format.
+ * Try to download in the accepted format not cached.
+ */
+export async function getExchangeTos(
+ wex: WalletExecutionContext,
+ exchangeBaseUrl: string,
+ acceptedFormat?: string[],
+ acceptLanguage?: string,
+): Promise<GetExchangeTosResult> {
+ const exch = await fetchFreshExchange(wex, exchangeBaseUrl);
+
+ const tosDownload = await downloadTosFromAcceptedFormat(
+ wex,
+ exchangeBaseUrl,
+ getExchangeRequestTimeout(),
+ acceptedFormat,
+ acceptLanguage,
+ );
+
+ await wex.db.runReadWriteTx({ storeNames: ["exchanges"] }, async (tx) => {
+ const updateExchangeEntry = await tx.exchanges.get(exchangeBaseUrl);
+ if (updateExchangeEntry) {
+ updateExchangeEntry.tosCurrentEtag = tosDownload.tosEtag;
+ wex.ws.exchangeCache.clear();
+ await tx.exchanges.put(updateExchangeEntry);
+ }
+ });
+
+ return {
+ acceptedEtag: exch.tosAcceptedEtag,
+ currentEtag: tosDownload.tosEtag,
+ content: tosDownload.tosText,
+ contentType: tosDownload.tosContentType,
+ contentLanguage: tosDownload.tosContentLanguage,
+ tosStatus: exch.tosStatus,
+ tosAvailableLanguages: tosDownload.tosAvailableLanguages,
+ };
+}
+
+/**
+ * Parsed information about an exchange,
+ * obtained by requesting /keys.
+ */
+export interface ExchangeInfo {
+ keys: ExchangeKeysDownloadResult;
+}
+
+/**
+ * Helper function to download the exchange /keys info.
+ *
+ * Only used for testing / dbless wallet.
+ */
+export async function downloadExchangeInfo(
+ exchangeBaseUrl: string,
+ http: HttpRequestLibrary,
+): Promise<ExchangeInfo> {
+ const keysInfo = await downloadExchangeKeysInfo(
+ exchangeBaseUrl,
+ http,
+ Duration.getForever(),
+ CancellationToken.CONTINUE,
+ false,
+ );
+ return {
+ keys: keysInfo,
+ };
+}
+
+/**
+ * List all exchange entries known to the wallet.
+ */
+export async function listExchanges(
+ wex: WalletExecutionContext,
+): Promise<ExchangesListResponse> {
+ const exchanges: ExchangeListItem[] = [];
+ await wex.db.runReadOnlyTx(
+ {
+ storeNames: [
+ "exchanges",
+ "operationRetries",
+ "exchangeDetails",
+ "globalCurrencyAuditors",
+ "globalCurrencyExchanges",
+ ],
+ },
+ async (tx) => {
+ const exchangeRecords = await tx.exchanges.iter().toArray();
+ for (const r of exchangeRecords) {
+ const taskId = constructTaskIdentifier({
+ tag: PendingTaskType.ExchangeUpdate,
+ exchangeBaseUrl: r.baseUrl,
+ });
+ const exchangeDetails = await getExchangeRecordsInternal(tx, r.baseUrl);
+ const opRetryRecord = await tx.operationRetries.get(taskId);
+ exchanges.push(
+ await makeExchangeListItem(
+ tx,
+ r,
+ exchangeDetails,
+ opRetryRecord?.lastError,
+ ),
+ );
+ }
+ },
+ );
+ return { exchanges };
+}
+
+/**
+ * Transition an exchange to the "used" entry state if necessary.
+ *
+ * Should be called whenever the exchange is actively used by the client (for withdrawals etc.).
+ *
+ * The caller should emit the returned notification iff the current transaction
+ * succeeded.
+ */
+export async function markExchangeUsed(
+ wex: WalletExecutionContext,
+ tx: WalletDbReadWriteTransaction<["exchanges"]>,
+ exchangeBaseUrl: string,
+): Promise<{ notif: WalletNotification | undefined }> {
+ logger.info(`marking exchange ${exchangeBaseUrl} as used`);
+ const exch = await tx.exchanges.get(exchangeBaseUrl);
+ if (!exch) {
+ return {
+ notif: undefined,
+ };
+ }
+ const oldExchangeState = getExchangeState(exch);
+ switch (exch.entryStatus) {
+ case ExchangeEntryDbRecordStatus.Ephemeral:
+ case ExchangeEntryDbRecordStatus.Preset: {
+ exch.entryStatus = ExchangeEntryDbRecordStatus.Used;
+ await tx.exchanges.put(exch);
+ const newExchangeState = getExchangeState(exch);
+ return {
+ notif: {
+ type: NotificationType.ExchangeStateTransition,
+ exchangeBaseUrl,
+ newExchangeState: newExchangeState,
+ oldExchangeState: oldExchangeState,
+ } satisfies WalletNotification,
+ };
+ }
+ default:
+ return {
+ notif: undefined,
+ };
+ }
+}
+
+/**
+ * Get detailed information about the exchange including a timeline
+ * for the fees charged by the exchange.
+ */
+export async function getExchangeDetailedInfo(
+ wex: WalletExecutionContext,
+ exchangeBaseurl: string,
+): Promise<ExchangeDetailedResponse> {
+ const exchange = await wex.db.runReadOnlyTx(
+ { storeNames: ["exchanges", "exchangeDetails", "denominations"] },
+ async (tx) => {
+ const ex = await tx.exchanges.get(exchangeBaseurl);
+ const dp = ex?.detailsPointer;
+ if (!dp) {
+ return;
+ }
+ const { currency } = dp;
+ const exchangeDetails = await getExchangeRecordsInternal(tx, ex.baseUrl);
+ if (!exchangeDetails) {
+ return;
+ }
+ const denominationRecords =
+ await tx.denominations.indexes.byExchangeBaseUrl.getAll(ex.baseUrl);
+
+ if (!denominationRecords) {
+ return;
+ }
+
+ const denominations: DenominationInfo[] = denominationRecords.map((x) =>
+ DenominationRecord.toDenomInfo(x),
+ );
+
+ return {
+ info: {
+ exchangeBaseUrl: ex.baseUrl,
+ currency,
+ paytoUris: exchangeDetails.wireInfo.accounts.map((x) => x.payto_uri),
+ auditors: exchangeDetails.auditors,
+ wireInfo: exchangeDetails.wireInfo,
+ globalFees: exchangeDetails.globalFees,
+ },
+ denominations,
+ };
+ },
+ );
+
+ if (!exchange) {
+ throw Error(`exchange with base url "${exchangeBaseurl}" not found`);
+ }
+
+ const denoms = exchange.denominations.map((d) => ({
+ ...d,
+ group: Amounts.stringifyValue(d.value),
+ }));
+ const denomFees: DenomOperationMap<FeeDescription[]> = {
+ deposit: createTimeline(
+ denoms,
+ "denomPubHash",
+ "stampStart",
+ "stampExpireDeposit",
+ "feeDeposit",
+ "group",
+ selectBestForOverlappingDenominations,
+ ),
+ refresh: createTimeline(
+ denoms,
+ "denomPubHash",
+ "stampStart",
+ "stampExpireWithdraw",
+ "feeRefresh",
+ "group",
+ selectBestForOverlappingDenominations,
+ ),
+ refund: createTimeline(
+ denoms,
+ "denomPubHash",
+ "stampStart",
+ "stampExpireWithdraw",
+ "feeRefund",
+ "group",
+ selectBestForOverlappingDenominations,
+ ),
+ withdraw: createTimeline(
+ denoms,
+ "denomPubHash",
+ "stampStart",
+ "stampExpireWithdraw",
+ "feeWithdraw",
+ "group",
+ selectBestForOverlappingDenominations,
+ ),
+ };
+
+ const transferFees = Object.entries(
+ exchange.info.wireInfo.feesForType,
+ ).reduce(
+ (prev, [wireType, infoForType]) => {
+ const feesByGroup = [
+ ...infoForType.map((w) => ({
+ ...w,
+ fee: Amounts.stringify(w.closingFee),
+ group: "closing",
+ })),
+ ...infoForType.map((w) => ({ ...w, fee: w.wireFee, group: "wire" })),
+ ];
+ prev[wireType] = createTimeline(
+ feesByGroup,
+ "sig",
+ "startStamp",
+ "endStamp",
+ "fee",
+ "group",
+ selectMinimumFee,
+ );
+ return prev;
+ },
+ {} as Record<string, FeeDescription[]>,
+ );
+
+ const globalFeesByGroup = [
+ ...exchange.info.globalFees.map((w) => ({
+ ...w,
+ fee: w.accountFee,
+ group: "account",
+ })),
+ ...exchange.info.globalFees.map((w) => ({
+ ...w,
+ fee: w.historyFee,
+ group: "history",
+ })),
+ ...exchange.info.globalFees.map((w) => ({
+ ...w,
+ fee: w.purseFee,
+ group: "purse",
+ })),
+ ];
+
+ const globalFees = createTimeline(
+ globalFeesByGroup,
+ "signature",
+ "startDate",
+ "endDate",
+ "fee",
+ "group",
+ selectMinimumFee,
+ );
+
+ return {
+ exchange: {
+ ...exchange.info,
+ denomFees,
+ transferFees,
+ globalFees,
+ },
+ };
+}
+
+async function internalGetExchangeResources(
+ wex: WalletExecutionContext,
+ tx: DbReadOnlyTransaction<
+ typeof WalletStoresV1,
+ ["exchanges", "coins", "withdrawalGroups"]
+ >,
+ exchangeBaseUrl: string,
+): Promise<GetExchangeResourcesResponse> {
+ let numWithdrawals = 0;
+ let numCoins = 0;
+ numCoins = await tx.coins.indexes.byBaseUrl.count(exchangeBaseUrl);
+ numWithdrawals =
+ await tx.withdrawalGroups.indexes.byExchangeBaseUrl.count(exchangeBaseUrl);
+ const total = numWithdrawals + numCoins;
+ return {
+ hasResources: total != 0,
+ };
+}
+
+/**
+ * Purge information in the database associated with the exchange.
+ *
+ * Deletes information specific to the exchange and withdrawals,
+ * but keeps some transactions (payments, p2p, refreshes) around.
+ */
+async function purgeExchange(
+ tx: WalletDbReadWriteTransaction<
+ [
+ "exchanges",
+ "exchangeDetails",
+ "transactions",
+ "coinAvailability",
+ "coins",
+ "denominations",
+ "exchangeSignKeys",
+ "withdrawalGroups",
+ "planchets",
+ ]
+ >,
+ exchangeBaseUrl: string,
+): Promise<void> {
+ const detRecs = await tx.exchangeDetails.indexes.byExchangeBaseUrl.getAll();
+ for (const r of detRecs) {
+ if (r.rowId == null) {
+ // Should never happen, as rowId is the primary key.
+ continue;
+ }
+ await tx.exchangeDetails.delete(r.rowId);
+ const signkeyRecs =
+ await tx.exchangeSignKeys.indexes.byExchangeDetailsRowId.getAll(r.rowId);
+ for (const rec of signkeyRecs) {
+ await tx.exchangeSignKeys.delete([r.rowId, rec.signkeyPub]);
+ }
+ }
+ // FIXME: Also remove records related to transactions?
+ await tx.exchanges.delete(exchangeBaseUrl);
+
+ {
+ const coinAvailabilityRecs =
+ await tx.coinAvailability.indexes.byExchangeBaseUrl.getAll(
+ exchangeBaseUrl,
+ );
+ for (const rec of coinAvailabilityRecs) {
+ await tx.coinAvailability.delete([
+ exchangeBaseUrl,
+ rec.denomPubHash,
+ rec.maxAge,
+ ]);
+ }
+ }
+
+ {
+ const coinRecs = await tx.coins.indexes.byBaseUrl.getAll(exchangeBaseUrl);
+ for (const rec of coinRecs) {
+ await tx.coins.delete(rec.coinPub);
+ }
+ }
+
+ {
+ const denomRecs =
+ await tx.denominations.indexes.byExchangeBaseUrl.getAll(exchangeBaseUrl);
+ for (const rec of denomRecs) {
+ await tx.denominations.delete(rec.denomPubHash);
+ }
+ }
+
+ {
+ const withdrawalGroupRecs =
+ await tx.withdrawalGroups.indexes.byExchangeBaseUrl.getAll(
+ exchangeBaseUrl,
+ );
+ for (const wg of withdrawalGroupRecs) {
+ await tx.withdrawalGroups.delete(wg.withdrawalGroupId);
+ const planchets = await tx.planchets.indexes.byGroup.getAll(
+ wg.withdrawalGroupId,
+ );
+ for (const p of planchets) {
+ await tx.planchets.delete(p.coinPub);
+ }
+ }
+ }
+}
+
+export async function deleteExchange(
+ wex: WalletExecutionContext,
+ req: DeleteExchangeRequest,
+): Promise<void> {
+ let inUse: boolean = false;
+ const exchangeBaseUrl = req.exchangeBaseUrl;
+ await wex.db.runReadWriteTx(
+ {
+ storeNames: [
+ "exchanges",
+ "exchangeDetails",
+ "transactions",
+ "coinAvailability",
+ "coins",
+ "denominations",
+ "exchangeSignKeys",
+ "withdrawalGroups",
+ "planchets",
+ ],
+ },
+ async (tx) => {
+ const exchangeRec = await tx.exchanges.get(exchangeBaseUrl);
+ if (!exchangeRec) {
+ // Nothing to delete!
+ logger.info("no exchange found to delete");
+ return;
+ }
+ const res = await internalGetExchangeResources(wex, tx, exchangeBaseUrl);
+ if (res.hasResources && !req.purge) {
+ inUse = true;
+ return;
+ }
+ await purgeExchange(tx, exchangeBaseUrl);
+ wex.ws.exchangeCache.clear();
+ },
+ );
+
+ if (inUse) {
+ throw TalerError.fromUncheckedDetail({
+ code: TalerErrorCode.WALLET_EXCHANGE_ENTRY_USED,
+ hint: "Exchange in use.",
+ });
+ }
+}
+
+export async function getExchangeResources(
+ wex: WalletExecutionContext,
+ exchangeBaseUrl: string,
+): Promise<GetExchangeResourcesResponse> {
+ // Withdrawals include internal withdrawals from peer transactions
+ const res = await wex.db.runReadOnlyTx(
+ { storeNames: ["exchanges", "withdrawalGroups", "coins"] },
+ async (tx) => {
+ const exchangeRecord = await tx.exchanges.get(exchangeBaseUrl);
+ if (!exchangeRecord) {
+ return undefined;
+ }
+ return internalGetExchangeResources(wex, tx, exchangeBaseUrl);
+ },
+ );
+ if (!res) {
+ throw Error("exchange not found");
+ }
+ return res;
+}