/*
 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 
 */
/**
 * Imports.
 */
import {
  AbsoluteTime,
  AccountInfo,
  Amounts,
  CancellationToken,
  canonicalizeBaseUrl,
  codecForExchangeKeysJson,
  DenomGroup,
  DenominationPubKey,
  DenomKeyType,
  Duration,
  durationFromSpec,
  encodeCrock,
  ExchangeAuditor,
  ExchangeDenomination,
  ExchangeEntryStatus,
  ExchangeGlobalFees,
  ExchangeSignKeyJson,
  ExchangeWireJson,
  GlobalFees,
  hashDenomPub,
  j2s,
  LibtoolVersion,
  Logger,
  makeErrorDetail,
  NotificationType,
  parsePaytoUri,
  Recoup,
  TalerError,
  TalerErrorCode,
  TalerErrorDetail,
  TalerPreciseTimestamp,
  TalerProtocolDuration,
  TalerProtocolTimestamp,
  URL,
  WireFee,
  WireFeeMap,
  WireFeesJson,
  WireInfo,
} from "@gnu-taler/taler-util";
import {
  HttpRequestLibrary,
  readSuccessResponseTextOrThrow,
  readSuccessResponseJsonOrThrow,
  getExpiry,
} from "@gnu-taler/taler-util/http";
import {
  DenominationRecord,
  DenominationVerificationStatus,
  ExchangeDetailsRecord,
  ExchangeEntryRecord,
  WalletStoresV1,
} from "../db.js";
import {
  ExchangeEntryDbRecordStatus,
  ExchangeEntryDbUpdateStatus,
  isWithdrawableDenom,
  WalletDbReadWriteTransaction,
} from "../index.js";
import { InternalWalletState, TrustInfo } from "../internal-wallet-state.js";
import { checkDbInvariant } from "../util/invariants.js";
import {
  DbAccess,
  GetReadOnlyAccess,
  GetReadWriteAccess,
} from "../util/query.js";
import { WALLET_EXCHANGE_PROTOCOL_VERSION } from "../versions.js";
import {
  TaskRunResultType,
  runTaskWithErrorReporting,
  TaskIdentifiers,
  TaskRunResult,
} from "./common.js";
const logger = new Logger("exchanges.ts");
export function getExchangeRequestTimeout(): Duration {
  return Duration.fromSpec({
    seconds: 5,
  });
}
export interface ExchangeTosDownloadResult {
  tosText: string;
  tosEtag: string;
  tosContentType: string;
}
export async function downloadExchangeWithTermsOfService(
  exchangeBaseUrl: string,
  http: HttpRequestLibrary,
  timeout: Duration,
  contentType: string,
): Promise {
  logger.trace(`downloading exchange tos (type ${contentType})`);
  const reqUrl = new URL("terms", exchangeBaseUrl);
  const headers = {
    Accept: contentType,
  };
  const resp = await http.fetch(reqUrl.href, {
    headers,
    timeout,
  });
  const tosText = await readSuccessResponseTextOrThrow(resp);
  const tosEtag = resp.headers.get("etag") || "unknown";
  const tosContentType = resp.headers.get("content-type") || "text/plain";
  return { tosText, tosEtag, tosContentType };
}
/**
 * Get exchange details from the database.
 */
export async function getExchangeDetails(
  tx: GetReadOnlyAccess<{
    exchanges: typeof WalletStoresV1.exchanges;
    exchangeDetails: typeof WalletStoresV1.exchangeDetails;
  }>,
  exchangeBaseUrl: string,
): Promise {
  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,
  ]);
}
getExchangeDetails.makeContext = (db: DbAccess) =>
  db.mktx((x) => [x.exchanges, x.exchangeDetails]);
