diff options
Diffstat (limited to 'packages/taler-wallet-core/src/exchanges.ts')
-rw-r--r-- | packages/taler-wallet-core/src/exchanges.ts | 2007 |
1 files changed, 2007 insertions, 0 deletions
diff --git a/packages/taler-wallet-core/src/exchanges.ts b/packages/taler-wallet-core/src/exchanges.ts new file mode 100644 index 000000000..4e1651ae0 --- /dev/null +++ b/packages/taler-wallet-core/src/exchanges.ts @@ -0,0 +1,2007 @@ +/* + This file is part of GNU Taler + (C) 2019 GNUnet e.V. + + GNU Taler is free software; you can redistribute it and/or modify it under the + terms of the GNU General Public License as published by the Free Software + Foundation; either version 3, or (at your option) any later version. + + GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY + WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR + A PARTICULAR PURPOSE. See the GNU General Public License for more details. + + You should have received a copy of the GNU General Public License along with + GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> + */ + +/** + * @fileoverview + * Implementation of exchange entry management in wallet-core. + * The details of exchange entry management are specified in DD48. + */ + +/** + * Imports. + */ +import { + AbsoluteTime, + AgeRestriction, + Amounts, + AsyncFlag, + CancellationToken, + CoinRefreshRequest, + CoinStatus, + DeleteExchangeRequest, + DenomKeyType, + DenomOperationMap, + DenominationInfo, + DenominationPubKey, + Duration, + EddsaPublicKeyString, + ExchangeAuditor, + ExchangeDetailedResponse, + ExchangeGlobalFees, + ExchangeListItem, + ExchangeSignKeyJson, + ExchangeTosStatus, + ExchangeWireAccount, + ExchangesListResponse, + FeeDescription, + GetExchangeEntryByUrlRequest, + GetExchangeResourcesResponse, + GetExchangeTosResult, + GlobalFees, + LibtoolVersion, + Logger, + NotificationType, + OperationErrorInfo, + Recoup, + RefreshReason, + ScopeInfo, + ScopeType, + TalerError, + TalerErrorCode, + TalerErrorDetail, + TalerPreciseTimestamp, + TalerProtocolDuration, + TalerProtocolTimestamp, + URL, + WalletNotification, + WireFee, + WireFeeMap, + WireFeesJson, + WireInfo, + assertUnreachable, + canonicalizeBaseUrl, + codecForExchangeKeysJson, + durationFromSpec, + durationMul, + 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, + WalletDbReadOnlyTransaction, + WalletDbReadWriteTransaction, + createRefreshGroup, + createTimeline, + isWithdrawableDenom, + selectBestForOverlappingDenominations, + selectMinimumFee, + timestampAbsoluteFromDb, + timestampOptionalPreciseFromDb, + timestampPreciseFromDb, + timestampPreciseToDb, + timestampProtocolFromDb, + timestampProtocolToDb, +} from "./index.js"; +import { InternalWalletState } from "./internal-wallet-state.js"; +import { checkDbInvariant } from "./util/invariants.js"; +import { DbReadOnlyTransaction } from "./query.js"; +import { WALLET_EXCHANGE_PROTOCOL_VERSION } from "./versions.js"; +import { + TaskIdentifiers, + TaskRunResult, + TaskRunResultType, + constructTaskIdentifier, + getAutoRefreshExecuteThreshold, + getExchangeEntryStatusFromRecord, + getExchangeState, + getExchangeTosStatusFromRecord, + getExchangeUpdateStatusFromRecord, +} from "./common.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( + 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. + */ +async function getExchangeRecordsInternal( + tx: WalletDbReadOnlyTransaction<["exchanges", "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, + ]); +} + +export async function getExchangeScopeInfo( + tx: WalletDbReadOnlyTransaction< + [ + "exchanges", + "exchangeDetails", + "globalCurrencyExchanges", + "globalCurrencyAuditors", + ] + >, + exchangeBaseUrl: string, + currency: string, +): Promise<ScopeInfo> { + const det = await getExchangeRecordsInternal(tx, exchangeBaseUrl); + if (!det) { + return { + type: ScopeType.Exchange, + currency: currency, + url: exchangeBaseUrl, + }; + } + return internalGetExchangeScopeInfo(tx, det); +} + +async function internalGetExchangeScopeInfo( + tx: WalletDbReadOnlyTransaction< + ["globalCurrencyExchanges", "globalCurrencyAuditors"] + >, + exchangeDetails: ExchangeDetailsRecord, +): Promise<ScopeInfo> { + const globalExchangeRec = + await tx.globalCurrencyExchanges.indexes.byCurrencyAndUrlAndPub.get([ + exchangeDetails.currency, + exchangeDetails.exchangeBaseUrl, + exchangeDetails.masterPublicKey, + ]); + if (globalExchangeRec) { + return { + currency: exchangeDetails.currency, + type: ScopeType.Global, + }; + } else { + for (const aud of exchangeDetails.auditors) { + const globalAuditorRec = + await tx.globalCurrencyAuditors.indexes.byCurrencyAndUrlAndPub.get([ + exchangeDetails.currency, + aud.auditor_url, + aud.auditor_pub, + ]); + if (globalAuditorRec) { + return { + currency: exchangeDetails.currency, + type: ScopeType.Auditor, + url: aud.auditor_url, + }; + } + } + } + return { + currency: exchangeDetails.currency, + type: ScopeType.Exchange, + url: exchangeDetails.exchangeBaseUrl, + }; +} + +async function makeExchangeListItem( + tx: WalletDbReadOnlyTransaction< + ["globalCurrencyExchanges", "globalCurrencyAuditors"] + >, + r: ExchangeEntryRecord, + exchangeDetails: ExchangeDetailsRecord | undefined, + lastError: TalerErrorDetail | undefined, +): Promise<ExchangeListItem> { + const lastUpdateErrorInfo: OperationErrorInfo | undefined = lastError + ? { + error: lastError, + } + : undefined; + + let scopeInfo: ScopeInfo | undefined = undefined; + + if (exchangeDetails) { + scopeInfo = await internalGetExchangeScopeInfo(tx, exchangeDetails); + } + + return { + exchangeBaseUrl: r.baseUrl, + currency: exchangeDetails?.currency ?? r.presetCurrencyHint, + 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) ?? [], + lastUpdateErrorInfo, + scopeInfo, + }; +} + +export interface ExchangeWireDetails { + currency: string; + masterPublicKey: EddsaPublicKeyString; + wireInfo: WireInfo; + exchangeBaseUrl: string; + auditors: ExchangeAuditor[]; + globalFees: ExchangeGlobalFees[]; +} + +export async function getExchangeWireDetailsInTx( + tx: WalletDbReadOnlyTransaction<["exchanges", "exchangeDetails"]>, + exchangeBaseUrl: string, +): Promise<ExchangeWireDetails | undefined> { + const det = await getExchangeRecordsInternal(tx, exchangeBaseUrl); + if (!det) { + return undefined; + } + return { + currency: det.currency, + masterPublicKey: det.masterPublicKey, + wireInfo: det.wireInfo, + exchangeBaseUrl: det.exchangeBaseUrl, + auditors: det.auditors, + globalFees: det.globalFees, + }; +} + +export async function lookupExchangeByUri( + ws: InternalWalletState, + req: GetExchangeEntryByUrlRequest, +): Promise<ExchangeListItem> { + return await ws.db.runReadOnlyTx( + [ + "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( + ws: InternalWalletState, + exchangeBaseUrl: string, +): Promise<void> { + const notif = await ws.db.runReadWriteTx( + ["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); + 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<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, + }; +} + +/** + * Validate global fees. + * + * Throw an exception if they are invalid. + */ +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: 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()), + ), + 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, +): Promise<ExchangeKeysDownloadResult> { + const keysUrl = new URL("keys", baseUrl); + + const resp = await http.fetch(keysUrl.href, { + timeout, + cancellationToken, + }); + + 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, + 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<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. + */ +async function startUpdateExchangeEntry( + ws: InternalWalletState, + exchangeBaseUrl: string, + options: { forceUpdate?: boolean } = {}, +): Promise<void> { + const canonBaseUrl = canonicalizeBaseUrl(exchangeBaseUrl); + + logger.info( + `starting update of exchange entry ${canonBaseUrl}, forced=${ + options.forceUpdate ?? false + }`, + ); + + const { notification } = await ws.db.runReadWriteTx( + ["exchanges", "exchangeDetails"], + async (tx) => { + return provideExchangeRecordInTx(ws, tx, exchangeBaseUrl); + }, + ); + + if (notification) { + ws.notify(notification); + } + + const { oldExchangeState, newExchangeState, taskId } = + await ws.db.runReadWriteTx( + ["exchanges", "operationRetries"], + 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, taskId }; + }, + ); + ws.notify({ + type: NotificationType.ExchangeStateTransition, + exchangeBaseUrl: canonBaseUrl, + newExchangeState: newExchangeState, + oldExchangeState: oldExchangeState, + }); + await ws.taskScheduler.resetTaskRetries(taskId); +} + +/** + * Basic information about an exchange in a ready state. + */ +export interface ReadyExchangeSummary { + exchangeBaseUrl: string; + currency: string; + masterPub: string; + tosStatus: ExchangeTosStatus; + tosAcceptedEtag: string | undefined; + tosCurrentEtag: string | undefined; + wireInfo: WireInfo; + protocolVersionRange: string; + tosAcceptedTimestamp: TalerPreciseTimestamp | undefined; + scopeInfo: ScopeInfo; +} + +async function internalWaitReadyExchange( + ws: InternalWalletState, + canonUrl: string, + exchangeNotifFlag: AsyncFlag, + options: { + cancellationToken?: CancellationToken; + forceUpdate?: boolean; + expectedMasterPub?: string; + } = {}, +): Promise<ReadyExchangeSummary> { + const operationId = constructTaskIdentifier({ + tag: PendingTaskType.ExchangeUpdate, + exchangeBaseUrl: canonUrl, + }); + while (true) { + logger.info(`waiting for ready exchange ${canonUrl}`); + const { exchange, exchangeDetails, retryInfo, scopeInfo } = + await ws.db.runReadOnlyTx( + [ + "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; + default: { + if (retryInfo) { + throw TalerError.fromDetail( + TalerErrorCode.WALLET_EXCHANGE_UNAVAILABLE, + { + exchangeBaseUrl: canonUrl, + innerError: retryInfo?.lastError, + }, + ); + } + } + } + + if (!ready) { + logger.info("waiting for exchange update notification"); + await exchangeNotifFlag.wait(); + logger.info("done waiting for exchange update notification"); + exchangeNotifFlag.reset(); + continue; + } + + if (!exchangeDetails) { + throw Error("invariant failed"); + } + + if (!scopeInfo) { + throw Error("invariant failed"); + } + + const res: ReadyExchangeSummary = { + currency: exchangeDetails.currency, + exchangeBaseUrl: canonUrl, + masterPub: exchangeDetails.masterPublicKey, + tosStatus: getExchangeTosStatusFromRecord(exchange), + tosAcceptedEtag: exchange.tosAcceptedEtag, + wireInfo: exchangeDetails.wireInfo, + protocolVersionRange: exchangeDetails.protocolVersionRange, + tosCurrentEtag: exchange.tosCurrentEtag, + tosAcceptedTimestamp: timestampOptionalPreciseFromDb( + exchange.tosAcceptedTimestamp, + ), + scopeInfo, + }; + + if (options.expectedMasterPub) { + if (res.masterPub !== options.expectedMasterPub) { + throw Error( + "public key of the exchange does not match expected public key", + ); + } + } + return res; + } +} + +/** + * Ensure that a fresh exchange entry exists for the given + * exchange base URL. + * + * The cancellation token can be used to abort waiting for the + * updated exchange entry. + * + * If an exchange entry for the database doesn't exist in the + * DB, it will be added ephemerally. + * + * If the expectedMasterPub is given and does not match the actual + * master pub, an exception will be thrown. However, the exchange + * will still have been added as an ephemeral exchange entry. + */ +export async function fetchFreshExchange( + ws: InternalWalletState, + baseUrl: string, + options: { + cancellationToken?: CancellationToken; + forceUpdate?: boolean; + expectedMasterPub?: string; + } = {}, +): Promise<ReadyExchangeSummary> { + const canonUrl = canonicalizeBaseUrl(baseUrl); + + ws.ensureTaskLoopRunning(); + + await startUpdateExchangeEntry(ws, canonUrl, { + forceUpdate: options.forceUpdate, + }); + + return waitReadyExchange(ws, canonUrl, options); +} + +async function waitReadyExchange( + ws: InternalWalletState, + canonUrl: string, + options: { + cancellationToken?: CancellationToken; + forceUpdate?: boolean; + expectedMasterPub?: string; + } = {}, +): Promise<ReadyExchangeSummary> { + // FIXME: We should use Symbol.dispose magic here for cleanup! + + const exchangeNotifFlag = new AsyncFlag(); + // Raise exchangeNotifFlag whenever we get a notification + // about our exchange. + const cancelNotif = ws.addNotificationListener((notif) => { + if ( + notif.type === NotificationType.ExchangeStateTransition && + notif.exchangeBaseUrl === canonUrl + ) { + logger.info(`raising update notification: ${j2s(notif)}`); + exchangeNotifFlag.raise(); + } + }); + + try { + const res = await internalWaitReadyExchange( + ws, + canonUrl, + exchangeNotifFlag, + options, + ); + logger.info("done waiting for ready exchange"); + return res; + } finally { + cancelNotif(); + } +} + +/** + * 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, + cancellationToken: CancellationToken, +): Promise<TaskRunResult> { + logger.trace(`updating exchange info for ${exchangeBaseUrl}`); + exchangeBaseUrl = canonicalizeBaseUrl(exchangeBaseUrl); + + const oldExchangeRec = await ws.db.runReadOnlyTx( + ["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: + case ExchangeEntryDbUpdateStatus.UnavailableUpdate: + updateRequestedExplicitly = true; + 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)) { + 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, + ws.http, + timeout, + cancellationToken, + ); + + 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.runReadWriteTx( + [ + "exchanges", + "exchangeDetails", + "exchangeSignKeys", + "denominations", + "coins", + "refreshGroups", + "recoupGroups", + ], + 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<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) { + const recoupTaskId = constructTaskIdentifier({ + tag: PendingTaskType.Recoup, + recoupGroupId, + }); + // Asynchronously start recoup. This doesn't need to finish + // for the exchange update to be considered finished. + ws.taskScheduler.startShepherdTask(recoupTaskId); + } + + if (!updated) { + throw Error("something went wrong with updating the exchange"); + } + + logger.trace("done updating exchange info in database"); + + logger.trace(`doing auto-refresh check for '${exchangeBaseUrl}'`); + + let minCheckThreshold = AbsoluteTime.addDuration( + AbsoluteTime.now(), + durationFromSpec({ days: 1 }), + ); + + if (refreshCheckNecessary) { + // Do auto-refresh. + await ws.db.runReadWriteTx( + [ + "coins", + "denominations", + "coinAvailability", + "refreshGroups", + "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( + ws, + 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), + ); + await tx.exchanges.put(exchange); + }, + ); + } + + 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(); +} + +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( + 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.runReadOnlyTx( + ["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( + ws: InternalWalletState, + exchangeBaseUrl: string, + acceptedFormat?: string[], + acceptLanguage?: string, +): Promise<GetExchangeTosResult> { + const exch = await fetchFreshExchange(ws, exchangeBaseUrl); + + const tosDownload = await downloadTosFromAcceptedFormat( + ws, + exchangeBaseUrl, + getExchangeRequestTimeout(), + acceptedFormat, + acceptLanguage, + ); + + await ws.db.runReadWriteTx(["exchanges"], 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<ExchangeInfo> { + const keysInfo = await downloadExchangeKeysInfo( + exchangeBaseUrl, + http, + Duration.getForever(), + CancellationToken.CONTINUE, + ); + return { + keys: keysInfo, + }; +} + +/** + * List all exchange entries known to the wallet. + */ +export async function listExchanges( + ws: InternalWalletState, +): Promise<ExchangesListResponse> { + const exchanges: ExchangeListItem[] = []; + await ws.db.runReadOnlyTx( + [ + "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( + ws: InternalWalletState, + tx: WalletDbReadWriteTransaction<["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<ExchangeDetailedResponse> { + const exchange = await ws.db.runReadOnlyTx( + ["exchanges", "exchangeDetails", "denominations"], + async (tx) => { + const ex = await tx.exchanges.get(exchangeBaseurl); + const dp = ex?.detailsPointer; + if (!dp) { + return; + } + const { currency } = dp; + const exchangeDetails = await getExchangeRecordsInternal(tx, ex.baseUrl); + if (!exchangeDetails) { + return; + } + const denominationRecords = + await tx.denominations.indexes.byExchangeBaseUrl.getAll(ex.baseUrl); + + if (!denominationRecords) { + return; + } + + const denominations: DenominationInfo[] = denominationRecords.map((x) => + DenominationRecord.toDenomInfo(x), + ); + + return { + info: { + exchangeBaseUrl: ex.baseUrl, + currency, + paytoUris: exchangeDetails.wireInfo.accounts.map((x) => x.payto_uri), + auditors: exchangeDetails.auditors, + wireInfo: exchangeDetails.wireInfo, + globalFees: exchangeDetails.globalFees, + }, + denominations, + }; + }, + ); + + if (!exchange) { + throw Error(`exchange with base url "${exchangeBaseurl}" not found`); + } + + const denoms = exchange.denominations.map((d) => ({ + ...d, + group: Amounts.stringifyValue(d.value), + })); + const denomFees: DenomOperationMap<FeeDescription[]> = { + deposit: createTimeline( + denoms, + "denomPubHash", + "stampStart", + "stampExpireDeposit", + "feeDeposit", + "group", + selectBestForOverlappingDenominations, + ), + refresh: createTimeline( + denoms, + "denomPubHash", + "stampStart", + "stampExpireWithdraw", + "feeRefresh", + "group", + selectBestForOverlappingDenominations, + ), + refund: createTimeline( + denoms, + "denomPubHash", + "stampStart", + "stampExpireWithdraw", + "feeRefund", + "group", + selectBestForOverlappingDenominations, + ), + withdraw: createTimeline( + denoms, + "denomPubHash", + "stampStart", + "stampExpireWithdraw", + "feeWithdraw", + "group", + selectBestForOverlappingDenominations, + ), + }; + + const transferFees = Object.entries( + exchange.info.wireInfo.feesForType, + ).reduce( + (prev, [wireType, infoForType]) => { + const feesByGroup = [ + ...infoForType.map((w) => ({ + ...w, + fee: Amounts.stringify(w.closingFee), + group: "closing", + })), + ...infoForType.map((w) => ({ ...w, fee: w.wireFee, group: "wire" })), + ]; + prev[wireType] = createTimeline( + feesByGroup, + "sig", + "startStamp", + "endStamp", + "fee", + "group", + selectMinimumFee, + ); + return prev; + }, + {} as Record<string, FeeDescription[]>, + ); + + const globalFeesByGroup = [ + ...exchange.info.globalFees.map((w) => ({ + ...w, + fee: w.accountFee, + group: "account", + })), + ...exchange.info.globalFees.map((w) => ({ + ...w, + fee: w.historyFee, + group: "history", + })), + ...exchange.info.globalFees.map((w) => ({ + ...w, + fee: w.purseFee, + group: "purse", + })), + ]; + + const globalFees = createTimeline( + globalFeesByGroup, + "signature", + "startDate", + "endDate", + "fee", + "group", + selectMinimumFee, + ); + + return { + exchange: { + ...exchange.info, + denomFees, + transferFees, + globalFees, + }, + }; +} + +async function internalGetExchangeResources( + ws: InternalWalletState, + tx: DbReadOnlyTransaction< + typeof WalletStoresV1, + ["exchanges", "coins", "withdrawalGroups"] + >, + exchangeBaseUrl: string, +): Promise<GetExchangeResourcesResponse> { + let numWithdrawals = 0; + let numCoins = 0; + numCoins = await tx.coins.indexes.byBaseUrl.count(exchangeBaseUrl); + numWithdrawals = + await tx.withdrawalGroups.indexes.byExchangeBaseUrl.count(exchangeBaseUrl); + const total = numWithdrawals + numCoins; + return { + hasResources: total != 0, + }; +} + +export async function deleteExchange( + ws: InternalWalletState, + req: DeleteExchangeRequest, +): Promise<void> { + 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<GetExchangeResourcesResponse> { + // 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; +} |