diff options
Diffstat (limited to 'packages/taler-wallet-core/src/operations/exchanges.ts')
-rw-r--r-- | packages/taler-wallet-core/src/operations/exchanges.ts | 1433 |
1 files changed, 0 insertions, 1433 deletions
diff --git a/packages/taler-wallet-core/src/operations/exchanges.ts b/packages/taler-wallet-core/src/operations/exchanges.ts deleted file mode 100644 index 8f878ecc0..000000000 --- a/packages/taler-wallet-core/src/operations/exchanges.ts +++ /dev/null @@ -1,1433 +0,0 @@ -/* - This file is part of GNU Taler - (C) 2019 GNUnet e.V. - - GNU Taler is free software; you can redistribute it and/or modify it under the - terms of the GNU General Public License as published by the Free Software - Foundation; either version 3, or (at your option) any later version. - - GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY - WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR - A PARTICULAR PURPOSE. See the GNU General Public License for more details. - - You should have received a copy of the GNU General Public License along with - GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> - */ - -/** - * @fileoverview - * Implementation of exchange entry management in wallet-core. - * The details of exchange entry management are specified in DD48. - */ - -/** - * Imports. - */ -import { - AbsoluteTime, - Amounts, - CancellationToken, - DenomKeyType, - DenomOperationMap, - DenominationInfo, - DenominationPubKey, - Duration, - ExchangeAuditor, - ExchangeDetailedResponse, - ExchangeGlobalFees, - ExchangeListItem, - ExchangeSignKeyJson, - ExchangeTosStatus, - ExchangeWireAccount, - ExchangesListResponse, - FeeDescription, - GetExchangeTosResult, - GlobalFees, - LibtoolVersion, - Logger, - NotificationType, - Recoup, - TalerError, - TalerErrorCode, - TalerErrorDetail, - TalerPreciseTimestamp, - TalerProtocolDuration, - TalerProtocolTimestamp, - URL, - WalletNotification, - WireFee, - WireFeeMap, - WireFeesJson, - WireInfo, - canonicalizeBaseUrl, - codecForExchangeKeysJson, - durationFromSpec, - 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, - OpenedPromise, - PendingTaskType, - WalletDbReadWriteTransaction, - createTimeline, - isWithdrawableDenom, - openPromise, - selectBestForOverlappingDenominations, - selectMinimumFee, - timestampOptionalAbsoluteFromDb, - timestampOptionalPreciseFromDb, - timestampPreciseFromDb, - timestampPreciseToDb, - timestampProtocolToDb, -} from "../index.js"; -import { CancelFn, InternalWalletState } from "../internal-wallet-state.js"; -import { checkDbInvariant } from "../util/invariants.js"; -import { 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<ExchangeTosDownloadResult> { - logger.trace(`downloading exchange tos (type ${acceptFormat})`); - const reqUrl = new URL("terms", exchangeBaseUrl); - const headers: { - Accept: string; - "Accept-Language"?: string; - } = { - Accept: acceptFormat, - }; - - if (acceptLanguage) { - headers["Accept-Language"] = acceptLanguage; - } - - const resp = await http.fetch(reqUrl.href, { - headers, - timeout, - }); - const tosText = await readSuccessResponseTextOrThrow(resp); - const tosEtag = resp.headers.get("etag") || "unknown"; - const tosContentLanguage = resp.headers.get("content-language") || undefined; - const tosContentType = resp.headers.get("content-type") || "text/plain"; - const availLangStr = resp.headers.get("avail-languages") || ""; - // Work around exchange bug that reports the same language multiple times. - const availLangSet = new Set<string>( - availLangStr.split(",").map((x) => x.trim()), - ); - const tosAvailableLanguages = [...availLangSet]; - - return { - tosText, - tosEtag, - tosContentType, - tosContentLanguage, - tosAvailableLanguages, - }; -} - -/** - * Get exchange details from the database. - * - * FIXME: Should we encapsulate the result better, instead of returning the raw DB records here? - */ -export async function getExchangeDetails( - tx: GetReadOnlyAccess<{ - exchanges: typeof WalletStoresV1.exchanges; - exchangeDetails: typeof WalletStoresV1.exchangeDetails; - }>, - exchangeBaseUrl: string, -): Promise<ExchangeDetailsRecord | undefined> { - 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, - ]); -} - -/** - * Mark a ToS version as accepted by the user. - * - * @param etag version of the ToS to accept, or current ToS version of not given - */ -export async function acceptExchangeTermsOfService( - ws: InternalWalletState, - exchangeBaseUrl: string, - etag: string | undefined, -): Promise<void> { - await ws.db - .mktx((x) => [x.exchanges, x.exchangeDetails]) - .runReadWrite(async (tx) => { - const exch = await tx.exchanges.get(exchangeBaseUrl); - if (exch && exch.tosCurrentEtag) { - exch.tosAcceptedEtag = exch.tosCurrentEtag; - exch.tosAcceptedTimestamp = timestampPreciseToDb( - TalerPreciseTimestamp.now(), - ); - await tx.exchanges.put(exch); - } - }); -} - -async function validateWireInfo( - ws: InternalWalletState, - versionCurrent: number, - wireInfo: ExchangeKeysDownloadResult, - masterPublicKey: string, -): Promise<WireInfo> { - 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, - }; -} - -async function validateGlobalFees( - ws: InternalWalletState, - fees: GlobalFees[], - masterPub: string, -): Promise<ExchangeGlobalFees[]> { - 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: 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: GetReadWriteAccess<{ - exchanges: typeof WalletStoresV1.exchanges; - exchangeDetails: typeof WalletStoresV1.exchangeDetails; - }>, - baseUrl: string, - now: AbsoluteTime, -): 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 getExchangeDetails(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<ExchangeKeysDownloadResult> { - 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: durationFromSpec({ 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<ExchangeTosDownloadResult> { - let tosFound: ExchangeTosDownloadResult | undefined; - // Remove this when exchange supports multiple content-type in accept header - if (acceptedFormat) - for (const format of acceptedFormat) { - const resp = await downloadExchangeWithTermsOfService( - 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<void> { - const canonBaseUrl = canonicalizeBaseUrl(exchangeBaseUrl); - - const now = AbsoluteTime.now(); - - const { notification } = await ws.db - .mktx((x) => [x.exchanges, x.exchangeDetails]) - .runReadWrite(async (tx) => { - return provideExchangeRecordInTx(ws, tx, exchangeBaseUrl, now); - }); - - 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(); -} - -export interface NotificationWaiter { - waitNext(): Promise<void>; - cancel(): void; -} - -export function createNotificationWaiter( - ws: InternalWalletState, - pred: (x: WalletNotification) => boolean, -): NotificationWaiter { - ws.ensureTaskLoopRunning(); - let cancelFn: CancelFn | undefined = undefined; - let p: OpenedPromise<void> | undefined = undefined; - - return { - cancel() { - cancelFn?.(); - }, - waitNext(): Promise<void> { - if (!p) { - p = openPromise(); - cancelFn = ws.addNotificationListener((notif) => { - if (pred(notif)) { - // We got a notification that matches our predicate. - // Resolve promise for existing waiters, - // and create a new promise to wait for the next - // notification occurrence. - const myResolve = p?.resolve; - const myCancel = cancelFn; - p = undefined; - cancelFn = undefined; - myResolve?.(); - myCancel?.(); - } - }); - } - return p.promise; - }, - }; -} - -/** - * 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<ReadyExchangeSummary> { - 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 } = await ws.db - .mktx((x) => [x.exchanges, x.exchangeDetails]) - .runReadOnly(async (tx) => { - const exchange = await tx.exchanges.get(canonUrl); - const exchangeDetails = await getExchangeDetails(tx, canonUrl); - return { exchange, exchangeDetails }; - }); - - if (!exchange) { - throw Error("exchange entry does not exist anymore"); - } - - switch (exchange.updateStatus) { - case ExchangeEntryDbUpdateStatus.Ready: - case ExchangeEntryDbUpdateStatus.ReadyUpdate: - break; - default: - throw Error("unable to update exchange"); - } - - 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<TaskRunResult> { - 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 getExchangeDetails(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<string>( - 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<string> { - // 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 getExchangeDetails(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<GetExchangeTosResult> { - // FIXME: download ToS in acceptable format if passed! - 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, - }; -} - -export interface ExchangeInfo { - keys: ExchangeKeysDownloadResult; -} - -/** - * Helper function to download the exchange /keys info. - * - * Only used for testing / dbless wallet. - */ -export async function downloadExchangeInfo( - exchangeBaseUrl: string, - http: HttpRequestLibrary, -): Promise<ExchangeInfo> { - const keysInfo = await downloadExchangeKeysInfo( - exchangeBaseUrl, - http, - Duration.getForever(), - ); - return { - keys: keysInfo, - }; -} - -export async function getExchanges( - ws: InternalWalletState, -): Promise<ExchangesListResponse> { - const exchanges: ExchangeListItem[] = []; - await ws.db - .mktx((x) => [ - x.exchanges, - x.exchangeDetails, - x.denominations, - x.operationRetries, - ]) - .runReadOnly(async (tx) => { - const exchangeRecords = await tx.exchanges.iter().toArray(); - for (const r of exchangeRecords) { - const exchangeDetails = await getExchangeDetails(tx, r.baseUrl); - const opRetryRecord = await tx.operationRetries.get( - TaskIdentifiers.forExchangeUpdate(r), - ); - exchanges.push( - makeExchangeListItem(r, exchangeDetails, opRetryRecord?.lastError), - ); - } - }); - return { exchanges }; -} - -export async function getExchangeDetailedInfo( - ws: InternalWalletState, - exchangeBaseurl: string, -): Promise<ExchangeDetailedResponse> { - //TODO: should we use the forceUpdate parameter? - 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 getExchangeDetails(tx, ex.baseUrl); - if (!exchangeDetails) { - return; - } - const denominationRecords = - await tx.denominations.indexes.byExchangeBaseUrl.getAll(ex.baseUrl); - - if (!denominationRecords) { - return; - } - - const denominations: DenominationInfo[] = denominationRecords.map((x) => - DenominationRecord.toDenomInfo(x), - ); - - return { - info: { - exchangeBaseUrl: ex.baseUrl, - currency, - paytoUris: exchangeDetails.wireInfo.accounts.map((x) => x.payto_uri), - auditors: exchangeDetails.auditors, - wireInfo: exchangeDetails.wireInfo, - globalFees: exchangeDetails.globalFees, - }, - denominations, - }; - }); - - if (!exchange) { - throw Error(`exchange with base url "${exchangeBaseurl}" not found`); - } - - const denoms = exchange.denominations.map((d) => ({ - ...d, - group: Amounts.stringifyValue(d.value), - })); - const denomFees: DenomOperationMap<FeeDescription[]> = { - deposit: createTimeline( - denoms, - "denomPubHash", - "stampStart", - "stampExpireDeposit", - "feeDeposit", - "group", - selectBestForOverlappingDenominations, - ), - refresh: createTimeline( - denoms, - "denomPubHash", - "stampStart", - "stampExpireWithdraw", - "feeRefresh", - "group", - selectBestForOverlappingDenominations, - ), - refund: createTimeline( - denoms, - "denomPubHash", - "stampStart", - "stampExpireWithdraw", - "feeRefund", - "group", - selectBestForOverlappingDenominations, - ), - withdraw: createTimeline( - denoms, - "denomPubHash", - "stampStart", - "stampExpireWithdraw", - "feeWithdraw", - "group", - selectBestForOverlappingDenominations, - ), - }; - - const transferFees = Object.entries( - exchange.info.wireInfo.feesForType, - ).reduce( - (prev, [wireType, infoForType]) => { - const feesByGroup = [ - ...infoForType.map((w) => ({ - ...w, - fee: Amounts.stringify(w.closingFee), - group: "closing", - })), - ...infoForType.map((w) => ({ ...w, fee: w.wireFee, group: "wire" })), - ]; - prev[wireType] = createTimeline( - feesByGroup, - "sig", - "startStamp", - "endStamp", - "fee", - "group", - selectMinimumFee, - ); - return prev; - }, - {} as Record<string, FeeDescription[]>, - ); - - const globalFeesByGroup = [ - ...exchange.info.globalFees.map((w) => ({ - ...w, - fee: w.accountFee, - group: "account", - })), - ...exchange.info.globalFees.map((w) => ({ - ...w, - fee: w.historyFee, - group: "history", - })), - ...exchange.info.globalFees.map((w) => ({ - ...w, - fee: w.purseFee, - group: "purse", - })), - ]; - - const globalFees = createTimeline( - globalFeesByGroup, - "signature", - "startDate", - "endDate", - "fee", - "group", - selectMinimumFee, - ); - - return { - exchange: { - ...exchange.info, - denomFees, - transferFees, - globalFees, - }, - }; -} |