/* 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, Amounts, CancellationToken, DeleteExchangeRequest, DenomKeyType, DenomOperationMap, DenominationInfo, DenominationPubKey, Duration, EddsaPublicKeyString, ExchangeAuditor, ExchangeDetailedResponse, ExchangeGlobalFees, ExchangeListItem, ExchangeSignKeyJson, ExchangeTosStatus, ExchangeWireAccount, ExchangesListResponse, FeeDescription, GetExchangeEntryByUrlRequest, GetExchangeResourcesResponse, GetExchangeTosResult, GlobalFees, LibtoolVersion, Logger, NotificationType, Recoup, TalerError, TalerErrorCode, TalerErrorDetail, TalerPreciseTimestamp, TalerProtocolDuration, TalerProtocolTimestamp, URL, WalletNotification, WireFee, WireFeeMap, WireFeesJson, WireInfo, canonicalizeBaseUrl, codecForExchangeKeysJson, encodeCrock, hashDenomPub, j2s, makeErrorDetail, parsePaytoUri, } from "@gnu-taler/taler-util"; import { HttpRequestLibrary, getExpiry, readSuccessResponseJsonOrThrow, readSuccessResponseTextOrThrow, } from "@gnu-taler/taler-util/http"; import { DenominationRecord, DenominationVerificationStatus, ExchangeDetailsRecord, ExchangeEntryRecord, WalletStoresV1, } from "../db.js"; import { ExchangeEntryDbRecordStatus, ExchangeEntryDbUpdateStatus, PendingTaskType, WalletDbReadWriteTransaction, WalletDbReadWriteTransactionArr, createTimeline, isWithdrawableDenom, selectBestForOverlappingDenominations, selectMinimumFee, timestampOptionalAbsoluteFromDb, timestampOptionalPreciseFromDb, timestampPreciseFromDb, timestampPreciseToDb, timestampProtocolToDb, } from "../index.js"; import { InternalWalletState } from "../internal-wallet-state.js"; import { checkDbInvariant } from "../util/invariants.js"; import { DbReadOnlyTransaction, DbReadOnlyTransactionArr, GetReadOnlyAccess, GetReadWriteAccess, } from "../util/query.js"; import { WALLET_EXCHANGE_PROTOCOL_VERSION } from "../versions.js"; import { TaskIdentifiers, TaskRunResult, TaskRunResultType, constructTaskIdentifier, getExchangeState, getExchangeTosStatusFromRecord, makeExchangeListItem, runTaskWithErrorReporting, } from "./common.js"; const logger = new Logger("exchanges.ts"); function getExchangeRequestTimeout(): Duration { return Duration.fromSpec({ seconds: 5, }); } interface ExchangeTosDownloadResult { tosText: string; tosEtag: string; tosContentType: string; tosContentLanguage: string | undefined; tosAvailableLanguages: string[]; } async function downloadExchangeWithTermsOfService( 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, }); 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: GetReadOnlyAccess<{ exchanges: typeof WalletStoresV1.exchanges; exchangeDetails: typeof WalletStoresV1.exchangeDetails; }>, exchangeBaseUrl: string, ): Promise { const r = await tx.exchanges.get(exchangeBaseUrl); if (!r) { return; } const dp = r.detailsPointer; if (!dp) { return; } const { currency, masterPublicKey } = dp; return await tx.exchangeDetails.indexes.byPointer.get([ r.baseUrl, currency, masterPublicKey, ]); } export interface ExchangeWireDetails { currency: string; masterPublicKey: EddsaPublicKeyString; wireInfo: WireInfo; exchangeBaseUrl: string; auditors: ExchangeAuditor[]; globalFees: ExchangeGlobalFees[]; } export async function getExchangeWireDetailsInTx( tx: GetReadOnlyAccess<{ exchanges: typeof WalletStoresV1.exchanges; exchangeDetails: typeof WalletStoresV1.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( ws: InternalWalletState, req: GetExchangeEntryByUrlRequest, ): Promise { return await ws.db .mktx((x) => [ x.exchanges, x.exchangeDetails, x.denominations, x.operationRetries, ]) .runReadOnly(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 makeExchangeListItem( exchangeRec, exchangeDetails, opRetryRecord?.lastError, ); }); } /** * Mark the current ToS version as accepted by the user. */ export async function acceptExchangeTermsOfService( ws: InternalWalletState, exchangeBaseUrl: string, ): Promise { const notif = await ws.db .mktx((x) => [x.exchanges, x.exchangeDetails]) .runReadWrite(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); return { type: NotificationType.ExchangeStateTransition, exchangeBaseUrl, newExchangeState: newExchangeState, oldExchangeState: oldExchangeState, } satisfies WalletNotification; } return undefined; }); if (notif) { ws.notify(notif); } } /** * Validate wire fees and wire accounts. * * Throw an exception if they are invalid. */ async function validateWireInfo( ws: InternalWalletState, versionCurrent: number, wireInfo: ExchangeKeysDownloadResult, masterPublicKey: string, ): Promise { for (const a of wireInfo.accounts) { logger.trace("validating exchange acct"); let isValid = false; if (ws.config.testing.insecureTrustExchange) { isValid = true; } else { const { valid: v } = await 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 (ws.config.testing.insecureTrustExchange) { isValid = true; } else { const { valid: v } = await 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( ws: InternalWalletState, fees: GlobalFees[], masterPub: string, ): Promise { const egf: ExchangeGlobalFees[] = []; for (const gf of fees) { logger.trace("validating exchange global fees"); let isValid = false; if (ws.config.testing.insecureTrustExchange) { isValid = true; } else { const { valid: v } = await ws.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: WalletDbReadWriteTransactionArr<["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: GetReadWriteAccess<{ exchanges: typeof WalletStoresV1.exchanges; exchangeDetails: typeof WalletStoresV1.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()), ), 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, ): Promise { const keysUrl = new URL("keys", baseUrl); const resp = await http.fetch(keysUrl.href, { timeout, }); // 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, 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, listIssueDate: timestampProtocolToDb( exchangeKeysJsonUnchecked.list_issue_date, ), 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( ws: InternalWalletState, 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( baseUrl, ws.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( baseUrl, ws.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. */ export async function startUpdateExchangeEntry( ws: InternalWalletState, exchangeBaseUrl: string, options: { forceUpdate?: boolean } = {}, ): Promise { const canonBaseUrl = canonicalizeBaseUrl(exchangeBaseUrl); const { notification } = await ws.db .mktx((x) => [x.exchanges, x.exchangeDetails]) .runReadWrite(async (tx) => { return provideExchangeRecordInTx(ws, tx, exchangeBaseUrl); }); if (notification) { ws.notify(notification); } const { oldExchangeState, newExchangeState } = await ws.db .mktx((x) => [x.exchanges, x.operationRetries]) .runReadWrite(async (tx) => { const r = await tx.exchanges.get(canonBaseUrl); if (!r) { throw Error("exchange not found"); } const oldExchangeState = getExchangeState(r); switch (r.updateStatus) { case ExchangeEntryDbUpdateStatus.UnavailableUpdate: break; case ExchangeEntryDbUpdateStatus.Suspended: break; case ExchangeEntryDbUpdateStatus.ReadyUpdate: 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; } break; } case ExchangeEntryDbUpdateStatus.Initial: r.updateStatus = ExchangeEntryDbUpdateStatus.InitialUpdate; break; } 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 }; }); ws.notify({ type: NotificationType.ExchangeStateTransition, exchangeBaseUrl: canonBaseUrl, newExchangeState: newExchangeState, oldExchangeState: oldExchangeState, }); ws.workAvailable.trigger(); } /** * 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; } /** * 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( ws: InternalWalletState, baseUrl: string, options: { cancellationToken?: CancellationToken; forceUpdate?: boolean; expectedMasterPub?: string; } = {}, ): Promise { const canonUrl = canonicalizeBaseUrl(baseUrl); const operationId = constructTaskIdentifier({ tag: PendingTaskType.ExchangeUpdate, exchangeBaseUrl: canonUrl, }); const oldExchange = await ws.db .mktx((x) => [x.exchanges]) .runReadOnly(async (tx) => { return tx.exchanges.get(canonUrl); }); let needsUpdate = false; if (!oldExchange || options.forceUpdate) { needsUpdate = true; await startUpdateExchangeEntry(ws, canonUrl, { forceUpdate: options.forceUpdate, }); } else { const nextUpdate = timestampOptionalAbsoluteFromDb( oldExchange.nextUpdateStamp, ); if ( nextUpdate == null || AbsoluteTime.isExpired(nextUpdate) || oldExchange.updateStatus !== ExchangeEntryDbUpdateStatus.Ready ) { needsUpdate = true; } } if (needsUpdate) { await runTaskWithErrorReporting(ws, operationId, () => updateExchangeFromUrlHandler(ws, canonUrl), ); } const { exchange, exchangeDetails, retryInfo } = await ws.db .mktx((x) => [x.exchanges, x.exchangeDetails, x.operationRetries]) .runReadOnly(async (tx) => { const exchange = await tx.exchanges.get(canonUrl); const exchangeDetails = await getExchangeRecordsInternal(tx, canonUrl); const retryInfo = await tx.operationRetries.get(operationId); return { exchange, exchangeDetails, retryInfo }; }); if (!exchange) { throw Error("exchange entry does not exist anymore"); } switch (exchange.updateStatus) { case ExchangeEntryDbUpdateStatus.Ready: case ExchangeEntryDbUpdateStatus.ReadyUpdate: break; default: throw TalerError.fromDetail(TalerErrorCode.WALLET_EXCHANGE_UNAVAILABLE, { exchangeBaseUrl: canonUrl, innerError: retryInfo?.lastError, }); } if (!exchangeDetails) { 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, ), }; if (options.expectedMasterPub) { if (res.masterPub !== options.expectedMasterPub) { throw Error( "public key of the exchange does not match expected public key", ); } } return res; } /** * 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( ws: InternalWalletState, exchangeBaseUrl: string, options: { cancellationToken?: CancellationToken; } = {}, ): Promise { logger.trace(`updating exchange info for ${exchangeBaseUrl}`); exchangeBaseUrl = canonicalizeBaseUrl(exchangeBaseUrl); logger.trace("updating exchange /keys info"); const timeout = getExchangeRequestTimeout(); const keysInfo = await downloadExchangeKeysInfo( exchangeBaseUrl, ws.http, timeout, ); 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( ws, version.current, keysInfo, keysInfo.masterPublicKey, ); const globalFees = await validateGlobalFees( ws, 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( ws, exchangeBaseUrl, timeout, ["text/plain"], ); let recoupGroupId: string | undefined; logger.trace("updating exchange info in database"); let detailsPointerChanged = false; let ageMask = 0; for (const x of keysInfo.currentDenominations) { if ( isWithdrawableDenom(x, ws.config.testing.denomselAllowLate) && x.denomPub.age_mask != 0 ) { ageMask = x.denomPub.age_mask; break; } } const updated = await ws.db .mktx((x) => [ x.exchanges, x.exchangeDetails, x.exchangeSignKeys, x.denominations, x.coins, x.refreshGroups, x.recoupGroups, ]) .runReadWrite(async (tx) => { const r = await tx.exchanges.get(exchangeBaseUrl); if (!r) { logger.warn(`exchange ${exchangeBaseUrl} no longer present`); return; } const oldExchangeState = getExchangeState(r); const existingDetails = await getExchangeRecordsInternal(tx, r.baseUrl); if (!existingDetails) { detailsPointerChanged = true; } if (existingDetails) { if (existingDetails.masterPublicKey !== keysInfo.masterPublicKey) { detailsPointerChanged = true; } if (existingDetails.currency !== keysInfo.currency) { detailsPointerChanged = true; } // FIXME: We need to do some consistency checks! } 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.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; await tx.exchanges.put(r); 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), }); } logger.trace("updating denominations in database"); const currentDenomSet = new Set( keysInfo.currentDenominations.map((x) => x.denomPubHash), ); for (const currentDenom of keysInfo.currentDenominations) { const oldDenom = await tx.denominations.get([ exchangeBaseUrl, currentDenom.denomPubHash, ]); if (oldDenom) { // FIXME: Do consistency check, report to auditor if necessary. } else { await tx.denominations.put(currentDenom); } } // Update list issue date for all denominations, // and mark non-offered denominations as such. await tx.denominations.indexes.byExchangeBaseUrl .iter(r.baseUrl) .forEachAsync(async (x) => { 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 { x.listIssueDate = timestampProtocolToDb(keysInfo.listIssueDate); 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"); // Handle recoup const recoupDenomList = keysInfo.recoup; const newlyRevokedCoinPubs: string[] = []; logger.trace("recoup list from exchange", recoupDenomList); for (const recoupInfo of recoupDenomList) { const oldDenom = await tx.denominations.get([ r.baseUrl, 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 .iter(recoupInfo.h_denom_pub) .toArray(); for (const ac of affectedCoins) { newlyRevokedCoinPubs.push(ac.coinPub); } } if (newlyRevokedCoinPubs.length != 0) { logger.info("recouping coins", newlyRevokedCoinPubs); recoupGroupId = await ws.recoupOps.createRecoupGroup( ws, tx, exchangeBaseUrl, newlyRevokedCoinPubs, ); } const newExchangeState = getExchangeState(r); return { exchange: r, exchangeDetails: newDetails, oldExchangeState, newExchangeState, }; }); if (recoupGroupId) { // Asynchronously start recoup. This doesn't need to finish // for the exchange update to be considered finished. ws.workAvailable.trigger(); } if (!updated) { throw Error("something went wrong with updating the exchange"); } logger.trace("done updating exchange info in database"); ws.notify({ type: NotificationType.ExchangeStateTransition, exchangeBaseUrl, newExchangeState: updated.newExchangeState, oldExchangeState: updated.oldExchangeState, }); return TaskRunResult.finished(); } /** * 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( ws: InternalWalletState, exchangeBaseUrl: string, supportedTargetTypes: string[], ): Promise { // We do the update here, since the exchange might not even exist // yet in our database. const details = await ws.db .mktx((x) => [x.exchangeDetails, x.exchanges]) .runReadOnly(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( ws: InternalWalletState, exchangeBaseUrl: string, acceptedFormat?: string[], acceptLanguage?: string, ): Promise { const exch = await fetchFreshExchange(ws, exchangeBaseUrl); const tosDownload = await downloadTosFromAcceptedFormat( ws, exchangeBaseUrl, getExchangeRequestTimeout(), acceptedFormat, acceptLanguage, ); await ws.db .mktx((x) => [x.exchanges, x.exchangeDetails]) .runReadWrite(async (tx) => { const updateExchangeEntry = await tx.exchanges.get(exchangeBaseUrl); if (updateExchangeEntry) { updateExchangeEntry.tosCurrentEtag = tosDownload.tosEtag; 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(), ); return { keys: keysInfo, }; } /** * List all exchange entries known to the wallet. */ export async function listExchanges( ws: InternalWalletState, ): Promise { const exchanges: ExchangeListItem[] = []; await ws.db .mktx((x) => [x.exchanges, x.exchangeDetails, x.operationRetries]) .runReadOnly(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( makeExchangeListItem(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( ws: InternalWalletState, tx: GetReadWriteAccess<{ exchanges: typeof WalletStoresV1.exchanges }>, exchangeBaseUrl: string, ): Promise<{ notif: WalletNotification | undefined }> { exchangeBaseUrl = canonicalizeBaseUrl(exchangeBaseUrl); 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( ws: InternalWalletState, exchangeBaseurl: string, ): Promise { const exchange = await ws.db .mktx((x) => [x.exchanges, x.exchangeDetails, x.denominations]) .runReadOnly(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( ws: InternalWalletState, tx: DbReadOnlyTransactionArr< 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, }; } export async function deleteExchange( ws: InternalWalletState, req: DeleteExchangeRequest, ): Promise { let inUse: boolean = false; const exchangeBaseUrl = canonicalizeBaseUrl(req.exchangeBaseUrl); await ws.db.runReadWriteTx( ["exchanges", "coins", "withdrawalGroups", "exchangeDetails"], 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(ws, tx, exchangeBaseUrl); if (res.hasResources) { if (req.purge) { 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); } // FIXME: Also remove records related to transactions? } else { inUse = true; return; } } await tx.exchanges.delete(exchangeBaseUrl); }, ); if (inUse) { throw TalerError.fromUncheckedDetail({ code: TalerErrorCode.WALLET_EXCHANGE_ENTRY_USED, hint: "Exchange in use.", }); } } export async function getExchangeResources( ws: InternalWalletState, exchangeBaseUrl: string, ): Promise { // Withdrawals include internal withdrawals from peer transactions const res = await ws.db.runReadOnlyTx( ["exchanges", "withdrawalGroups", "coins"], async (tx) => { const exchangeRecord = await tx.exchanges.get(exchangeBaseUrl); if (!exchangeRecord) { return undefined; } return internalGetExchangeResources(ws, tx, exchangeBaseUrl); }, ); if (!res) { throw Error("exchange not found"); } return res; }