/**
 * 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 {
  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: TalerPreciseTimestamp.now(),
        };
        await tx.exchangeDetails.put(d);
      }
    });
}
async function validateWireInfo(
  ws: InternalWalletState,
  versionCurrent: number,
  wireInfo: ExchangeKeysDownloadResult,
  masterPublicKey: string,
): Promise {
  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 {
  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;
}
export interface ExchangeInfo {
  keys: ExchangeKeysDownloadResult;
}
export async function downloadExchangeInfo(
  exchangeBaseUrl: string,
  http: HttpRequestLibrary,
): Promise {
  const keysInfo = await downloadExchangeKeysInfo(
    exchangeBaseUrl,
    http,
    Duration.getForever(),
  );
  return {
    keys: keysInfo,
  };
}
export async function addPresetExchangeEntry(
  tx: WalletDbReadWriteTransaction<"exchanges">,
  exchangeBaseUrl: string,
): Promise {
  let exchange = await tx.exchanges.get(exchangeBaseUrl);
  if (!exchange) {
    const r: ExchangeEntryRecord = {
      entryStatus: ExchangeEntryDbRecordStatus.Preset,
      updateStatus: ExchangeEntryDbUpdateStatus.Initial,
      baseUrl: exchangeBaseUrl,
      detailsPointer: undefined,
      lastUpdate: undefined,
      lastKeysEtag: undefined,
      nextRefreshCheckStampMs: AbsoluteTime.getStampMsNever(),
      nextUpdateStampMs: AbsoluteTime.getStampMsNever(),
    };
    await tx.exchanges.put(r);
  }
}
export 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;
}> {
  let exchange = await tx.exchanges.get(baseUrl);
  if (!exchange) {
    const r: ExchangeEntryRecord = {
      entryStatus: ExchangeEntryDbRecordStatus.Ephemeral,
      updateStatus: ExchangeEntryDbUpdateStatus.InitialUpdate,
      baseUrl: baseUrl,
      detailsPointer: undefined,
      lastUpdate: undefined,
      nextUpdateStampMs: AbsoluteTime.getStampMsNever(),
      nextRefreshCheckStampMs: AbsoluteTime.getStampMsNever(),
      lastKeysEtag: undefined,
    };
    await tx.exchanges.put(r);
    exchange = r;
  }
  const exchangeDetails = await getExchangeDetails(tx, baseUrl);
  return { exchange, exchangeDetails };
}
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: AccountInfo[];
  wireFees: { [methodName: string]: WireFeesJson[] };
}
/**
 * Download and validate an exchange's /keys data.
 */
async function downloadExchangeKeysInfo(
  baseUrl: string,
  http: HttpRequestLibrary,
  timeout: Duration,
): Promise {
  const keysUrl = new URL("keys", baseUrl);
  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",
    );
  }
  const protocolVersion = exchangeKeysJsonUnchecked.version;
  const versionRes = LibtoolVersion.compare(
    WALLET_EXCHANGE_PROTOCOL_VERSION,
    protocolVersion,
  );
  if (versionRes?.compatible != true) {
    throw TalerError.fromDetail(
      TalerErrorCode.WALLET_EXCHANGE_PROTOCOL_VERSION_INCOMPATIBLE,
      {
        exchangeProtocolVersion: protocolVersion,
        walletProtocolVersion: WALLET_EXCHANGE_PROTOCOL_VERSION,
      },
      "exchange protocol version not compatible with wallet",
    );
  }
  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,
            amountFrac: value.fraction,
            amountVal: value.value,
            currency: value.currency,
            stampExpireDeposit: denomIn.stamp_expire_deposit,
            stampExpireLegal: denomIn.stamp_expire_legal,
            stampExpireWithdraw: denomIn.stamp_expire_withdraw,
            stampStart: denomIn.stamp_start,
            verificationStatus: DenominationVerificationStatus.Unverified,
            masterSig: denomIn.master_sig,
            listIssueDate: 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,
  };
}
export async function downloadTosFromAcceptedFormat(
  ws: InternalWalletState,
  baseUrl: string,
  timeout: Duration,
  acceptedFormat?: string[],
): Promise {
  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,
      );
      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",
  );
}
/**
 * FIXME: Split this into two parts: (a) triggering the exchange
 * to be updated and (b) waiting for the update to finish.
 */
