/* 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 */ /** * @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, CancellationToken, CoinRefreshRequest, CoinStatus, DeleteExchangeRequest, DenomKeyType, DenomLossEventType, DenomOperationMap, DenominationInfo, DenominationPubKey, Duration, EddsaPublicKeyString, ExchangeAuditor, ExchangeDetailedResponse, ExchangeGlobalFees, ExchangeListItem, ExchangeSignKeyJson, ExchangeTosStatus, ExchangeWireAccount, ExchangesListResponse, FeeDescription, GetExchangeEntryByUrlRequest, GetExchangeResourcesResponse, GetExchangeTosResult, GlobalFees, HttpStatusCode, 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, checkLogicInvariant, codecForExchangeKeysJson, durationMul, encodeCrock, getRandomBytes, hashDenomPub, j2s, makeErrorDetail, parsePaytoUri, } from "@gnu-taler/taler-util"; import { HttpRequestLibrary, getExpiry, readSuccessResponseJsonOrThrow, readSuccessResponseTextOrThrow, readTalerErrorResponse, throwUnexpectedRequestError, } from "@gnu-taler/taler-util/http"; import { PendingTaskType, TaskIdStr, TaskIdentifiers, TaskRunResult, TaskRunResultType, TransactionContext, computeDbBackoff, constructTaskIdentifier, genericWaitForState, 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 { 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( 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 { 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 { 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 { 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 { 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 { 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 { 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 { 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 { 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 { 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 { 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 { const keysUrl = new URL("keys", baseUrl); const headers: Record = {}; 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, }; } type TosMetaResult = { type: "not-found" } | { type: "ok"; etag: string }; /** * Download metadata about an exchange's terms of service. */ async function downloadTosMeta( wex: WalletExecutionContext, exchangeBaseUrl: string, ): Promise { logger.trace(`downloading exchange tos metadata for ${exchangeBaseUrl}`); const reqUrl = new URL("terms", exchangeBaseUrl); // FIXME: We can/should make a HEAD request here. // Not sure if qtart supports it at the moment. const resp = await wex.http.fetch(reqUrl.href, { cancellationToken: wex.cancellationToken, }); switch (resp.status) { case HttpStatusCode.NotFound: case HttpStatusCode.NotImplemented: return { type: "not-found" }; case HttpStatusCode.Ok: break; default: throwUnexpectedRequestError(resp, await readTalerErrorResponse(resp)); } const etag = resp.headers.get("etag") || "unknown"; return { type: "ok", etag, }; } async function downloadTosFromAcceptedFormat( wex: WalletExecutionContext, baseUrl: string, timeout: Duration, acceptedFormat?: string[], acceptLanguage?: string, ): Promise { 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 { 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); const taskId = TaskIdentifiers.forExchangeUpdate(r); return { oldExchangeState, newExchangeState, taskId }; }, ); wex.ws.notify({ type: NotificationType.ExchangeStateTransition, exchangeBaseUrl, newExchangeState: newExchangeState, oldExchangeState: oldExchangeState, }); logger.info(`start update ${exchangeBaseUrl} task ${taskId}`); 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; } /** * 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: { forceUpdate?: boolean; } = {}, ): Promise { logger.info(`fetch fresh ${baseUrl} forced ${options.forceUpdate}`); 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 { logger.trace(`waiting for exchange ${canonUrl} to become ready`); const operationId = constructTaskIdentifier({ tag: PendingTaskType.ExchangeUpdate, exchangeBaseUrl: canonUrl, }); let res: ReadyExchangeSummary | undefined = undefined; await genericWaitForState(wex, { filterNotification(notif): boolean { return ( notif.type === NotificationType.ExchangeStateTransition && notif.exchangeBaseUrl === canonUrl ); }, async checkState(): Promise { 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) { return false; } if (!exchangeDetails) { throw Error("invariant failed"); } if (!scopeInfo) { throw Error("invariant failed"); } const mySummary: 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 (mySummary.masterPub !== options.expectedMasterPub) { throw Error( "public key of the exchange does not match expected public key", ); } } res = mySummary; return true; }, }); checkLogicInvariant(!!res); return res; } 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 { 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"); const tosMeta = await downloadTosMeta(wex, exchangeBaseUrl); 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; switch (tosMeta.type) { case "not-found": r.tosCurrentEtag = undefined; break; case "ok": r.tosCurrentEtag = tosMeta.etag; break; } 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); const drRowId = await tx.exchangeDetails.put(newDetails); checkDbInvariant( typeof drRowId.key === "number", "exchange details key is not a 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(); for (const denom of allOldDenoms) { oldDenomByDph.set(denom.denomPubHash, denom); } logger.trace("updating denominations in database"); const currentDenomSet = new Set( 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 { 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 { throw new Error("Method not implemented."); } suspendTransaction(): Promise { throw new Error("Method not implemented."); } resumeTransaction(): Promise { throw new Error("Method not implemented."); } failTransaction(): Promise { throw new Error("Method not implemented."); } async deleteTransaction(): Promise { 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 { // 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 { // 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 { 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 { 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 { 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) { logger.info(`exchange ${exchangeBaseUrl} NOT found`); 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 { 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 = { 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, ); 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 { 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 { 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 { 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 { // 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; }