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.ts1433
1 files changed, 0 insertions, 1433 deletions
diff --git a/packages/taler-wallet-core/src/operations/exchanges.ts b/packages/taler-wallet-core/src/operations/exchanges.ts
deleted file mode 100644
index 8f878ecc0..000000000
--- a/packages/taler-wallet-core/src/operations/exchanges.ts
+++ /dev/null
@@ -1,1433 +0,0 @@
-/*
- This file is part of GNU Taler
- (C) 2019 GNUnet e.V.
-
- GNU Taler is free software; you can redistribute it and/or modify it under the
- terms of the GNU General Public License as published by the Free Software
- Foundation; either version 3, or (at your option) any later version.
-
- GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
- WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
- A PARTICULAR PURPOSE. See the GNU General Public License for more details.
-
- You should have received a copy of the GNU General Public License along with
- GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
- */
-
-/**
- * @fileoverview
- * Implementation of exchange entry management in wallet-core.
- * The details of exchange entry management are specified in DD48.
- */
-
-/**
- * Imports.
- */
-import {
- AbsoluteTime,
- Amounts,
- CancellationToken,
- DenomKeyType,
- DenomOperationMap,
- DenominationInfo,
- DenominationPubKey,
- Duration,
- ExchangeAuditor,
- ExchangeDetailedResponse,
- ExchangeGlobalFees,
- ExchangeListItem,
- ExchangeSignKeyJson,
- ExchangeTosStatus,
- ExchangeWireAccount,
- ExchangesListResponse,
- FeeDescription,
- GetExchangeTosResult,
- GlobalFees,
- LibtoolVersion,
- Logger,
- NotificationType,
- Recoup,
- TalerError,
- TalerErrorCode,
- TalerErrorDetail,
- TalerPreciseTimestamp,
- TalerProtocolDuration,
- TalerProtocolTimestamp,
- URL,
- WalletNotification,
- WireFee,
- WireFeeMap,
- WireFeesJson,
- WireInfo,
- canonicalizeBaseUrl,
- codecForExchangeKeysJson,
- durationFromSpec,
- encodeCrock,
- hashDenomPub,
- j2s,
- makeErrorDetail,
- parsePaytoUri,
-} from "@gnu-taler/taler-util";
-import {
- HttpRequestLibrary,
- getExpiry,
- readSuccessResponseJsonOrThrow,
- readSuccessResponseTextOrThrow,
-} from "@gnu-taler/taler-util/http";
-import {
- DenominationRecord,
- DenominationVerificationStatus,
- ExchangeDetailsRecord,
- ExchangeEntryRecord,
- WalletStoresV1,
-} from "../db.js";
-import {
- ExchangeEntryDbRecordStatus,
- ExchangeEntryDbUpdateStatus,
- OpenedPromise,
- PendingTaskType,
- WalletDbReadWriteTransaction,
- createTimeline,
- isWithdrawableDenom,
- openPromise,
- selectBestForOverlappingDenominations,
- selectMinimumFee,
- timestampOptionalAbsoluteFromDb,
- timestampOptionalPreciseFromDb,
- timestampPreciseFromDb,
- timestampPreciseToDb,
- timestampProtocolToDb,
-} from "../index.js";
-import { CancelFn, InternalWalletState } from "../internal-wallet-state.js";
-import { checkDbInvariant } from "../util/invariants.js";
-import { GetReadOnlyAccess, GetReadWriteAccess } from "../util/query.js";
-import { WALLET_EXCHANGE_PROTOCOL_VERSION } from "../versions.js";
-import {
- TaskIdentifiers,
- TaskRunResult,
- TaskRunResultType,
- constructTaskIdentifier,
- getExchangeState,
- getExchangeTosStatusFromRecord,
- makeExchangeListItem,
- runTaskWithErrorReporting,
-} from "./common.js";
-
-const logger = new Logger("exchanges.ts");
-
-function getExchangeRequestTimeout(): Duration {
- return Duration.fromSpec({
- seconds: 5,
- });
-}
-
-interface ExchangeTosDownloadResult {
- tosText: string;
- tosEtag: string;
- tosContentType: string;
- tosContentLanguage: string | undefined;
- tosAvailableLanguages: string[];
-}
-
-async function downloadExchangeWithTermsOfService(
- exchangeBaseUrl: string,
- http: HttpRequestLibrary,
- timeout: Duration,
- acceptFormat: string,
- acceptLanguage: string | undefined,
-): Promise<ExchangeTosDownloadResult> {
- logger.trace(`downloading exchange tos (type ${acceptFormat})`);
- const reqUrl = new URL("terms", exchangeBaseUrl);
- const headers: {
- Accept: string;
- "Accept-Language"?: string;
- } = {
- Accept: acceptFormat,
- };
-
- if (acceptLanguage) {
- headers["Accept-Language"] = acceptLanguage;
- }
-
- const resp = await http.fetch(reqUrl.href, {
- headers,
- timeout,
- });
- const tosText = await readSuccessResponseTextOrThrow(resp);
- const tosEtag = resp.headers.get("etag") || "unknown";
- const tosContentLanguage = resp.headers.get("content-language") || undefined;
- const tosContentType = resp.headers.get("content-type") || "text/plain";
- const availLangStr = resp.headers.get("avail-languages") || "";
- // Work around exchange bug that reports the same language multiple times.
- const availLangSet = new Set<string>(
- availLangStr.split(",").map((x) => x.trim()),
- );
- const tosAvailableLanguages = [...availLangSet];
-
- return {
- tosText,
- tosEtag,
- tosContentType,
- tosContentLanguage,
- tosAvailableLanguages,
- };
-}
-
-/**
- * Get exchange details from the database.
- *
- * FIXME: Should we encapsulate the result better, instead of returning the raw DB records here?
- */
-export async function getExchangeDetails(
- tx: GetReadOnlyAccess<{
- exchanges: typeof WalletStoresV1.exchanges;
- exchangeDetails: typeof WalletStoresV1.exchangeDetails;
- }>,
- exchangeBaseUrl: string,
-): Promise<ExchangeDetailsRecord | undefined> {
- const r = await tx.exchanges.get(exchangeBaseUrl);
- if (!r) {
- return;
- }
- const dp = r.detailsPointer;
- if (!dp) {
- return;
- }
- const { currency, masterPublicKey } = dp;
- return await tx.exchangeDetails.indexes.byPointer.get([
- r.baseUrl,
- currency,
- masterPublicKey,
- ]);
-}
-
-/**
- * Mark a ToS version as accepted by the user.
- *
- * @param etag version of the ToS to accept, or current ToS version of not given
- */
-export async function acceptExchangeTermsOfService(
- ws: InternalWalletState,
- exchangeBaseUrl: string,
- etag: string | undefined,
-): Promise<void> {
- await ws.db
- .mktx((x) => [x.exchanges, x.exchangeDetails])
- .runReadWrite(async (tx) => {
- const exch = await tx.exchanges.get(exchangeBaseUrl);
- if (exch && exch.tosCurrentEtag) {
- exch.tosAcceptedEtag = exch.tosCurrentEtag;
- exch.tosAcceptedTimestamp = timestampPreciseToDb(
- TalerPreciseTimestamp.now(),
- );
- await tx.exchanges.put(exch);
- }
- });
-}
-
-async function validateWireInfo(
- ws: InternalWalletState,
- versionCurrent: number,
- wireInfo: ExchangeKeysDownloadResult,
- masterPublicKey: string,
-): Promise<WireInfo> {
- for (const a of wireInfo.accounts) {
- logger.trace("validating exchange acct");
- let isValid = false;
- if (ws.config.testing.insecureTrustExchange) {
- isValid = true;
- } else {
- const { valid: v } = await ws.cryptoApi.isValidWireAccount({
- masterPub: masterPublicKey,
- paytoUri: a.payto_uri,
- sig: a.master_sig,
- versionCurrent,
- conversionUrl: a.conversion_url,
- creditRestrictions: a.credit_restrictions,
- debitRestrictions: a.debit_restrictions,
- });
- isValid = v;
- }
- if (!isValid) {
- throw Error("exchange acct signature invalid");
- }
- }
- logger.trace("account validation done");
- const feesForType: WireFeeMap = {};
- for (const wireMethod of Object.keys(wireInfo.wireFees)) {
- const feeList: WireFee[] = [];
- for (const x of wireInfo.wireFees[wireMethod]) {
- const startStamp = x.start_date;
- const endStamp = x.end_date;
- const fee: WireFee = {
- closingFee: Amounts.stringify(x.closing_fee),
- endStamp,
- sig: x.sig,
- startStamp,
- wireFee: Amounts.stringify(x.wire_fee),
- };
- let isValid = false;
- if (ws.config.testing.insecureTrustExchange) {
- isValid = true;
- } else {
- const { valid: v } = await ws.cryptoApi.isValidWireFee({
- masterPub: masterPublicKey,
- type: wireMethod,
- wf: fee,
- });
- isValid = v;
- }
- if (!isValid) {
- throw Error("exchange wire fee signature invalid");
- }
- feeList.push(fee);
- }
- feesForType[wireMethod] = feeList;
- }
-
- return {
- accounts: wireInfo.accounts,
- feesForType,
- };
-}
-
-async function validateGlobalFees(
- ws: InternalWalletState,
- fees: GlobalFees[],
- masterPub: string,
-): Promise<ExchangeGlobalFees[]> {
- const egf: ExchangeGlobalFees[] = [];
- for (const gf of fees) {
- logger.trace("validating exchange global fees");
- let isValid = false;
- if (ws.config.testing.insecureTrustExchange) {
- isValid = true;
- } else {
- const { valid: v } = await ws.cryptoApi.isValidGlobalFees({
- masterPub,
- gf,
- });
- isValid = v;
- }
-
- if (!isValid) {
- throw Error("exchange global fees signature invalid: " + gf.master_sig);
- }
- egf.push({
- accountFee: Amounts.stringify(gf.account_fee),
- historyFee: Amounts.stringify(gf.history_fee),
- purseFee: Amounts.stringify(gf.purse_fee),
- startDate: gf.start_date,
- endDate: gf.end_date,
- signature: gf.master_sig,
- historyTimeout: gf.history_expiration,
- purseLimit: gf.purse_account_limit,
- purseTimeout: gf.purse_timeout,
- });
- }
-
- return egf;
-}
-
-/**
- * Add an exchange entry to the wallet database in the
- * entry state "preset".
- *
- * Returns the notification to the caller that should be emitted
- * if the DB transaction succeeds.
- */
-export async function addPresetExchangeEntry(
- tx: WalletDbReadWriteTransaction<"exchanges">,
- exchangeBaseUrl: string,
- currencyHint?: string,
-): Promise<{ notification?: WalletNotification }> {
- let exchange = await tx.exchanges.get(exchangeBaseUrl);
- if (!exchange) {
- const r: ExchangeEntryRecord = {
- entryStatus: ExchangeEntryDbRecordStatus.Preset,
- updateStatus: ExchangeEntryDbUpdateStatus.Initial,
- baseUrl: exchangeBaseUrl,
- presetCurrencyHint: currencyHint,
- detailsPointer: undefined,
- lastUpdate: undefined,
- lastKeysEtag: undefined,
- nextRefreshCheckStamp: timestampPreciseToDb(
- AbsoluteTime.toPreciseTimestamp(AbsoluteTime.never()),
- ),
- nextUpdateStamp: timestampPreciseToDb(
- AbsoluteTime.toPreciseTimestamp(AbsoluteTime.never()),
- ),
- tosAcceptedEtag: undefined,
- tosAcceptedTimestamp: undefined,
- tosCurrentEtag: undefined,
- };
- await tx.exchanges.put(r);
- return {
- notification: {
- type: NotificationType.ExchangeStateTransition,
- exchangeBaseUrl: exchangeBaseUrl,
- // Exchange did not exist yet
- oldExchangeState: undefined,
- newExchangeState: getExchangeState(r),
- },
- };
- }
- return {};
-}
-
-async function provideExchangeRecordInTx(
- ws: InternalWalletState,
- tx: GetReadWriteAccess<{
- exchanges: typeof WalletStoresV1.exchanges;
- exchangeDetails: typeof WalletStoresV1.exchangeDetails;
- }>,
- baseUrl: string,
- now: AbsoluteTime,
-): Promise<{
- exchange: ExchangeEntryRecord;
- exchangeDetails: ExchangeDetailsRecord | undefined;
- notification?: WalletNotification;
-}> {
- let notification: WalletNotification | undefined = undefined;
- let exchange = await tx.exchanges.get(baseUrl);
- if (!exchange) {
- const r: ExchangeEntryRecord = {
- entryStatus: ExchangeEntryDbRecordStatus.Ephemeral,
- updateStatus: ExchangeEntryDbUpdateStatus.InitialUpdate,
- baseUrl: baseUrl,
- detailsPointer: undefined,
- lastUpdate: undefined,
- nextUpdateStamp: timestampPreciseToDb(
- AbsoluteTime.toPreciseTimestamp(AbsoluteTime.never()),
- ),
- nextRefreshCheckStamp: timestampPreciseToDb(
- AbsoluteTime.toPreciseTimestamp(AbsoluteTime.never()),
- ),
- lastKeysEtag: undefined,
- tosAcceptedEtag: undefined,
- tosAcceptedTimestamp: undefined,
- tosCurrentEtag: undefined,
- };
- await tx.exchanges.put(r);
- exchange = r;
- notification = {
- type: NotificationType.ExchangeStateTransition,
- exchangeBaseUrl: r.baseUrl,
- oldExchangeState: undefined,
- newExchangeState: getExchangeState(r),
- };
- }
- const exchangeDetails = await getExchangeDetails(tx, baseUrl);
- return { exchange, exchangeDetails, notification };
-}
-
-export interface ExchangeKeysDownloadResult {
- baseUrl: string;
- masterPublicKey: string;
- currency: string;
- auditors: ExchangeAuditor[];
- currentDenominations: DenominationRecord[];
- protocolVersion: string;
- signingKeys: ExchangeSignKeyJson[];
- reserveClosingDelay: TalerProtocolDuration;
- expiry: TalerProtocolTimestamp;
- recoup: Recoup[];
- listIssueDate: TalerProtocolTimestamp;
- globalFees: GlobalFees[];
- accounts: ExchangeWireAccount[];
- wireFees: { [methodName: string]: WireFeesJson[] };
-}
-
-/**
- * Download and validate an exchange's /keys data.
- */
-async function downloadExchangeKeysInfo(
- baseUrl: string,
- http: HttpRequestLibrary,
- timeout: Duration,
-): Promise<ExchangeKeysDownloadResult> {
- const keysUrl = new URL("keys", baseUrl);
-
- const resp = await http.fetch(keysUrl.href, {
- timeout,
- });
-
- // We must make sure to parse out the protocol version
- // before we validate the body.
- // Otherwise the parser might complain with a hard to understand
- // message about some other field, when it is just a version
- // incompatibility.
-
- const keysJson = await resp.json();
-
- const protocolVersion = keysJson.version;
- if (typeof protocolVersion !== "string") {
- throw Error("bad exchange, does not even specify protocol version");
- }
-
- const versionRes = LibtoolVersion.compare(
- WALLET_EXCHANGE_PROTOCOL_VERSION,
- protocolVersion,
- );
- if (!versionRes) {
- throw TalerError.fromDetail(
- TalerErrorCode.WALLET_RECEIVED_MALFORMED_RESPONSE,
- {
- requestUrl: resp.requestUrl,
- httpStatusCode: resp.status,
- requestMethod: resp.requestMethod,
- },
- "exchange protocol version malformed",
- );
- }
- if (!versionRes.compatible) {
- throw TalerError.fromDetail(
- TalerErrorCode.WALLET_EXCHANGE_PROTOCOL_VERSION_INCOMPATIBLE,
- {
- exchangeProtocolVersion: protocolVersion,
- walletProtocolVersion: WALLET_EXCHANGE_PROTOCOL_VERSION,
- },
- "exchange protocol version not compatible with wallet",
- );
- }
-
- const exchangeKeysJsonUnchecked = await readSuccessResponseJsonOrThrow(
- resp,
- codecForExchangeKeysJson(),
- );
-
- if (exchangeKeysJsonUnchecked.denominations.length === 0) {
- throw TalerError.fromDetail(
- TalerErrorCode.WALLET_EXCHANGE_DENOMINATIONS_INSUFFICIENT,
- {
- exchangeBaseUrl: baseUrl,
- },
- "exchange doesn't offer any denominations",
- );
- }
-
- const currency = exchangeKeysJsonUnchecked.currency;
-
- const currentDenominations: DenominationRecord[] = [];
-
- for (const denomGroup of exchangeKeysJsonUnchecked.denominations) {
- switch (denomGroup.cipher) {
- case "RSA":
- case "RSA+age_restricted": {
- let ageMask = 0;
- if (denomGroup.cipher === "RSA+age_restricted") {
- ageMask = denomGroup.age_mask;
- }
- for (const denomIn of denomGroup.denoms) {
- const denomPub: DenominationPubKey = {
- age_mask: ageMask,
- cipher: DenomKeyType.Rsa,
- rsa_public_key: denomIn.rsa_pub,
- };
- const denomPubHash = encodeCrock(hashDenomPub(denomPub));
- const value = Amounts.parseOrThrow(denomGroup.value);
- const rec: DenominationRecord = {
- denomPub,
- denomPubHash,
- exchangeBaseUrl: baseUrl,
- exchangeMasterPub: exchangeKeysJsonUnchecked.master_public_key,
- isOffered: true,
- isRevoked: false,
- value: Amounts.stringify(value),
- currency: value.currency,
- stampExpireDeposit: timestampProtocolToDb(
- denomIn.stamp_expire_deposit,
- ),
- stampExpireLegal: timestampProtocolToDb(denomIn.stamp_expire_legal),
- stampExpireWithdraw: timestampProtocolToDb(
- denomIn.stamp_expire_withdraw,
- ),
- stampStart: timestampProtocolToDb(denomIn.stamp_start),
- verificationStatus: DenominationVerificationStatus.Unverified,
- masterSig: denomIn.master_sig,
- listIssueDate: timestampProtocolToDb(
- exchangeKeysJsonUnchecked.list_issue_date,
- ),
- fees: {
- feeDeposit: Amounts.stringify(denomGroup.fee_deposit),
- feeRefresh: Amounts.stringify(denomGroup.fee_refresh),
- feeRefund: Amounts.stringify(denomGroup.fee_refund),
- feeWithdraw: Amounts.stringify(denomGroup.fee_withdraw),
- },
- };
- currentDenominations.push(rec);
- }
- break;
- }
- case "CS+age_restricted":
- case "CS":
- logger.warn("Clause-Schnorr denominations not supported");
- continue;
- default:
- logger.warn(
- `denomination type ${(denomGroup as any).cipher} not supported`,
- );
- continue;
- }
- }
-
- return {
- masterPublicKey: exchangeKeysJsonUnchecked.master_public_key,
- currency,
- baseUrl: exchangeKeysJsonUnchecked.base_url,
- auditors: exchangeKeysJsonUnchecked.auditors,
- currentDenominations,
- protocolVersion: exchangeKeysJsonUnchecked.version,
- signingKeys: exchangeKeysJsonUnchecked.signkeys,
- reserveClosingDelay: exchangeKeysJsonUnchecked.reserve_closing_delay,
- expiry: AbsoluteTime.toProtocolTimestamp(
- getExpiry(resp, {
- minDuration: durationFromSpec({ hours: 1 }),
- }),
- ),
- recoup: exchangeKeysJsonUnchecked.recoup ?? [],
- listIssueDate: exchangeKeysJsonUnchecked.list_issue_date,
- globalFees: exchangeKeysJsonUnchecked.global_fees,
- accounts: exchangeKeysJsonUnchecked.accounts,
- wireFees: exchangeKeysJsonUnchecked.wire_fees,
- };
-}
-
-async function downloadTosFromAcceptedFormat(
- ws: InternalWalletState,
- baseUrl: string,
- timeout: Duration,
- acceptedFormat?: string[],
- acceptLanguage?: string,
-): Promise<ExchangeTosDownloadResult> {
- let tosFound: ExchangeTosDownloadResult | undefined;
- // Remove this when exchange supports multiple content-type in accept header
- if (acceptedFormat)
- for (const format of acceptedFormat) {
- const resp = await downloadExchangeWithTermsOfService(
- baseUrl,
- ws.http,
- timeout,
- format,
- acceptLanguage,
- );
- if (resp.tosContentType === format) {
- tosFound = resp;
- break;
- }
- }
- if (tosFound !== undefined) {
- return tosFound;
- }
- // If none of the specified format was found try text/plain
- return await downloadExchangeWithTermsOfService(
- baseUrl,
- ws.http,
- timeout,
- "text/plain",
- acceptLanguage,
- );
-}
-
-/**
- * Transition an exchange into an updating state.
- *
- * If the update is forced, the exchange is put into an updating state
- * even if the old information should still be up to date.
- *
- * If the exchange entry doesn't exist,
- * a new ephemeral entry is created.
- */
-export async function startUpdateExchangeEntry(
- ws: InternalWalletState,
- exchangeBaseUrl: string,
- options: { forceUpdate?: boolean } = {},
-): Promise<void> {
- const canonBaseUrl = canonicalizeBaseUrl(exchangeBaseUrl);
-
- const now = AbsoluteTime.now();
-
- const { notification } = await ws.db
- .mktx((x) => [x.exchanges, x.exchangeDetails])
- .runReadWrite(async (tx) => {
- return provideExchangeRecordInTx(ws, tx, exchangeBaseUrl, now);
- });
-
- if (notification) {
- ws.notify(notification);
- }
-
- const { oldExchangeState, newExchangeState } = await ws.db
- .mktx((x) => [x.exchanges, x.operationRetries])
- .runReadWrite(async (tx) => {
- const r = await tx.exchanges.get(canonBaseUrl);
- if (!r) {
- throw Error("exchange not found");
- }
- const oldExchangeState = getExchangeState(r);
- switch (r.updateStatus) {
- case ExchangeEntryDbUpdateStatus.UnavailableUpdate:
- break;
- case ExchangeEntryDbUpdateStatus.Suspended:
- break;
- case ExchangeEntryDbUpdateStatus.ReadyUpdate:
- break;
- case ExchangeEntryDbUpdateStatus.Ready: {
- const nextUpdateTimestamp = AbsoluteTime.fromPreciseTimestamp(
- timestampPreciseFromDb(r.nextUpdateStamp),
- );
- // Only update if entry is outdated or update is forced.
- if (
- options.forceUpdate ||
- AbsoluteTime.isExpired(nextUpdateTimestamp)
- ) {
- r.updateStatus = ExchangeEntryDbUpdateStatus.ReadyUpdate;
- }
- break;
- }
- case ExchangeEntryDbUpdateStatus.Initial:
- r.updateStatus = ExchangeEntryDbUpdateStatus.InitialUpdate;
- break;
- }
- await tx.exchanges.put(r);
- const newExchangeState = getExchangeState(r);
- // Reset retries for updating the exchange entry.
- const taskId = TaskIdentifiers.forExchangeUpdate(r);
- await tx.operationRetries.delete(taskId);
- return { oldExchangeState, newExchangeState };
- });
- ws.notify({
- type: NotificationType.ExchangeStateTransition,
- exchangeBaseUrl: canonBaseUrl,
- newExchangeState: newExchangeState,
- oldExchangeState: oldExchangeState,
- });
- ws.workAvailable.trigger();
-}
-
-export interface NotificationWaiter {
- waitNext(): Promise<void>;
- cancel(): void;
-}
-
-export function createNotificationWaiter(
- ws: InternalWalletState,
- pred: (x: WalletNotification) => boolean,
-): NotificationWaiter {
- ws.ensureTaskLoopRunning();
- let cancelFn: CancelFn | undefined = undefined;
- let p: OpenedPromise<void> | undefined = undefined;
-
- return {
- cancel() {
- cancelFn?.();
- },
- waitNext(): Promise<void> {
- if (!p) {
- p = openPromise();
- cancelFn = ws.addNotificationListener((notif) => {
- if (pred(notif)) {
- // We got a notification that matches our predicate.
- // Resolve promise for existing waiters,
- // and create a new promise to wait for the next
- // notification occurrence.
- const myResolve = p?.resolve;
- const myCancel = cancelFn;
- p = undefined;
- cancelFn = undefined;
- myResolve?.();
- myCancel?.();
- }
- });
- }
- return p.promise;
- },
- };
-}
-
-/**
- * Basic information about an exchange in a ready state.
- */
-export interface ReadyExchangeSummary {
- exchangeBaseUrl: string;
- currency: string;
- masterPub: string;
- tosStatus: ExchangeTosStatus;
- tosAcceptedEtag: string | undefined;
- tosCurrentEtag: string | undefined;
- wireInfo: WireInfo;
- protocolVersionRange: string;
- tosAcceptedTimestamp: TalerPreciseTimestamp | undefined;
-}
-
-/**
- * Ensure that a fresh exchange entry exists for the given
- * exchange base URL.
- *
- * The cancellation token can be used to abort waiting for the
- * updated exchange entry.
- *
- * If an exchange entry for the database doesn't exist in the
- * DB, it will be added ephemerally.
- *
- * If the expectedMasterPub is given and does not match the actual
- * master pub, an exception will be thrown. However, the exchange
- * will still have been added as an ephemeral exchange entry.
- */
-export async function fetchFreshExchange(
- ws: InternalWalletState,
- baseUrl: string,
- options: {
- cancellationToken?: CancellationToken;
- forceUpdate?: boolean;
- expectedMasterPub?: string;
- } = {},
-): Promise<ReadyExchangeSummary> {
- const canonUrl = canonicalizeBaseUrl(baseUrl);
- const operationId = constructTaskIdentifier({
- tag: PendingTaskType.ExchangeUpdate,
- exchangeBaseUrl: canonUrl,
- });
-
- const oldExchange = await ws.db
- .mktx((x) => [x.exchanges])
- .runReadOnly(async (tx) => {
- return tx.exchanges.get(canonUrl);
- });
-
- let needsUpdate = false;
-
- if (!oldExchange || options.forceUpdate) {
- needsUpdate = true;
- await startUpdateExchangeEntry(ws, canonUrl, {
- forceUpdate: options.forceUpdate,
- });
- } else {
- const nextUpdate = timestampOptionalAbsoluteFromDb(
- oldExchange.nextUpdateStamp,
- );
- if (
- nextUpdate == null ||
- AbsoluteTime.isExpired(nextUpdate) ||
- oldExchange.updateStatus !== ExchangeEntryDbUpdateStatus.Ready
- ) {
- needsUpdate = true;
- }
- }
-
- if (needsUpdate) {
- await runTaskWithErrorReporting(ws, operationId, () =>
- updateExchangeFromUrlHandler(ws, canonUrl),
- );
- }
-
- const { exchange, exchangeDetails } = await ws.db
- .mktx((x) => [x.exchanges, x.exchangeDetails])
- .runReadOnly(async (tx) => {
- const exchange = await tx.exchanges.get(canonUrl);
- const exchangeDetails = await getExchangeDetails(tx, canonUrl);
- return { exchange, exchangeDetails };
- });
-
- if (!exchange) {
- throw Error("exchange entry does not exist anymore");
- }
-
- switch (exchange.updateStatus) {
- case ExchangeEntryDbUpdateStatus.Ready:
- case ExchangeEntryDbUpdateStatus.ReadyUpdate:
- break;
- default:
- throw Error("unable to update exchange");
- }
-
- if (!exchangeDetails) {
- throw Error("invariant failed");
- }
-
- const res: ReadyExchangeSummary = {
- currency: exchangeDetails.currency,
- exchangeBaseUrl: canonUrl,
- masterPub: exchangeDetails.masterPublicKey,
- tosStatus: getExchangeTosStatusFromRecord(exchange),
- tosAcceptedEtag: exchange.tosAcceptedEtag,
- wireInfo: exchangeDetails.wireInfo,
- protocolVersionRange: exchangeDetails.protocolVersionRange,
- tosCurrentEtag: exchange.tosCurrentEtag,
- tosAcceptedTimestamp: timestampOptionalPreciseFromDb(
- exchange.tosAcceptedTimestamp,
- ),
- };
-
- if (options.expectedMasterPub) {
- if (res.masterPub !== options.expectedMasterPub) {
- throw Error(
- "public key of the exchange does not match expected public key",
- );
- }
- }
- return res;
-}
-
-/**
- * Update an exchange entry in the wallet's database
- * by fetching the /keys and /wire information.
- * Optionally link the reserve entry to the new or existing
- * exchange entry in then DB.
- */
-export async function updateExchangeFromUrlHandler(
- ws: InternalWalletState,
- exchangeBaseUrl: string,
- options: {
- cancellationToken?: CancellationToken;
- } = {},
-): Promise<TaskRunResult> {
- logger.trace(`updating exchange info for ${exchangeBaseUrl}`);
- exchangeBaseUrl = canonicalizeBaseUrl(exchangeBaseUrl);
-
- logger.trace("updating exchange /keys info");
-
- const timeout = getExchangeRequestTimeout();
-
- const keysInfo = await downloadExchangeKeysInfo(
- exchangeBaseUrl,
- ws.http,
- timeout,
- );
-
- logger.trace("validating exchange wire info");
-
- const version = LibtoolVersion.parseVersion(keysInfo.protocolVersion);
- if (!version) {
- // Should have been validated earlier.
- throw Error("unexpected invalid version");
- }
-
- const wireInfo = await validateWireInfo(
- ws,
- version.current,
- keysInfo,
- keysInfo.masterPublicKey,
- );
-
- const globalFees = await validateGlobalFees(
- ws,
- keysInfo.globalFees,
- keysInfo.masterPublicKey,
- );
- if (keysInfo.baseUrl != exchangeBaseUrl) {
- logger.warn("exchange base URL mismatch");
- const errorDetail: TalerErrorDetail = makeErrorDetail(
- TalerErrorCode.WALLET_EXCHANGE_BASE_URL_MISMATCH,
- {
- urlWallet: exchangeBaseUrl,
- urlExchange: keysInfo.baseUrl,
- },
- );
- return {
- type: TaskRunResultType.Error,
- errorDetail,
- };
- }
-
- logger.trace("finished validating exchange /wire info");
-
- // We download the text/plain version here,
- // because that one needs to exist, and we
- // will get the current etag from the response.
- const tosDownload = await downloadTosFromAcceptedFormat(
- ws,
- exchangeBaseUrl,
- timeout,
- ["text/plain"],
- );
-
- let recoupGroupId: string | undefined;
-
- logger.trace("updating exchange info in database");
-
- let detailsPointerChanged = false;
-
- let ageMask = 0;
- for (const x of keysInfo.currentDenominations) {
- if (
- isWithdrawableDenom(x, ws.config.testing.denomselAllowLate) &&
- x.denomPub.age_mask != 0
- ) {
- ageMask = x.denomPub.age_mask;
- break;
- }
- }
-
- const updated = await ws.db
- .mktx((x) => [
- x.exchanges,
- x.exchangeDetails,
- x.exchangeSignKeys,
- x.denominations,
- x.coins,
- x.refreshGroups,
- x.recoupGroups,
- ])
- .runReadWrite(async (tx) => {
- const r = await tx.exchanges.get(exchangeBaseUrl);
- if (!r) {
- logger.warn(`exchange ${exchangeBaseUrl} no longer present`);
- return;
- }
- const oldExchangeState = getExchangeState(r);
- const existingDetails = await getExchangeDetails(tx, r.baseUrl);
- if (!existingDetails) {
- detailsPointerChanged = true;
- }
- if (existingDetails) {
- if (existingDetails.masterPublicKey !== keysInfo.masterPublicKey) {
- detailsPointerChanged = true;
- }
- if (existingDetails.currency !== keysInfo.currency) {
- detailsPointerChanged = true;
- }
- // FIXME: We need to do some consistency checks!
- }
- const newDetails: ExchangeDetailsRecord = {
- auditors: keysInfo.auditors,
- currency: keysInfo.currency,
- masterPublicKey: keysInfo.masterPublicKey,
- protocolVersionRange: keysInfo.protocolVersion,
- reserveClosingDelay: keysInfo.reserveClosingDelay,
- globalFees,
- exchangeBaseUrl: r.baseUrl,
- wireInfo,
- ageMask,
- };
- r.tosCurrentEtag = tosDownload.tosEtag;
- if (existingDetails?.rowId) {
- newDetails.rowId = existingDetails.rowId;
- }
- r.lastUpdate = timestampPreciseToDb(TalerPreciseTimestamp.now());
- r.nextUpdateStamp = timestampPreciseToDb(
- AbsoluteTime.toPreciseTimestamp(
- AbsoluteTime.fromProtocolTimestamp(keysInfo.expiry),
- ),
- );
- // New denominations might be available.
- r.nextRefreshCheckStamp = timestampPreciseToDb(
- TalerPreciseTimestamp.now(),
- );
- if (detailsPointerChanged) {
- r.detailsPointer = {
- currency: newDetails.currency,
- masterPublicKey: newDetails.masterPublicKey,
- updateClock: timestampPreciseToDb(TalerPreciseTimestamp.now()),
- };
- }
- r.updateStatus = ExchangeEntryDbUpdateStatus.Ready;
- await tx.exchanges.put(r);
- const drRowId = await tx.exchangeDetails.put(newDetails);
- checkDbInvariant(typeof drRowId.key === "number");
-
- for (const sk of keysInfo.signingKeys) {
- // FIXME: validate signing keys before inserting them
- await tx.exchangeSignKeys.put({
- exchangeDetailsRowId: drRowId.key,
- masterSig: sk.master_sig,
- signkeyPub: sk.key,
- stampEnd: timestampProtocolToDb(sk.stamp_end),
- stampExpire: timestampProtocolToDb(sk.stamp_expire),
- stampStart: timestampProtocolToDb(sk.stamp_start),
- });
- }
-
- logger.trace("updating denominations in database");
- const currentDenomSet = new Set<string>(
- keysInfo.currentDenominations.map((x) => x.denomPubHash),
- );
- for (const currentDenom of keysInfo.currentDenominations) {
- const oldDenom = await tx.denominations.get([
- exchangeBaseUrl,
- currentDenom.denomPubHash,
- ]);
- if (oldDenom) {
- // FIXME: Do consistency check, report to auditor if necessary.
- } else {
- await tx.denominations.put(currentDenom);
- }
- }
-
- // Update list issue date for all denominations,
- // and mark non-offered denominations as such.
- await tx.denominations.indexes.byExchangeBaseUrl
- .iter(r.baseUrl)
- .forEachAsync(async (x) => {
- if (!currentDenomSet.has(x.denomPubHash)) {
- // FIXME: Here, an auditor report should be created, unless
- // the denomination is really legally expired.
- if (x.isOffered) {
- x.isOffered = false;
- logger.info(
- `setting denomination ${x.denomPubHash} to offered=false`,
- );
- }
- } else {
- x.listIssueDate = timestampProtocolToDb(keysInfo.listIssueDate);
- if (!x.isOffered) {
- x.isOffered = true;
- logger.info(
- `setting denomination ${x.denomPubHash} to offered=true`,
- );
- }
- }
- await tx.denominations.put(x);
- });
-
- logger.trace("done updating denominations in database");
-
- // Handle recoup
- const recoupDenomList = keysInfo.recoup;
- const newlyRevokedCoinPubs: string[] = [];
- logger.trace("recoup list from exchange", recoupDenomList);
- for (const recoupInfo of recoupDenomList) {
- const oldDenom = await tx.denominations.get([
- r.baseUrl,
- recoupInfo.h_denom_pub,
- ]);
- if (!oldDenom) {
- // We never even knew about the revoked denomination, all good.
- continue;
- }
- if (oldDenom.isRevoked) {
- // We already marked the denomination as revoked,
- // this implies we revoked all coins
- logger.trace("denom already revoked");
- continue;
- }
- logger.info("revoking denom", recoupInfo.h_denom_pub);
- oldDenom.isRevoked = true;
- await tx.denominations.put(oldDenom);
- const affectedCoins = await tx.coins.indexes.byDenomPubHash
- .iter(recoupInfo.h_denom_pub)
- .toArray();
- for (const ac of affectedCoins) {
- newlyRevokedCoinPubs.push(ac.coinPub);
- }
- }
- if (newlyRevokedCoinPubs.length != 0) {
- logger.info("recouping coins", newlyRevokedCoinPubs);
- recoupGroupId = await ws.recoupOps.createRecoupGroup(
- ws,
- tx,
- exchangeBaseUrl,
- newlyRevokedCoinPubs,
- );
- }
-
- const newExchangeState = getExchangeState(r);
-
- return {
- exchange: r,
- exchangeDetails: newDetails,
- oldExchangeState,
- newExchangeState,
- };
- });
-
- if (recoupGroupId) {
- // Asynchronously start recoup. This doesn't need to finish
- // for the exchange update to be considered finished.
- ws.workAvailable.trigger();
- }
-
- if (!updated) {
- throw Error("something went wrong with updating the exchange");
- }
-
- logger.trace("done updating exchange info in database");
-
- ws.notify({
- type: NotificationType.ExchangeStateTransition,
- exchangeBaseUrl,
- newExchangeState: updated.newExchangeState,
- oldExchangeState: updated.oldExchangeState,
- });
-
- return TaskRunResult.finished();
-}
-
-/**
- * Find a payto:// URI of the exchange that is of one
- * of the given target types.
- *
- * Throws if no matching account was found.
- */
-export async function getExchangePaytoUri(
- ws: InternalWalletState,
- exchangeBaseUrl: string,
- supportedTargetTypes: string[],
-): Promise<string> {
- // We do the update here, since the exchange might not even exist
- // yet in our database.
- const details = await ws.db
- .mktx((x) => [x.exchangeDetails, x.exchanges])
- .runReadOnly(async (tx) => {
- return getExchangeDetails(tx, exchangeBaseUrl);
- });
- const accounts = details?.wireInfo.accounts ?? [];
- for (const account of accounts) {
- const res = parsePaytoUri(account.payto_uri);
- if (!res) {
- continue;
- }
- if (supportedTargetTypes.includes(res.targetType)) {
- return account.payto_uri;
- }
- }
- throw Error(
- `no matching account found at exchange ${exchangeBaseUrl} for wire types ${j2s(
- supportedTargetTypes,
- )}`,
- );
-}
-
-/**
- * Get the exchange ToS in the requested format.
- * Try to download in the accepted format not cached.
- */
-export async function getExchangeTos(
- ws: InternalWalletState,
- exchangeBaseUrl: string,
- acceptedFormat?: string[],
- acceptLanguage?: string,
-): Promise<GetExchangeTosResult> {
- // FIXME: download ToS in acceptable format if passed!
- const exch = await fetchFreshExchange(ws, exchangeBaseUrl);
-
- const tosDownload = await downloadTosFromAcceptedFormat(
- ws,
- exchangeBaseUrl,
- getExchangeRequestTimeout(),
- acceptedFormat,
- acceptLanguage,
- );
-
- await ws.db
- .mktx((x) => [x.exchanges, x.exchangeDetails])
- .runReadWrite(async (tx) => {
- const updateExchangeEntry = await tx.exchanges.get(exchangeBaseUrl);
- if (updateExchangeEntry) {
- updateExchangeEntry.tosCurrentEtag = tosDownload.tosEtag;
- await tx.exchanges.put(updateExchangeEntry);
- }
- });
-
- return {
- acceptedEtag: exch.tosAcceptedEtag,
- currentEtag: tosDownload.tosEtag,
- content: tosDownload.tosText,
- contentType: tosDownload.tosContentType,
- contentLanguage: tosDownload.tosContentLanguage,
- tosStatus: exch.tosStatus,
- tosAvailableLanguages: tosDownload.tosAvailableLanguages,
- };
-}
-
-export interface ExchangeInfo {
- keys: ExchangeKeysDownloadResult;
-}
-
-/**
- * Helper function to download the exchange /keys info.
- *
- * Only used for testing / dbless wallet.
- */
-export async function downloadExchangeInfo(
- exchangeBaseUrl: string,
- http: HttpRequestLibrary,
-): Promise<ExchangeInfo> {
- const keysInfo = await downloadExchangeKeysInfo(
- exchangeBaseUrl,
- http,
- Duration.getForever(),
- );
- return {
- keys: keysInfo,
- };
-}
-
-export async function getExchanges(
- ws: InternalWalletState,
-): Promise<ExchangesListResponse> {
- const exchanges: ExchangeListItem[] = [];
- await ws.db
- .mktx((x) => [
- x.exchanges,
- x.exchangeDetails,
- x.denominations,
- x.operationRetries,
- ])
- .runReadOnly(async (tx) => {
- const exchangeRecords = await tx.exchanges.iter().toArray();
- for (const r of exchangeRecords) {
- const exchangeDetails = await getExchangeDetails(tx, r.baseUrl);
- const opRetryRecord = await tx.operationRetries.get(
- TaskIdentifiers.forExchangeUpdate(r),
- );
- exchanges.push(
- makeExchangeListItem(r, exchangeDetails, opRetryRecord?.lastError),
- );
- }
- });
- return { exchanges };
-}
-
-export async function getExchangeDetailedInfo(
- ws: InternalWalletState,
- exchangeBaseurl: string,
-): Promise<ExchangeDetailedResponse> {
- //TODO: should we use the forceUpdate parameter?
- const exchange = await ws.db
- .mktx((x) => [x.exchanges, x.exchangeDetails, x.denominations])
- .runReadOnly(async (tx) => {
- const ex = await tx.exchanges.get(exchangeBaseurl);
- const dp = ex?.detailsPointer;
- if (!dp) {
- return;
- }
- const { currency } = dp;
- const exchangeDetails = await getExchangeDetails(tx, ex.baseUrl);
- if (!exchangeDetails) {
- return;
- }
- const denominationRecords =
- await tx.denominations.indexes.byExchangeBaseUrl.getAll(ex.baseUrl);
-
- if (!denominationRecords) {
- return;
- }
-
- const denominations: DenominationInfo[] = denominationRecords.map((x) =>
- DenominationRecord.toDenomInfo(x),
- );
-
- return {
- info: {
- exchangeBaseUrl: ex.baseUrl,
- currency,
- paytoUris: exchangeDetails.wireInfo.accounts.map((x) => x.payto_uri),
- auditors: exchangeDetails.auditors,
- wireInfo: exchangeDetails.wireInfo,
- globalFees: exchangeDetails.globalFees,
- },
- denominations,
- };
- });
-
- if (!exchange) {
- throw Error(`exchange with base url "${exchangeBaseurl}" not found`);
- }
-
- const denoms = exchange.denominations.map((d) => ({
- ...d,
- group: Amounts.stringifyValue(d.value),
- }));
- const denomFees: DenomOperationMap<FeeDescription[]> = {
- deposit: createTimeline(
- denoms,
- "denomPubHash",
- "stampStart",
- "stampExpireDeposit",
- "feeDeposit",
- "group",
- selectBestForOverlappingDenominations,
- ),
- refresh: createTimeline(
- denoms,
- "denomPubHash",
- "stampStart",
- "stampExpireWithdraw",
- "feeRefresh",
- "group",
- selectBestForOverlappingDenominations,
- ),
- refund: createTimeline(
- denoms,
- "denomPubHash",
- "stampStart",
- "stampExpireWithdraw",
- "feeRefund",
- "group",
- selectBestForOverlappingDenominations,
- ),
- withdraw: createTimeline(
- denoms,
- "denomPubHash",
- "stampStart",
- "stampExpireWithdraw",
- "feeWithdraw",
- "group",
- selectBestForOverlappingDenominations,
- ),
- };
-
- const transferFees = Object.entries(
- exchange.info.wireInfo.feesForType,
- ).reduce(
- (prev, [wireType, infoForType]) => {
- const feesByGroup = [
- ...infoForType.map((w) => ({
- ...w,
- fee: Amounts.stringify(w.closingFee),
- group: "closing",
- })),
- ...infoForType.map((w) => ({ ...w, fee: w.wireFee, group: "wire" })),
- ];
- prev[wireType] = createTimeline(
- feesByGroup,
- "sig",
- "startStamp",
- "endStamp",
- "fee",
- "group",
- selectMinimumFee,
- );
- return prev;
- },
- {} as Record<string, FeeDescription[]>,
- );
-
- const globalFeesByGroup = [
- ...exchange.info.globalFees.map((w) => ({
- ...w,
- fee: w.accountFee,
- group: "account",
- })),
- ...exchange.info.globalFees.map((w) => ({
- ...w,
- fee: w.historyFee,
- group: "history",
- })),
- ...exchange.info.globalFees.map((w) => ({
- ...w,
- fee: w.purseFee,
- group: "purse",
- })),
- ];
-
- const globalFees = createTimeline(
- globalFeesByGroup,
- "signature",
- "startDate",
- "endDate",
- "fee",
- "group",
- selectMinimumFee,
- );
-
- return {
- exchange: {
- ...exchange.info,
- denomFees,
- transferFees,
- globalFees,
- },
- };
-}