export async function updateExchangeFromUrl(
  ws: InternalWalletState,
  baseUrl: string,
  options: {
    checkMasterPub?: string;
    forceNow?: boolean;
    cancellationToken?: CancellationToken;
  } = {},
): Promise<{
  exchange: ExchangeEntryRecord;
  exchangeDetails: ExchangeDetailsRecord;
}> {
  const canonUrl = canonicalizeBaseUrl(baseUrl);
  const res = await runTaskWithErrorReporting(
    ws,
    TaskIdentifiers.forExchangeUpdateFromUrl(canonUrl),
    () => updateExchangeFromUrlHandler(ws, canonUrl, options),
  );
  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");
      }
      return { exchange, exchangeDetails };
    }
    case TaskRunResultType.Error:
      throw TalerError.fromUncheckedDetail(res.errorDetail);
    default:
      throw Error(`unexpected operation result (${res.type})`);
  }
}
/**
 * Update or add exchange DB entry 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: {
    checkMasterPub?: string;
    forceNow?: boolean;
    cancellationToken?: CancellationToken;
  } = {},
): Promise {
  const forceNow = options.forceNow ?? false;
  logger.trace(
    `updating exchange info for ${exchangeBaseUrl}, forced: ${forceNow}`,
  );
  const now = AbsoluteTime.now();
  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.fromStampMs(exchange.nextUpdateStampMs),
    )
  ) {
    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");
  const timeout = getExchangeRequestTimeout();
  const keysInfo = await downloadExchangeKeysInfo(
    exchangeBaseUrl,
    ws.http,
    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);
  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 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 existingTosAccepted = existingDetails?.tosAccepted;
      const newDetails: ExchangeDetailsRecord = {
        auditors: keysInfo.auditors,
        currency: keysInfo.currency,
        masterPublicKey: keysInfo.masterPublicKey,
        protocolVersionRange: keysInfo.protocolVersion,
        reserveClosingDelay: keysInfo.reserveClosingDelay,
        globalFees,
        exchangeBaseUrl: r.baseUrl,
        wireInfo,
        tosCurrentEtag: tosDownload.tosEtag,
        tosAccepted: existingTosAccepted,
        ageMask,
      };
      if (existingDetails?.rowId) {
        newDetails.rowId = existingDetails.rowId;
      }
      r.lastUpdate = TalerPreciseTimestamp.now();
      r.nextUpdateStampMs = AbsoluteTime.toStampMs(
        AbsoluteTime.fromProtocolTimestamp(keysInfo.expiry),
      );
      // New denominations might be available.
      r.nextRefreshCheckStampMs = AbsoluteTime.getStampMsNow();
      if (detailsPointerChanged) {
        r.detailsPointer = {
          currency: newDetails.currency,
          masterPublicKey: newDetails.masterPublicKey,
          updateClock: TalerPreciseTimestamp.now(),
        };
      }
      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: sk.stamp_end,
          stampExpire: sk.stamp_expire,
          stampStart: sk.stamp_start,
        });
      }
      logger.trace("updating denominations in database");
      const currentDenomSet = new Set(
        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 = 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,
          exchange.baseUrl,
          newlyRevokedCoinPubs,
        );
      }
      return {
        exchange: r,
        exchangeDetails: newDetails,
      };
    });
  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");
  if (isNewExchange) {
    ws.notify({
      type: NotificationType.ExchangeAdded,
    });
  }
  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 {
  // We do the update here, since the exchange might not even exist
  // yet in our database.
  const details = await getExchangeDetails
    .makeContext(ws.db)
    .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,
    )}`,
  );
}