aboutsummaryrefslogtreecommitdiff
path: root/packages/taler-wallet-core/src/operations/exchanges.ts
diff options
context:
space:
mode:
Diffstat (limited to 'packages/taler-wallet-core/src/operations/exchanges.ts')
-rw-r--r--packages/taler-wallet-core/src/operations/exchanges.ts723
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,
+ },
+ };
+}