diff options
Diffstat (limited to 'packages/taler-wallet-core/src/operations/exchanges.ts')
-rw-r--r-- | packages/taler-wallet-core/src/operations/exchanges.ts | 723 |
1 files changed, 577 insertions, 146 deletions
diff --git a/packages/taler-wallet-core/src/operations/exchanges.ts b/packages/taler-wallet-core/src/operations/exchanges.ts index 622f04bd3..253801e93 100644 --- a/packages/taler-wallet-core/src/operations/exchanges.ts +++ b/packages/taler-wallet-core/src/operations/exchanges.ts @@ -15,6 +15,12 @@ */ /** + * @fileoverview + * Implementation of exchange entry management in wallet-core. + * The details of exchange entry management are specified in DD48. + */ + +/** * Imports. */ import { @@ -31,7 +37,9 @@ import { ExchangeAuditor, ExchangeGlobalFees, ExchangeSignKeyJson, + ExchangeEntryState, ExchangeWireAccount, + GetExchangeTosResult, GlobalFees, hashDenomPub, j2s, @@ -48,10 +56,17 @@ import { TalerProtocolDuration, TalerProtocolTimestamp, URL, + WalletNotification, WireFee, WireFeeMap, WireFeesJson, WireInfo, + FeeDescription, + DenomOperationMap, + DenominationInfo, + ExchangeDetailedResponse, + ExchangeListItem, + ExchangesListResponse, } from "@gnu-taler/taler-util"; import { getExpiry, @@ -67,24 +82,29 @@ import { WalletStoresV1, } from "../db.js"; import { + createTimeline, ExchangeEntryDbRecordStatus, ExchangeEntryDbUpdateStatus, isWithdrawableDenom, + OpenedPromise, + openPromise, + selectBestForOverlappingDenominations, + selectMinimumFee, timestampPreciseFromDb, timestampPreciseToDb, timestampProtocolToDb, WalletDbReadWriteTransaction, } from "../index.js"; -import { InternalWalletState } from "../internal-wallet-state.js"; +import { CancelFn, InternalWalletState } from "../internal-wallet-state.js"; import { checkDbInvariant } from "../util/invariants.js"; -import { - DbAccess, - GetReadOnlyAccess, - GetReadWriteAccess, -} from "../util/query.js"; +import { GetReadOnlyAccess, GetReadWriteAccess } from "../util/query.js"; import { WALLET_EXCHANGE_PROTOCOL_VERSION } from "../versions.js"; import { - runTaskWithErrorReporting, + getExchangeEntryStatusFromRecord, + getExchangeState, + getExchangeTosStatusFromRecord, + getExchangeUpdateStatusFromRecord, + makeExchangeListItem, TaskIdentifiers, TaskRunResult, TaskRunResultType, @@ -92,19 +112,19 @@ import { const logger = new Logger("exchanges.ts"); -export function getExchangeRequestTimeout(): Duration { +function getExchangeRequestTimeout(): Duration { return Duration.fromSpec({ seconds: 5, }); } -export interface ExchangeTosDownloadResult { +interface ExchangeTosDownloadResult { tosText: string; tosEtag: string; tosContentType: string; } -export async function downloadExchangeWithTermsOfService( +async function downloadExchangeWithTermsOfService( exchangeBaseUrl: string, http: HttpRequestLibrary, timeout: Duration, @@ -129,6 +149,8 @@ export async function downloadExchangeWithTermsOfService( /** * 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<{ @@ -153,9 +175,6 @@ export async function getExchangeDetails( ]); } -getExchangeDetails.makeContext = (db: DbAccess<typeof WalletStoresV1>) => - db.mktx((x) => [x.exchanges, x.exchangeDetails]); - /** * Mark a ToS version as accepted by the user. * @@ -169,13 +188,13 @@ export async function acceptExchangeTermsOfService( await ws.db .mktx((x) => [x.exchanges, x.exchangeDetails]) .runReadWrite(async (tx) => { - const d = await getExchangeDetails(tx, exchangeBaseUrl); - if (d) { - d.tosAccepted = { - etag: etag || d.tosCurrentEtag, - timestamp: timestampPreciseToDb(TalerPreciseTimestamp.now()), - }; - await tx.exchangeDetails.put(d); + 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); } }); } @@ -284,29 +303,18 @@ async function validateGlobalFees( return egf; } -export interface ExchangeInfo { - keys: ExchangeKeysDownloadResult; -} - -export async function downloadExchangeInfo( - exchangeBaseUrl: string, - http: HttpRequestLibrary, -): Promise<ExchangeInfo> { - const keysInfo = await downloadExchangeKeysInfo( - exchangeBaseUrl, - http, - Duration.getForever(), - ); - return { - keys: keysInfo, - }; -} - +/** + * 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<void> { +): Promise<{ notification?: WalletNotification }> { let exchange = await tx.exchanges.get(exchangeBaseUrl); if (!exchange) { const r: ExchangeEntryRecord = { @@ -323,9 +331,22 @@ export async function addPresetExchangeEntry( 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( @@ -339,7 +360,9 @@ async function provideExchangeRecordInTx( ): 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 = { @@ -355,15 +378,24 @@ async function provideExchangeRecordInTx( 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 }; + return { exchange, exchangeDetails, notification }; } -interface ExchangeKeysDownloadResult { +export interface ExchangeKeysDownloadResult { baseUrl: string; masterPublicKey: string; currency: string; @@ -393,28 +425,36 @@ async function downloadExchangeKeysInfo( const resp = await http.fetch(keysUrl.href, { timeout, }); - 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", - ); - } + // 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 protocolVersion = exchangeKeysJsonUnchecked.version; + 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?.compatible != true) { + 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, { @@ -425,6 +465,21 @@ async function downloadExchangeKeysInfo( ); } + 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[] = []; @@ -512,7 +567,7 @@ async function downloadExchangeKeysInfo( }; } -export async function downloadTosFromAcceptedFormat( +async function downloadTosFromAcceptedFormat( ws: InternalWalletState, baseUrl: string, timeout: Duration, @@ -546,54 +601,225 @@ export async function downloadTosFromAcceptedFormat( } /** - * FIXME: Split this into two parts: (a) triggering the exchange - * to be updated and (b) waiting for the update to finish. + * 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. + * + * For backwards compatibility, if the exchange entry doesn't exist, + * a new ephemeral entry is created. */ -export async function updateExchangeFromUrl( +export async function startUpdateExchangeEntry( ws: InternalWalletState, - baseUrl: string, - options: { - checkMasterPub?: string; - forceNow?: boolean; - cancellationToken?: CancellationToken; - } = {}, + 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; + }, + }; +} + +/** + * Wait until an exchange entry got successfully updated. + * + * Reject with an exception if the update encountered an error. + */ +export async function waitExchangeEntryUpdated( + ws: InternalWalletState, + exchangeBaseUrl: string, + cancellationToken?: CancellationToken, ): Promise<{ exchange: ExchangeEntryRecord; exchangeDetails: ExchangeDetailsRecord; }> { - const canonUrl = canonicalizeBaseUrl(baseUrl); - const res = await runTaskWithErrorReporting( + exchangeBaseUrl = canonicalizeBaseUrl(exchangeBaseUrl); + + const waiter = createNotificationWaiter( ws, - TaskIdentifiers.forExchangeUpdateFromUrl(canonUrl), - () => updateExchangeFromUrlHandler(ws, canonUrl, options), + (notif) => + notif.type == NotificationType.ExchangeStateTransition && + notif.exchangeBaseUrl === exchangeBaseUrl, ); - switch (res.type) { - case TaskRunResultType.Finished: { - const now = AbsoluteTime.now(); - const { exchange, exchangeDetails } = await ws.db - .mktx((x) => [x.exchanges, x.exchangeDetails]) - .runReadWrite(async (tx) => { - let exchange = await tx.exchanges.get(canonUrl); - const exchangeDetails = await getExchangeDetails(tx, canonUrl); - return { exchange, exchangeDetails }; - }); - if (!exchange) { - throw Error("exchange not found"); - } - if (!exchangeDetails) { - throw Error("exchange details not found"); + + const taskId = TaskIdentifiers.forExchangeUpdateFromUrl(exchangeBaseUrl); + + while (1) { + const { exchange, retryRecord } = await ws.db + .mktx((x) => [x.exchanges, x.exchangeDetails, x.operationRetries]) + .runReadOnly(async (tx) => { + const exchange = await tx.exchanges.get(exchangeBaseUrl); + const retryRecord = await tx.operationRetries.get(taskId); + return { exchange, retryRecord }; + }); + + if (!exchange) { + throw Error("exchange does not exist anymore"); + } + + switch (exchange.updateStatus) { + case ExchangeEntryDbUpdateStatus.Ready: + const details = await ws.db + .mktx((x) => [x.exchanges, x.exchangeDetails]) + .runReadOnly(async (tx) => { + return getExchangeDetails(tx, exchangeBaseUrl); + }); + if (!details) { + throw Error("exchange entry inconsistent"); + } + waiter.cancel(); + return { exchange, exchangeDetails: details }; + case ExchangeEntryDbUpdateStatus.ReadyUpdate: + case ExchangeEntryDbUpdateStatus.InitialUpdate: { + waiter.cancel(); + if (retryRecord?.lastError) { + throw TalerError.fromUncheckedDetail(retryRecord.lastError); + } + break; } - return { exchange, exchangeDetails }; + case ExchangeEntryDbUpdateStatus.UnavailableUpdate: + waiter.cancel(); + if (retryRecord?.lastError) { + throw TalerError.fromUncheckedDetail(retryRecord.lastError); + } else { + throw Error( + "updating exchange failed, error info unavailable (bug!)", + ); + } } - case TaskRunResultType.Error: - throw TalerError.fromUncheckedDetail(res.errorDetail); - default: - throw Error(`unexpected operation result (${res.type})`); + + await waiter.waitNext(); } + throw Error("not reached"); } /** - * Update or add exchange DB entry by fetching the /keys and /wire information. + * 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. + */ +export async function fetchFreshExchange( + ws: InternalWalletState, + baseUrl: string, + options: { + cancellationToken?: CancellationToken; + forceUpdate?: boolean; + } = {}, +): Promise<{ + exchange: ExchangeEntryRecord; + exchangeDetails: ExchangeDetailsRecord; +}> { + const canonUrl = canonicalizeBaseUrl(baseUrl); + await startUpdateExchangeEntry(ws, canonUrl, { + forceUpdate: options.forceUpdate, + }); + return waitExchangeEntryUpdated(ws, canonUrl, options.cancellationToken); +} + +/** + * 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. */ @@ -601,48 +827,11 @@ export async function updateExchangeFromUrlHandler( ws: InternalWalletState, exchangeBaseUrl: string, options: { - checkMasterPub?: string; - forceNow?: boolean; cancellationToken?: CancellationToken; } = {}, ): Promise<TaskRunResult> { - const forceNow = options.forceNow ?? false; - logger.trace( - `updating exchange info for ${exchangeBaseUrl}, forced: ${forceNow}`, - ); - - const now = AbsoluteTime.now(); + logger.trace(`updating exchange info for ${exchangeBaseUrl}`); exchangeBaseUrl = canonicalizeBaseUrl(exchangeBaseUrl); - let isNewExchange = true; - const { exchange, exchangeDetails } = await ws.db - .mktx((x) => [x.exchanges, x.exchangeDetails]) - .runReadWrite(async (tx) => { - let oldExch = await tx.exchanges.get(exchangeBaseUrl); - if (oldExch) { - isNewExchange = false; - } - return provideExchangeRecordInTx(ws, tx, exchangeBaseUrl, now); - }); - - if ( - !forceNow && - exchangeDetails !== undefined && - !AbsoluteTime.isExpired( - AbsoluteTime.fromPreciseTimestamp( - timestampPreciseFromDb(exchange.nextUpdateStamp), - ), - ) - ) { - logger.trace("using existing exchange info"); - - if (options.checkMasterPub) { - if (exchangeDetails.masterPublicKey !== options.checkMasterPub) { - throw Error(`master public key mismatch`); - } - } - - return TaskRunResult.finished(); - } logger.trace("updating exchange /keys info"); @@ -654,12 +843,6 @@ export async function updateExchangeFromUrlHandler( timeout, ); - if (options.checkMasterPub) { - if (keysInfo.masterPublicKey !== options.checkMasterPub) { - throw Error(`master public key mismatch`); - } - } - logger.trace("validating exchange wire info"); const version = LibtoolVersion.parseVersion(keysInfo.protocolVersion); @@ -740,6 +923,7 @@ export async function updateExchangeFromUrlHandler( logger.warn(`exchange ${exchangeBaseUrl} no longer present`); return; } + const oldExchangeState = getExchangeState(r); const existingDetails = await getExchangeDetails(tx, r.baseUrl); if (!existingDetails) { detailsPointerChanged = true; @@ -753,7 +937,6 @@ export async function updateExchangeFromUrlHandler( } // FIXME: We need to do some consistency checks! } - const existingTosAccepted = existingDetails?.tosAccepted; const newDetails: ExchangeDetailsRecord = { auditors: keysInfo.auditors, currency: keysInfo.currency, @@ -763,10 +946,9 @@ export async function updateExchangeFromUrlHandler( globalFees, exchangeBaseUrl: r.baseUrl, wireInfo, - tosCurrentEtag: tosDownload.tosEtag, - tosAccepted: existingTosAccepted, ageMask, }; + r.tosCurrentEtag = tosDownload.tosEtag; if (existingDetails?.rowId) { newDetails.rowId = existingDetails.rowId; } @@ -787,6 +969,7 @@ export async function updateExchangeFromUrlHandler( 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"); @@ -881,14 +1064,18 @@ export async function updateExchangeFromUrlHandler( recoupGroupId = await ws.recoupOps.createRecoupGroup( ws, tx, - exchange.baseUrl, + exchangeBaseUrl, newlyRevokedCoinPubs, ); } + const newExchangeState = getExchangeState(r); + return { exchange: r, exchangeDetails: newDetails, + oldExchangeState, + newExchangeState, }; }); @@ -904,11 +1091,12 @@ export async function updateExchangeFromUrlHandler( logger.trace("done updating exchange info in database"); - if (isNewExchange) { - ws.notify({ - type: NotificationType.ExchangeAdded, - }); - } + ws.notify({ + type: NotificationType.ExchangeStateTransition, + exchangeBaseUrl, + newExchangeState: updated.newExchangeState, + oldExchangeState: updated.oldExchangeState, + }); return TaskRunResult.finished(); } @@ -926,8 +1114,8 @@ export async function getExchangePaytoUri( ): Promise<string> { // We do the update here, since the exchange might not even exist // yet in our database. - const details = await getExchangeDetails - .makeContext(ws.db) + const details = await ws.db + .mktx((x) => [x.exchangeDetails, x.exchanges]) .runReadOnly(async (tx) => { return getExchangeDetails(tx, exchangeBaseUrl); }); @@ -947,3 +1135,246 @@ export async function getExchangePaytoUri( )}`, ); } + +/** + * 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[], +): Promise<GetExchangeTosResult> { + // FIXME: download ToS in acceptable format if passed! + const { exchange, exchangeDetails } = await fetchFreshExchange( + ws, + exchangeBaseUrl, + ); + + const tosDownload = await downloadTosFromAcceptedFormat( + ws, + exchangeBaseUrl, + getExchangeRequestTimeout(), + acceptedFormat, + ); + + 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: exchange.tosAcceptedEtag, + currentEtag: tosDownload.tosEtag, + content: tosDownload.tosText, + contentType: tosDownload.tosContentType, + tosStatus: getExchangeTosStatusFromRecord(exchange), + }; +} + +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 + .iter(ex.baseUrl) + .toArray(); + + 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, + }, + }; +} |