/*
 This file is part of GNU Taler
 (C) 2019 GNUnet e.V.
 (C) 2025 Taler Systems S.A.

 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.
 */

import {
  AbsoluteTime,
  AccountKycStatus,
  AccountLimit,
  AgeRestriction,
  Amount,
  AmountLike,
  AmountString,
  Amounts,
  CancellationToken,
  CoinRefreshRequest,
  CoinStatus,
  CurrencySpecification,
  DeleteExchangeRequest,
  DenomKeyType,
  DenomLossEventType,
  DenomOperationMap,
  DenominationInfo,
  DenominationPubKey,
  Duration,
  EddsaPublicKeyString,
  EmptyObject,
  ExchangeAuditor,
  ExchangeDetailedResponse,
  ExchangeEntryState,
  ExchangeGlobalFees,
  ExchangeListItem,
  ExchangeSignKeyJson,
  ExchangeTosStatus,
  ExchangeUpdateStatus,
  ExchangeWalletKycStatus,
  ExchangeWireAccount,
  ExchangesListResponse,
  FeeDescription,
  GetExchangeEntryByUrlRequest,
  GetExchangeResourcesResponse,
  GetExchangeTosResult,
  GlobalFees,
  HttpStatusCode,
  LegitimizationNeededResponse,
  LibtoolVersion,
  LimitOperationType,
  ListExchangesRequest,
  Logger,
  NotificationType,
  OperationErrorInfo,
  Recoup,
  RefreshReason,
  ScopeInfo,
  ScopeType,
  StartExchangeWalletKycRequest,
  TalerError,
  TalerErrorCode,
  TalerErrorDetail,
  TalerPreciseTimestamp,
  TalerProtocolDuration,
  TalerProtocolTimestamp,
  TestingPlanMigrateExchangeBaseUrlRequest,
  TestingWaitExchangeStateRequest,
  TestingWaitWalletKycRequest,
  Transaction,
  TransactionAction,
  TransactionIdStr,
  TransactionMajorState,
  TransactionState,
  TransactionType,
  URL,
  WalletKycRequest,
  WalletNotification,
  WireFee,
  WireFeeMap,
  WireFeesJson,
  WireInfo,
  ZeroLimitedOperation,
  assertUnreachable,
  checkDbInvariant,
  checkLogicInvariant,
  codecForAccountKycStatus,
  codecForAmlWalletKycCheckResponse,
  codecForExchangeKeysResponse,
  codecForLegitimizationNeededResponse,
  durationMul,
  encodeCrock,
  getRandomBytes,
  hashDenomPub,
  hashFullPaytoUri,
  j2s,
  makeErrorDetail,
  makeTalerErrorDetail,
  parsePaytoUri,
  stringifyReservePaytoUri,
  stringifyScopeInfo,
} from "@gnu-taler/taler-util";
import {
  HttpRequestLibrary,
  getExpiry,
  readResponseJsonOrThrow,
  readSuccessResponseJsonOrThrow,
  readSuccessResponseTextOrThrow,
  readTalerErrorResponse,
  throwUnexpectedRequestError,
} from "@gnu-taler/taler-util/http";
import {
  PendingTaskType,
  TaskIdStr,
  TaskIdentifiers,
  TaskRunResult,
  TransactionContext,
  cancelableFetch,
  cancelableLongPoll,
  computeDbBackoff,
  constructTaskIdentifier,
  genericWaitForState,
  getAutoRefreshExecuteThreshold,
  getExchangeEntryStatusFromRecord,
  getExchangeState,
  getExchangeTosStatusFromRecord,
  getExchangeUpdateStatusFromRecord,
} from "./common.js";
import {
  DenomLossEventRecord,
  DenomLossStatus,
  DenominationRecord,
  DenominationVerificationStatus,
  ExchangeDetailsRecord,
  ExchangeEntryDbRecordStatus,
  ExchangeEntryDbUpdateStatus,
  ExchangeEntryRecord,
  ExchangeMigrationReason,
  ReserveRecord,
  ReserveRecordStatus,
  WalletDbAllStoresReadOnlyTransaction,
  WalletDbAllStoresReadWriteTransaction,
  WalletDbHelpers,
  WalletDbReadOnlyTransaction,
  WalletDbReadWriteTransaction,
  timestampAbsoluteFromDb,
  timestampOptionalPreciseFromDb,
  timestampPreciseFromDb,
  timestampPreciseToDb,
  timestampProtocolFromDb,
  timestampProtocolToDb,
} from "./db.js";
import {
  createTimeline,
  isCandidateWithdrawableDenom,
  selectBestForOverlappingDenominations,
  selectMinimumFee,
} from "./denominations.js";
import { DepositTransactionContext } from "./deposits.js";
import {
  PayMerchantTransactionContext,
  RefundTransactionContext,
} from "./pay-merchant.js";
import { PeerPullCreditTransactionContext } from "./pay-peer-pull-credit.js";
import { PeerPullDebitTransactionContext } from "./pay-peer-pull-debit.js";
import { PeerPushCreditTransactionContext } from "./pay-peer-push-credit.js";
import { PeerPushDebitTransactionContext } from "./pay-peer-push-debit.js";
import { RecoupTransactionContext, createRecoupGroup } from "./recoup.js";
import { RefreshTransactionContext, createRefreshGroup } from "./refresh.js";
import {
  BalanceEffect,
  applyNotifyTransition,
  constructTransactionIdentifier,
  rematerializeTransactions,
} from "./transactions.js";
import { WALLET_EXCHANGE_PROTOCOL_VERSION } from "./versions.js";
import {
  InternalWalletState,
  WalletExecutionContext,
  walletExchangeClient,
} from "./wallet.js";
import {
  WithdrawTransactionContext,
  updateWithdrawalDenomsForExchange,
} from "./withdraw.js";

const logger = new Logger("exchanges.ts");

function getExchangeRequestTimeout(): Duration {
  return Duration.fromSpec({
    seconds: 15,
  });
}

interface ExchangeTosDownloadResult {
  tosText: string;
  tosEtag: string;
  tosContentType: string;
  tosContentLanguage: string | undefined;
  tosAvailableLanguages: string[];
}

async function downloadExchangeWithTermsOfService(
  wex: WalletExecutionContext,
  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,
    cancellationToken: wex.cancellationToken,
  });

  if (
    (resp.status >= 500 && resp.status <= 599) ||
    (resp.status >= 400 && resp.status >= 499)
  ) {
    const innerError = await readTalerErrorResponse(resp);
    throw TalerError.fromDetail(TalerErrorCode.WALLET_EXCHANGE_UNAVAILABLE, {
      exchangeBaseUrl,
      innerError,
    });
  }

  const tosText = await readSuccessResponseTextOrThrow(resp);
  const tosEtag = resp.headers.get("taler-terms-version") || "unknown";
  const tosContentLanguage = resp.headers.get("content-language") || undefined;
  const tosContentType = resp.headers.get("content-type") || "text/plain";
  const availLangStr = resp.headers.get("avail-languages") || "";
  // Work around exchange bug that reports the same language multiple times.
  const availLangSet = new Set<string>(
    availLangStr.split(",").map((x) => x.trim()),
  );
  const tosAvailableLanguages = [...availLangSet];

  return {
    tosText,
    tosEtag,
    tosContentType,
    tosContentLanguage,
    tosAvailableLanguages,
  };
}

/**
 * Get exchange details from the database.
 */
async function getExchangeRecordsInternal(
  tx: WalletDbReadOnlyTransaction<["exchanges", "exchangeDetails"]>,
  exchangeBaseUrl: string,
): Promise<ExchangeDetailsRecord | undefined> {
  const r = await tx.exchanges.get(exchangeBaseUrl);
  if (!r) {
    logger.warn(`no exchange found for ${exchangeBaseUrl}`);
    return;
  }
  const dp = r.detailsPointer;
  if (!dp) {
    switch (r.updateStatus) {
      case ExchangeEntryDbUpdateStatus.Ready:
      case ExchangeEntryDbUpdateStatus.ReadyUpdate:
      case ExchangeEntryDbUpdateStatus.OutdatedUpdate:
        logger.error(
          `Database invariant violated: no exchange details in wallet database for ${exchangeBaseUrl}.`,
        );
        break;
      case ExchangeEntryDbUpdateStatus.Initial:
      case ExchangeEntryDbUpdateStatus.InitialUpdate:
      case ExchangeEntryDbUpdateStatus.Suspended:
      case ExchangeEntryDbUpdateStatus.UnavailableUpdate:
        // In these states, not having exchange details in the wallet DB
        // is expected.
        break;
      default:
        assertUnreachable(r.updateStatus);
    }
    return;
  }
  const { currency, masterPublicKey } = dp;
  const details = await tx.exchangeDetails.indexes.byPointer.get([
    r.baseUrl,
    currency,
    masterPublicKey,
  ]);
  if (!details) {
    logger.warn(
      `no exchange details with pointer ${j2s(dp)} for ${exchangeBaseUrl}`,
    );
  }
  return details;
}

export async function getScopeForAllCoins(
  tx: WalletDbReadOnlyTransaction<
    [
      "exchanges",
      "coins",
      "exchangeDetails",
      "globalCurrencyExchanges",
      "globalCurrencyAuditors",
    ]
  >,
  coinPubs: string[],
): Promise<ScopeInfo[]> {
  let exchangeSet = new Set<string>();
  for (const pub of coinPubs) {
    const coin = await tx.coins.get(pub);
    if (!coin) {
      logger.warn(`coin ${coinPubs} not found, unable to compute full scope`);
      continue;
    }
    exchangeSet.add(coin.exchangeBaseUrl);
  }
  return await getScopeForAllExchanges(tx, [...exchangeSet]);
}

/**
 * Get a list of scope infos applicable to a list of exchanges.
 */
export async function getScopeForAllExchanges(
  tx: WalletDbReadOnlyTransaction<
    [
      "exchanges",
      "exchangeDetails",
      "globalCurrencyExchanges",
      "globalCurrencyAuditors",
    ]
  >,
  exs: string[],
): Promise<ScopeInfo[]> {
  const scopes: ScopeInfo[] = [];
  const scSet = new Set<string>();
  for (const exchange of exs) {
    const sc = await getExchangeScopeInfoOrUndefined(tx, exchange);
    if (!sc) {
      continue;
    }
    const scStr = stringifyScopeInfo(sc);
    if (!scSet.has(scStr)) {
      scSet.add(scStr);
      scopes.push(sc);
    }
  }
  return scopes;
}

export async function getExchangeScopeInfoOrUndefined(
  tx: WalletDbReadOnlyTransaction<
    [
      "exchanges",
      "exchangeDetails",
      "globalCurrencyExchanges",
      "globalCurrencyAuditors",
    ]
  >,
  exchangeBaseUrl: string,
): Promise<ScopeInfo | undefined> {
  const det = await getExchangeRecordsInternal(tx, exchangeBaseUrl);
  if (!det) {
    return undefined;
  }
  return internalGetExchangeScopeInfo(tx, det);
}

export async function getExchangeScopeInfo(
  tx: WalletDbReadOnlyTransaction<
    [
      "exchanges",
      "exchangeDetails",
      "globalCurrencyExchanges",
      "globalCurrencyAuditors",
    ]
  >,
  exchangeBaseUrl: string,
  currency: string,
): Promise<ScopeInfo> {
  const det = await getExchangeRecordsInternal(tx, exchangeBaseUrl);
  if (!det) {
    return {
      type: ScopeType.Exchange,
      currency: currency,
      url: exchangeBaseUrl,
    };
  }
  return internalGetExchangeScopeInfo(tx, det);
}

async function internalGetExchangeScopeInfo(
  tx: WalletDbReadOnlyTransaction<
    ["globalCurrencyExchanges", "globalCurrencyAuditors"]
  >,
  exchangeDetails: ExchangeDetailsRecord,
): Promise<ScopeInfo> {
  const globalExchangeRec =
    await tx.globalCurrencyExchanges.indexes.byCurrencyAndUrlAndPub.get([
      exchangeDetails.currency,
      exchangeDetails.exchangeBaseUrl,
      exchangeDetails.masterPublicKey,
    ]);
  if (globalExchangeRec) {
    return {
      currency: exchangeDetails.currency,
      type: ScopeType.Global,
    };
  } else {
    for (const aud of exchangeDetails.auditors) {
      const globalAuditorRec =
        await tx.globalCurrencyAuditors.indexes.byCurrencyAndUrlAndPub.get([
          exchangeDetails.currency,
          aud.auditor_url,
          aud.auditor_pub,
        ]);
      if (globalAuditorRec) {
        return {
          currency: exchangeDetails.currency,
          type: ScopeType.Auditor,
          url: aud.auditor_url,
        };
      }
    }
  }
  return {
    currency: exchangeDetails.currency,
    type: ScopeType.Exchange,
    url: exchangeDetails.exchangeBaseUrl,
  };
}

function getKycStatusFromReserveStatus(
  status: ReserveRecordStatus,
): ExchangeWalletKycStatus {
  switch (status) {
    case ReserveRecordStatus.Done:
      return ExchangeWalletKycStatus.Done;
    // FIXME: Do we handle the suspended state?
    case ReserveRecordStatus.SuspendedLegiInit:
    case ReserveRecordStatus.PendingLegiInit:
      return ExchangeWalletKycStatus.LegiInit;
    // FIXME: Do we handle the suspended state?
    case ReserveRecordStatus.SuspendedLegi:
    case ReserveRecordStatus.PendingLegi:
      return ExchangeWalletKycStatus.Legi;
  }
}

async function makeExchangeListItem(
  wex: WalletExecutionContext,
  tx: WalletDbReadOnlyTransaction<
    ["globalCurrencyExchanges", "globalCurrencyAuditors"]
  >,
  r: ExchangeEntryRecord,
  exchangeDetails: ExchangeDetailsRecord | undefined,
  reserveRec: ReserveRecord | undefined,
  lastError: TalerErrorDetail | undefined,
): Promise<ExchangeListItem> {
  const lastUpdateErrorInfo: OperationErrorInfo | undefined = lastError
    ? {
        error: lastError,
      }
    : undefined;

  let scopeInfo: ScopeInfo | undefined = undefined;

  if (exchangeDetails) {
    scopeInfo = await internalGetExchangeScopeInfo(tx, exchangeDetails);
  }

  let walletKycStatus: ExchangeWalletKycStatus | undefined =
    reserveRec && reserveRec.status
      ? getKycStatusFromReserveStatus(reserveRec.status)
      : undefined;

  let noFees = r.noFees ?? false;

  if (wex.ws.devExperimentState.pretendNoFees) {
    noFees = true;
  }

  const listItem: ExchangeListItem = {
    exchangeBaseUrl: r.baseUrl,
    masterPub: exchangeDetails?.masterPublicKey,
    noFees,
    peerPaymentsDisabled: r.peerPaymentsDisabled ?? false,
    currency: exchangeDetails?.currency ?? r.presetCurrencyHint ?? "UNKNOWN",
    exchangeUpdateStatus: getExchangeUpdateStatusFromRecord(r),
    exchangeEntryStatus: getExchangeEntryStatusFromRecord(r),
    walletKycStatus,
    walletKycReservePub: reserveRec?.reservePub,
    walletKycUrl: reserveRec?.kycAccessToken
      ? new URL(`kyc-spa/${reserveRec.kycAccessToken}`, r.baseUrl).href
      : undefined,
    walletKycAccessToken: reserveRec?.kycAccessToken,
    walletKycRequestedThreshold: reserveRec?.thresholdRequested,
    tosStatus: getExchangeTosStatusFromRecord(r),
    ageRestrictionOptions: exchangeDetails?.ageMask
      ? AgeRestriction.getAgeGroupsFromMask(exchangeDetails.ageMask)
      : [],
    paytoUris: exchangeDetails?.wireInfo.accounts.map((x) => x.payto_uri) ?? [],
    bankComplianceLanguage: exchangeDetails?.bankComplianceLanguage,
    lastUpdateTimestamp: timestampOptionalPreciseFromDb(r.lastUpdate),
    lastUpdateErrorInfo,
    scopeInfo: scopeInfo ?? {
      type: ScopeType.Exchange,
      currency: "UNKNOWN",
      url: r.baseUrl,
    },
  };
  switch (listItem.exchangeUpdateStatus) {
    case ExchangeUpdateStatus.UnavailableUpdate:
      if (r.unavailableReason) {
        listItem.unavailableReason = r.unavailableReason;
      }
      break;
  }
  return listItem;
}

export interface ExchangeWireDetails {
  currency: string;
  masterPublicKey: EddsaPublicKeyString;
  wireInfo: WireInfo;
  exchangeBaseUrl: string;
  auditors: ExchangeAuditor[];
  globalFees: ExchangeGlobalFees[];
  reserveClosingDelay: TalerProtocolDuration;
}

export async function getExchangeWireDetailsInTx(
  tx: WalletDbReadOnlyTransaction<["exchanges", "exchangeDetails"]>,
  exchangeBaseUrl: string,
): Promise<ExchangeWireDetails | undefined> {
  const det = await getExchangeRecordsInternal(tx, exchangeBaseUrl);
  if (!det) {
    return undefined;
  }
  return {
    currency: det.currency,
    masterPublicKey: det.masterPublicKey,
    wireInfo: det.wireInfo,
    exchangeBaseUrl: det.exchangeBaseUrl,
    auditors: det.auditors,
    globalFees: det.globalFees,
    reserveClosingDelay: det.reserveClosingDelay,
  };
}

export async function lookupExchangeByUri(
  wex: WalletExecutionContext,
  req: GetExchangeEntryByUrlRequest,
): Promise<ExchangeListItem> {
  const res = await wex.db.runReadOnlyTx(
    {
      storeNames: [
        "exchanges",
        "reserves",
        "exchangeDetails",
        "operationRetries",
        "globalCurrencyAuditors",
        "globalCurrencyExchanges",
      ],
    },
    async (tx) => {
      const exchangeRec = await tx.exchanges.get(req.exchangeBaseUrl);
      if (!exchangeRec) {
        return undefined;
      }
      const exchangeDetails = await getExchangeRecordsInternal(
        tx,
        exchangeRec.baseUrl,
      );
      const opRetryRecord = await tx.operationRetries.get(
        TaskIdentifiers.forExchangeUpdate(exchangeRec),
      );
      let reserveRec: ReserveRecord | undefined = undefined;
      if (exchangeRec.currentMergeReserveRowId != null) {
        reserveRec = await tx.reserves.get(
          exchangeRec.currentMergeReserveRowId,
        );
        checkDbInvariant(!!reserveRec, "reserve record not found");
      }
      return await makeExchangeListItem(
        wex,
        tx,
        exchangeRec,
        exchangeDetails,
        reserveRec,
        opRetryRecord?.lastError,
      );
    },
  );
  if (!res) {
    throw TalerError.fromDetail(
      TalerErrorCode.WALLET_EXCHANGE_ENTRY_NOT_FOUND,
      {},
    );
  }
  return res;
}

/**
 * Mark the current ToS version as accepted by the user.
 */
export async function acceptExchangeTermsOfService(
  wex: WalletExecutionContext,
  exchangeBaseUrl: string,
): Promise<void> {
  const notif = await wex.db.runReadWriteTx(
    { storeNames: ["exchangeDetails", "exchanges"] },
    async (tx) => {
      const exch = await tx.exchanges.get(exchangeBaseUrl);
      if (exch && exch.tosCurrentEtag) {
        const oldExchangeState = getExchangeState(exch);
        exch.tosAcceptedEtag = exch.tosCurrentEtag;
        exch.tosAcceptedTimestamp = timestampPreciseToDb(
          TalerPreciseTimestamp.now(),
        );
        await tx.exchanges.put(exch);
        const newExchangeState = getExchangeState(exch);
        wex.ws.exchangeCache.clear();
        return {
          type: NotificationType.ExchangeStateTransition,
          exchangeBaseUrl,
          newExchangeState: newExchangeState,
          oldExchangeState: oldExchangeState,
        } satisfies WalletNotification;
      }
      return undefined;
    },
  );
  if (notif) {
    wex.ws.notify(notif);
  }
}

/**
 * Set the ToS status of the exchange as not accepted.
 */
export async function forgetExchangeTermsOfService(
  wex: WalletExecutionContext,
  exchangeBaseUrl: string,
): Promise<void> {
  const notif = await wex.db.runReadWriteTx(
    { storeNames: ["exchangeDetails", "exchanges"] },
    async (tx) => {
      const exch = await tx.exchanges.get(exchangeBaseUrl);
      if (exch) {
        const oldExchangeState = getExchangeState(exch);
        exch.tosAcceptedEtag = undefined;
        exch.tosAcceptedTimestamp = undefined;
        await tx.exchanges.put(exch);
        const newExchangeState = getExchangeState(exch);
        wex.ws.exchangeCache.clear();
        return {
          type: NotificationType.ExchangeStateTransition,
          exchangeBaseUrl,
          newExchangeState: newExchangeState,
          oldExchangeState: oldExchangeState,
        } satisfies WalletNotification;
      }
      return undefined;
    },
  );
  if (notif) {
    wex.ws.notify(notif);
  }
}

/**
 * Validate wire fees and wire accounts.
 *
 * Throw an exception if they are invalid.
 */
async function validateWireInfo(
  wex: WalletExecutionContext,
  versionCurrent: number,
  wireInfo: ExchangeKeysDownloadSuccessResult,
  masterPublicKey: string,
): Promise<WireInfo> {
  for (const a of wireInfo.accounts) {
    logger.trace("validating exchange acct");
    let isValid = false;
    if (wex.ws.config.testing.insecureTrustExchange) {
      isValid = true;
    } else {
      const { valid: v } = await wex.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 (wex.ws.config.testing.insecureTrustExchange) {
        isValid = true;
      } else {
        const { valid: v } = await wex.ws.cryptoApi.isValidWireFee({
          masterPub: masterPublicKey,
          type: wireMethod,
          wf: fee,
        });
        isValid = v;
      }
      if (!isValid) {
        throw Error("exchange wire fee signature invalid");
      }
      feeList.push(fee);
    }
    feesForType[wireMethod] = feeList;
  }

  return {
    accounts: wireInfo.accounts,
    feesForType,
  };
}

/**
 * Validate global fees.
 *
 * Throw an exception if they are invalid.
 */
async function validateGlobalFees(
  wex: WalletExecutionContext,
  fees: GlobalFees[],
  masterPub: string,
): Promise<ExchangeGlobalFees[]> {
  const egf: ExchangeGlobalFees[] = [];
  for (const gf of fees) {
    logger.trace("validating exchange global fees");
    let isValid = false;
    if (wex.ws.config.testing.insecureTrustExchange) {
      isValid = true;
    } else {
      const { valid: v } = await wex.cryptoApi.isValidGlobalFees({
        masterPub,
        gf,
      });
      isValid = v;
    }

    if (!isValid) {
      throw Error("exchange global fees signature invalid: " + gf.master_sig);
    }
    egf.push({
      accountFee: Amounts.stringify(gf.account_fee),
      historyFee: Amounts.stringify(gf.history_fee),
      purseFee: Amounts.stringify(gf.purse_fee),
      startDate: gf.start_date,
      endDate: gf.end_date,
      signature: gf.master_sig,
      historyTimeout: gf.history_expiration,
      purseLimit: gf.purse_account_limit,
      purseTimeout: gf.purse_timeout,
    });
  }

  return egf;
}

/**
 * Add an exchange entry to the wallet database in the
 * entry state "preset".
 *
 * Returns the notification to the caller that should be emitted
 * if the DB transaction succeeds.
 */
export async function addPresetExchangeEntry(
  tx: WalletDbReadWriteTransaction<["exchanges"]>,
  exchangeBaseUrl: string,
  currencyHint?: string,
): Promise<{ notification?: WalletNotification }> {
  let exchange = await tx.exchanges.get(exchangeBaseUrl);
  if (!exchange) {
    const r: ExchangeEntryRecord = {
      entryStatus: ExchangeEntryDbRecordStatus.Preset,
      updateStatus: ExchangeEntryDbUpdateStatus.Initial,
      baseUrl: exchangeBaseUrl,
      presetCurrencyHint: currencyHint,
      detailsPointer: undefined,
      lastUpdate: undefined,
      lastKeysEtag: undefined,
      nextRefreshCheckStamp: timestampPreciseToDb(
        AbsoluteTime.toPreciseTimestamp(AbsoluteTime.never()),
      ),
      nextUpdateStamp: timestampPreciseToDb(
        AbsoluteTime.toPreciseTimestamp(AbsoluteTime.never()),
      ),
      tosAcceptedEtag: undefined,
      tosAcceptedTimestamp: undefined,
      tosCurrentEtag: undefined,
    };
    await tx.exchanges.put(r);
    return {
      notification: {
        type: NotificationType.ExchangeStateTransition,
        exchangeBaseUrl: exchangeBaseUrl,
        // Exchange did not exist yet
        oldExchangeState: undefined,
        newExchangeState: getExchangeState(r),
      },
    };
  }
  return {};
}

async function provideExchangeRecordInTx(
  ws: InternalWalletState,
  tx: WalletDbReadWriteTransaction<["exchanges", "exchangeDetails"]>,
  baseUrl: string,
): Promise<{
  exchange: ExchangeEntryRecord;
  exchangeDetails: ExchangeDetailsRecord | undefined;
}> {
  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()),
      ),
      // The first update should always be done in a way that ignores the cache,
      // so that removing and re-adding an exchange works properly, even
      // if /keys is cached in the browser.
      cachebreakNextUpdate: true,
      lastKeysEtag: undefined,
      tosAcceptedEtag: undefined,
      tosAcceptedTimestamp: undefined,
      tosCurrentEtag: undefined,
    };
    await tx.exchanges.put(r);
    exchange = r;
    tx.notify({
      type: NotificationType.ExchangeStateTransition,
      exchangeBaseUrl: r.baseUrl,
      oldExchangeState: undefined,
      newExchangeState: getExchangeState(r),
    });
  }
  const exchangeDetails = await getExchangeRecordsInternal(tx, baseUrl);
  return { exchange, exchangeDetails };
}

export interface ExchangeKeysDownloadSuccessResult {
  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[] };
  currencySpecification?: CurrencySpecification;
  walletBalanceLimits: AmountString[] | undefined;
  hardLimits: AccountLimit[] | undefined;
  zeroLimits: ZeroLimitedOperation[] | undefined;
  bankComplianceLanguage: string | undefined;
}

export type ExchangeKeysDownloadResult =
  | { type: "ok"; res: ExchangeKeysDownloadSuccessResult }
  | { type: "version-incompatible"; exchangeProtocolVersion: string };

/**
 * Download and validate an exchange's /keys data.
 */
async function downloadExchangeKeysInfo(
  baseUrl: string,
  http: HttpRequestLibrary,
  timeout: Duration,
  cancellationToken: CancellationToken,
  noCache: boolean,
): Promise<ExchangeKeysDownloadResult> {
  const keysUrl = new URL("keys", baseUrl);

  const headers: Record<string, string> = {};
  if (noCache) {
    headers["cache-control"] = "no-cache";
  }
  const resp = await http.fetch(keysUrl.href, {
    timeout,
    cancellationToken,
    headers,
  });

  logger.trace("got response to /keys request");

  // We must make sure to parse out the protocol version
  // before we validate the body.
  // Otherwise the parser might complain with a hard to understand
  // message about some other field, when it is just a version
  // incompatibility.

  let keysJson;

  try {
    keysJson = await resp.json();
  } catch (e) {
    throw TalerError.fromDetail(
      TalerErrorCode.WALLET_RECEIVED_MALFORMED_RESPONSE,
      {
        requestUrl: resp.requestUrl,
        requestMethod: resp.requestMethod,
        httpStatusCode: resp.status,
        validationError: e instanceof Error ? e.message : String(e),
      },
      "Couldn't parse JSON format from response",
    );
  }
  const protocolVersion = keysJson.version;
  if (typeof protocolVersion !== "string") {
    throw TalerError.fromDetail(
      TalerErrorCode.WALLET_RECEIVED_MALFORMED_RESPONSE,
      {
        requestUrl: resp.requestUrl,
        requestMethod: resp.requestMethod,
        httpStatusCode: resp.status,
        validationError: "version field missing",
      },
      "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) {
    return {
      type: "version-incompatible",
      exchangeProtocolVersion: protocolVersion,
    };
  }

  const exchangeKeysResponseUnchecked = await readSuccessResponseJsonOrThrow(
    resp,
    codecForExchangeKeysResponse(),
  );

  if (exchangeKeysResponseUnchecked.denominations.length === 0) {
    throw TalerError.fromDetail(
      TalerErrorCode.WALLET_EXCHANGE_DENOMINATIONS_INSUFFICIENT,
      {
        exchangeBaseUrl: baseUrl,
      },
      "exchange doesn't offer any denominations",
    );
  }

  const currency = exchangeKeysResponseUnchecked.currency;

  const currentDenominations: DenominationRecord[] = [];

  for (const denomGroup of exchangeKeysResponseUnchecked.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: exchangeKeysResponseUnchecked.master_public_key,
            isOffered: true,
            isRevoked: false,
            isLost: denomIn.lost ?? 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,
            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;
    }
  }

  const res: ExchangeKeysDownloadSuccessResult = {
    masterPublicKey: exchangeKeysResponseUnchecked.master_public_key,
    currency,
    baseUrl: exchangeKeysResponseUnchecked.base_url,
    auditors: exchangeKeysResponseUnchecked.auditors,
    currentDenominations,
    protocolVersion: exchangeKeysResponseUnchecked.version,
    signingKeys: exchangeKeysResponseUnchecked.signkeys,
    reserveClosingDelay: exchangeKeysResponseUnchecked.reserve_closing_delay,
    expiry: AbsoluteTime.toProtocolTimestamp(
      getExpiry(resp, {
        minDuration: Duration.fromSpec({ hours: 1 }),
      }),
    ),
    recoup: exchangeKeysResponseUnchecked.recoup ?? [],
    listIssueDate: exchangeKeysResponseUnchecked.list_issue_date,
    globalFees: exchangeKeysResponseUnchecked.global_fees,
    accounts: exchangeKeysResponseUnchecked.accounts,
    wireFees: exchangeKeysResponseUnchecked.wire_fees,
    currencySpecification: exchangeKeysResponseUnchecked.currency_specification,
    walletBalanceLimits:
      exchangeKeysResponseUnchecked.wallet_balance_limit_without_kyc,
    hardLimits: exchangeKeysResponseUnchecked.hard_limits,
    zeroLimits: exchangeKeysResponseUnchecked.zero_limits,
    bankComplianceLanguage:
      exchangeKeysResponseUnchecked.bank_compliance_language,
  };
  return {
    type: "ok",
    res,
  };
}

type TosMetaResult = { type: "not-found" } | { type: "ok"; etag: string };

/**
 * Download metadata about an exchange's terms of service.
 */
async function downloadTosMeta(
  wex: WalletExecutionContext,
  exchangeBaseUrl: string,
): Promise<TosMetaResult> {
  logger.trace(`downloading exchange tos metadata for ${exchangeBaseUrl}`);
  const reqUrl = new URL("terms", exchangeBaseUrl);

  // FIXME: We can/should make a HEAD request here.
  // Not sure if qtart supports it at the moment.
  const resp = await cancelableFetch(wex, reqUrl);

  switch (resp.status) {
    case HttpStatusCode.NotFound:
    case HttpStatusCode.NotImplemented:
      return { type: "not-found" };
    case HttpStatusCode.Ok:
      break;
    default:
      throwUnexpectedRequestError(resp, await readTalerErrorResponse(resp));
  }

  const etag = resp.headers.get("taler-terms-version") || "unknown";
  return {
    type: "ok",
    etag,
  };
}

async function downloadTosFromAcceptedFormat(
  wex: WalletExecutionContext,
  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(
        wex,
        baseUrl,
        wex.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(
    wex,
    baseUrl,
    wex.http,
    timeout,
    "text/plain",
    acceptLanguage,
  );
}

/**
 * Check if an exchange entry should be considered
 * to be outdated.
 */
async function checkExchangeEntryOutdated(
  wex: WalletExecutionContext,
  tx: WalletDbReadOnlyTransaction<["exchanges", "denominations"]>,
  exchangeBaseUrl: string,
): Promise<boolean> {
  // We currently consider the exchange outdated when no
  // denominations can be used for withdrawal.

  logger.trace(`checking if exchange entry for ${exchangeBaseUrl} is outdated`);
  let numOkay = 0;
  let denoms =
    await tx.denominations.indexes.byExchangeBaseUrl.getAll(exchangeBaseUrl);
  logger.trace(`exchange entry has ${denoms.length} denominations`);
  for (const denom of denoms) {
    const denomOkay = isCandidateWithdrawableDenom(denom);
    if (denomOkay) {
      numOkay++;
    }
  }
  logger.trace(`Of these, ${numOkay} are usable`);
  return numOkay === 0;
}

/**
 * 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.
 *
 * If options.forceUnavailable is set, the exchange is put into
 * an unavailable state.  This flag should be set whenever
 * some other operation notices that something is wrong
 * with the exchange.
 */
export async function startUpdateExchangeEntry(
  wex: WalletExecutionContext,
  exchangeBaseUrl: string,
  options: { forceUpdate?: boolean; forceUnavailable?: boolean } = {},
): Promise<void> {
  logger.trace(
    `starting update of exchange entry ${exchangeBaseUrl}, forced=${
      options.forceUpdate ?? false
    }`,
  );

  await wex.db.runReadWriteTx(
    { storeNames: ["exchanges", "exchangeDetails"] },
    async (tx) => {
      wex.ws.exchangeCache.clear();
      return provideExchangeRecordInTx(wex.ws, tx, exchangeBaseUrl);
    },
  );

  const res = await wex.db.runReadWriteTx(
    { storeNames: ["exchanges", "operationRetries", "denominations"] },
    async (tx) => {
      const r = await tx.exchanges.get(exchangeBaseUrl);
      if (!r) {
        throw Error("exchange not found");
      }

      const oldExchangeState = getExchangeState(r);
      if (options.forceUnavailable) {
        switch (r.updateStatus) {
          case ExchangeEntryDbUpdateStatus.UnavailableUpdate:
            return undefined;
          default:
            r.lastUpdate = undefined;
            r.nextUpdateStamp = timestampPreciseToDb(
              TalerPreciseTimestamp.now(),
            );
            r.cachebreakNextUpdate = options.forceUpdate;
            r.updateStatus = ExchangeEntryDbUpdateStatus.UnavailableUpdate;
        }
      } else {
        switch (r.updateStatus) {
          case ExchangeEntryDbUpdateStatus.UnavailableUpdate:
            r.cachebreakNextUpdate = options.forceUpdate;
            break;
          case ExchangeEntryDbUpdateStatus.Suspended:
            r.cachebreakNextUpdate = options.forceUpdate;
            break;
          case ExchangeEntryDbUpdateStatus.ReadyUpdate: {
            const outdated = await checkExchangeEntryOutdated(
              wex,
              tx,
              exchangeBaseUrl,
            );
            if (outdated) {
              r.updateStatus = ExchangeEntryDbUpdateStatus.OutdatedUpdate;
            } else {
              r.updateStatus = ExchangeEntryDbUpdateStatus.ReadyUpdate;
            }
            r.cachebreakNextUpdate = options.forceUpdate;
            break;
          }
          case ExchangeEntryDbUpdateStatus.OutdatedUpdate:
            r.cachebreakNextUpdate = options.forceUpdate;
            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)
              )
            ) {
              return undefined;
            }
            const outdated = await checkExchangeEntryOutdated(
              wex,
              tx,
              exchangeBaseUrl,
            );
            if (outdated) {
              r.updateStatus = ExchangeEntryDbUpdateStatus.OutdatedUpdate;
            } else {
              r.updateStatus = ExchangeEntryDbUpdateStatus.ReadyUpdate;
            }
            r.cachebreakNextUpdate = options.forceUpdate;
            break;
          }
          case ExchangeEntryDbUpdateStatus.Initial:
            r.cachebreakNextUpdate = options.forceUpdate;
            r.updateStatus = ExchangeEntryDbUpdateStatus.InitialUpdate;
            break;
          case ExchangeEntryDbUpdateStatus.InitialUpdate:
            r.cachebreakNextUpdate = options.forceUpdate;
            break;
        }
      }
      wex.ws.exchangeCache.clear();
      await tx.exchanges.put(r);
      const newExchangeState = getExchangeState(r);
      const taskId = TaskIdentifiers.forExchangeUpdate(r);
      return { oldExchangeState, newExchangeState, taskId };
    },
  );
  if (!res) {
    // Exchange entry is already good.
    return;
  }
  const { oldExchangeState, newExchangeState, taskId } = res;
  wex.ws.notify({
    type: NotificationType.ExchangeStateTransition,
    exchangeBaseUrl,
    newExchangeState: newExchangeState,
    oldExchangeState: oldExchangeState,
  });
  logger.info(`updating exchange in task ${taskId}`);

  await wex.taskScheduler.resetTaskRetries(taskId);
}

/**
 * Basic information about an exchange in a ready state.
 */
export interface ReadyExchangeSummary {
  exchangeBaseUrl: string;
  currency: string;
  masterPub: string;
  tosStatus: ExchangeTosStatus;
  tosAcceptedEtag: string | undefined;
  tosCurrentEtag: string | undefined;
  wireInfo: WireInfo;
  protocolVersionRange: string;
  tosAcceptedTimestamp: TalerPreciseTimestamp | undefined;
  scopeInfo: ScopeInfo;
  walletBalanceLimitWithoutKyc: AmountString[] | undefined;
  zeroLimits: ZeroLimitedOperation[];
  hardLimits: AccountLimit[];
}

/**
 * Ensure that a fresh exchange entry exists for the given
 * exchange base URL.
 *
 *
 * 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(
  wex: WalletExecutionContext,
  baseUrl: string,
  options: {
    forceUpdate?: boolean;
  } = {},
): Promise<ReadyExchangeSummary> {
  logger.trace(`fetch fresh ${baseUrl} forced ${options.forceUpdate}`);

  if (!options.forceUpdate) {
    const cachedResp = wex.ws.exchangeCache.get(baseUrl);
    if (cachedResp) {
      return cachedResp;
    }
  } else {
    wex.ws.exchangeCache.clear();
  }

  await wex.db.runReadOnlyTx(
    { storeNames: ["exchangeBaseUrlFixups"] },
    async (tx) => {
      const rec = await tx.exchangeBaseUrlFixups.get(baseUrl);
      if (rec) {
        logger.warn(`using replacement ${rec.replacement} for ${baseUrl}`);
        baseUrl = rec.replacement;
      }
    },
  );

  await wex.taskScheduler.ensureRunning();

  await startUpdateExchangeEntry(wex, baseUrl, {
    forceUpdate: options.forceUpdate,
  });

  const resp = await waitReadyExchange(wex, baseUrl, options);
  return resp;
}

/**
 * Wait until an exchange is in a ready state.
 *
 * If options.noBail is set, do not stop waiting when
 * the exchange is in an unavailable or error state.
 *
 * If options.forceUpdate is set, only return when
 * the exchange is really not updating anymore,
 * even when the exchange entry still looks recent enough.
 */
export async function waitReadyExchange(
  wex: WalletExecutionContext,
  exchangeBaseUrl: string,
  options: {
    forceUpdate?: boolean;
    expectedMasterPub?: string;
    noBail?: boolean;
  } = {},
): Promise<ReadyExchangeSummary> {
  logger.trace(`waiting for exchange ${exchangeBaseUrl} to become ready`);

  const operationId = constructTaskIdentifier({
    tag: PendingTaskType.ExchangeUpdate,
    exchangeBaseUrl: exchangeBaseUrl,
  });

  let res: ReadyExchangeSummary | undefined = undefined;

  await genericWaitForState(wex, {
    filterNotification(notif): boolean {
      return (
        notif.type === NotificationType.ExchangeStateTransition &&
        notif.exchangeBaseUrl === exchangeBaseUrl
      );
    },
    async checkState(): Promise<boolean> {
      const { exchange, exchangeDetails, retryInfo, scopeInfo } =
        await wex.db.runReadOnlyTx(
          {
            storeNames: [
              "exchanges",
              "exchangeDetails",
              "operationRetries",
              "globalCurrencyAuditors",
              "globalCurrencyExchanges",
            ],
          },
          async (tx) => {
            const exchange = await tx.exchanges.get(exchangeBaseUrl);
            const exchangeDetails = await getExchangeRecordsInternal(
              tx,
              exchangeBaseUrl,
            );
            const retryInfo = await tx.operationRetries.get(operationId);
            let scopeInfo: ScopeInfo | undefined = undefined;
            if (exchange && exchangeDetails) {
              scopeInfo = await internalGetExchangeScopeInfo(
                tx,
                exchangeDetails,
              );
            }
            return { exchange, exchangeDetails, retryInfo, scopeInfo };
          },
        );

      if (!exchange) {
        throw Error("exchange entry does not exist anymore");
      }

      let ready = false;

      switch (exchange.updateStatus) {
        case ExchangeEntryDbUpdateStatus.Ready:
          ready = true;
          break;
        case ExchangeEntryDbUpdateStatus.ReadyUpdate:
          // If the update is forced,
          // we wait until we're in a full "ready" state,
          // as we're not happy with the stale information.
          if (!options.forceUpdate) {
            ready = true;
          }
          break;
        case ExchangeEntryDbUpdateStatus.UnavailableUpdate: {
          if (!options.noBail) {
            throw TalerError.fromDetail(
              TalerErrorCode.WALLET_EXCHANGE_UNAVAILABLE,
              {
                exchangeBaseUrl: exchangeBaseUrl,
                innerError: retryInfo?.lastError ?? exchange.unavailableReason,
              },
            );
          }
          break;
        }
        case ExchangeEntryDbUpdateStatus.OutdatedUpdate:
        default: {
          if (retryInfo && !options.noBail) {
            throw TalerError.fromDetail(
              TalerErrorCode.WALLET_EXCHANGE_UNAVAILABLE,
              {
                exchangeBaseUrl: exchangeBaseUrl,
                innerError: retryInfo?.lastError ?? exchange.unavailableReason,
              },
            );
          }
          break;
        }
      }

      if (!ready) {
        return false;
      }

      if (!exchangeDetails) {
        throw Error("invariant failed");
      }

      if (!scopeInfo) {
        throw Error("invariant failed");
      }

      const mySummary: ReadyExchangeSummary = {
        currency: exchangeDetails.currency,
        exchangeBaseUrl: exchangeBaseUrl,
        masterPub: exchangeDetails.masterPublicKey,
        tosStatus: getExchangeTosStatusFromRecord(exchange),
        tosAcceptedEtag: exchange.tosAcceptedEtag,
        wireInfo: exchangeDetails.wireInfo,
        protocolVersionRange: exchangeDetails.protocolVersionRange,
        tosCurrentEtag: exchange.tosCurrentEtag,
        tosAcceptedTimestamp: timestampOptionalPreciseFromDb(
          exchange.tosAcceptedTimestamp,
        ),
        scopeInfo,
        walletBalanceLimitWithoutKyc: exchangeDetails.walletBalanceLimits,
        hardLimits: exchangeDetails.hardLimits ?? [],
        zeroLimits: exchangeDetails.zeroLimits ?? [],
      };

      if (options.expectedMasterPub) {
        if (mySummary.masterPub !== options.expectedMasterPub) {
          throw Error(
            "public key of the exchange does not match expected public key",
          );
        }
      }
      res = mySummary;
      return true;
    },
  });

  checkLogicInvariant(!!res);
  wex.ws.exchangeCache.put(exchangeBaseUrl, res);
  return res;
}

function checkPeerPaymentsDisabled(
  keysInfo: ExchangeKeysDownloadSuccessResult,
): boolean {
  const now = AbsoluteTime.now();
  for (let gf of keysInfo.globalFees) {
    const isActive = AbsoluteTime.isBetween(
      now,
      AbsoluteTime.fromProtocolTimestamp(gf.start_date),
      AbsoluteTime.fromProtocolTimestamp(gf.end_date),
    );
    if (!isActive) {
      continue;
    }
    return false;
  }
  // No global fees, we can't do p2p payments!
  return true;
}

function checkNoFees(keysInfo: ExchangeKeysDownloadSuccessResult): boolean {
  for (const gf of keysInfo.globalFees) {
    if (!Amounts.isZero(gf.account_fee)) {
      return false;
    }
    if (!Amounts.isZero(gf.history_fee)) {
      return false;
    }
    if (!Amounts.isZero(gf.purse_fee)) {
      return false;
    }
  }
  for (const denom of keysInfo.currentDenominations) {
    if (!Amounts.isZero(denom.fees.feeWithdraw)) {
      return false;
    }
    if (!Amounts.isZero(denom.fees.feeDeposit)) {
      return false;
    }
    if (!Amounts.isZero(denom.fees.feeRefund)) {
      return false;
    }
    if (!Amounts.isZero(denom.fees.feeRefresh)) {
      return false;
    }
  }
  for (const wft of Object.values(keysInfo.wireFees)) {
    for (const wf of wft) {
      if (!Amounts.isZero(wf.wire_fee)) {
        return false;
      }
    }
  }
  return true;
}

async function handleExchageUpdateIncompatible(
  wex: WalletExecutionContext,
  exchangeBaseUrl: string,
  exchangeProtocolVersion: string,
): Promise<TaskRunResult> {
  const updated = await wex.db.runReadWriteTx(
    {
      storeNames: ["exchanges"],
    },
    async (tx) => {
      const r = await tx.exchanges.get(exchangeBaseUrl);
      if (!r) {
        logger.warn(`exchange ${exchangeBaseUrl} no longer present`);
        return undefined;
      }
      switch (r.updateStatus) {
        case ExchangeEntryDbUpdateStatus.InitialUpdate:
        case ExchangeEntryDbUpdateStatus.OutdatedUpdate:
        case ExchangeEntryDbUpdateStatus.ReadyUpdate:
        case ExchangeEntryDbUpdateStatus.UnavailableUpdate:
          break;
        default:
          return undefined;
      }
      const oldExchangeState = getExchangeState(r);
      r.updateRetryCounter = (r.updateRetryCounter ?? 0) + 1;
      r.nextUpdateStamp = computeDbBackoff(r.updateRetryCounter);
      r.updateStatus = ExchangeEntryDbUpdateStatus.UnavailableUpdate;
      r.unavailableReason = makeTalerErrorDetail(
        TalerErrorCode.WALLET_EXCHANGE_PROTOCOL_VERSION_INCOMPATIBLE,
        {
          exchangeProtocolVersion: exchangeProtocolVersion,
          walletProtocolVersion: WALLET_EXCHANGE_PROTOCOL_VERSION,
        },
      );
      const newExchangeState = getExchangeState(r);
      await tx.exchanges.put(r);
      return {
        oldExchangeState,
        newExchangeState,
      };
    },
  );
  if (updated) {
    wex.ws.notify({
      type: NotificationType.ExchangeStateTransition,
      exchangeBaseUrl,
      newExchangeState: updated.newExchangeState,
      oldExchangeState: updated.oldExchangeState,
    });
  }
  // Next invocation will cause the task to be run again
  // at the necessary time.
  return TaskRunResult.progress();
}

/**
 * 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(
  wex: WalletExecutionContext,
  exchangeBaseUrl: string,
): Promise<TaskRunResult> {
  if (!wex.ws.networkAvailable) {
    return TaskRunResult.networkRequired();
  }

  logger.trace(`updating exchange info for ${exchangeBaseUrl}`);

  const oldExchangeRec = await wex.db.runReadOnlyTx(
    { storeNames: ["exchanges"] },
    async (tx) => {
      return tx.exchanges.get(exchangeBaseUrl);
    },
  );

  if (!oldExchangeRec) {
    logger.info(`not updating exchange ${exchangeBaseUrl}, no record in DB`);
    return TaskRunResult.finished();
  }

  let updateRequestedExplicitly = false;
  switch (oldExchangeRec.updateStatus) {
    case ExchangeEntryDbUpdateStatus.Suspended:
      logger.info(`not updating exchange in status "suspended"`);
      return TaskRunResult.finished();
    case ExchangeEntryDbUpdateStatus.Initial:
      logger.info(`not updating exchange in status "initial"`);
      return TaskRunResult.finished();
    case ExchangeEntryDbUpdateStatus.OutdatedUpdate:
      updateRequestedExplicitly = true;
      break;
    case ExchangeEntryDbUpdateStatus.InitialUpdate:
      updateRequestedExplicitly = true;
      break;
    case ExchangeEntryDbUpdateStatus.ReadyUpdate:
      updateRequestedExplicitly = true;
      break;
    case ExchangeEntryDbUpdateStatus.UnavailableUpdate:
      // Only retry when scheduled to respect backoff
      break;
    case ExchangeEntryDbUpdateStatus.Ready:
      break;
    default:
      assertUnreachable(oldExchangeRec.updateStatus);
  }

  if (!updateRequestedExplicitly) {
    // If the update wasn't requested explicitly,
    // check if we really need to update.

    let nextUpdateStamp = timestampAbsoluteFromDb(
      oldExchangeRec.nextUpdateStamp,
    );

    if (
      oldExchangeRec.lastUpdate != null &&
      !AbsoluteTime.isNever(nextUpdateStamp) &&
      !AbsoluteTime.isExpired(nextUpdateStamp)
    ) {
      logger.trace(
        `exchange update for ${exchangeBaseUrl} not necessary, scheduled for ${AbsoluteTime.toIsoString(
          nextUpdateStamp,
        )}`,
      );
      logger.trace("update not necessary, running again later");
      return TaskRunResult.runAgainAt(nextUpdateStamp);
    }
  }

  // When doing the auto-refresh check, we always update
  // the key info before that.

  logger.trace("updating exchange /keys info");

  const timeout = getExchangeRequestTimeout();

  let keysInfoRes: ExchangeKeysDownloadResult;

  try {
    keysInfoRes = await downloadExchangeKeysInfo(
      exchangeBaseUrl,
      wex.http,
      timeout,
      wex.cancellationToken,
      oldExchangeRec.cachebreakNextUpdate ?? false,
    );
  } catch (e) {
    logger.warn(`unable to download exchange keys for ${exchangeBaseUrl}`);
    // If keys download fails, check if there's a migration.
    // Only if the migration target is reachable, migrate there!
    const plan = wex.ws.exchangeMigrationPlan.get(exchangeBaseUrl);
    if (plan) {
      logger.warn(
        `trying migration from ${exchangeBaseUrl} to ${plan.newExchangeBaseUrl}`,
      );
      const newExchangeClient = walletExchangeClient(
        plan.newExchangeBaseUrl,
        wex,
      );
      const newExchangeKeys = await newExchangeClient.getKeys();
      logger.info(`new exchange status ${newExchangeKeys.case}`);
      if (
        newExchangeKeys.case !== "ok" ||
        newExchangeKeys.body.base_url !== plan.newExchangeBaseUrl
      ) {
        logger.info(`not migrating`);
        throw e;
      }
      logger.info(`migrating`);
      await migrateExchange(wex, {
        oldExchangeBaseUrl: exchangeBaseUrl,
        newExchangeBaseUrl: plan.newExchangeBaseUrl,
        trigger: ExchangeMigrationReason.UnavailableOldUrl,
      });
      return TaskRunResult.finished();
    }
    throw e;
  }

  logger.trace("validating exchange wire info");

  switch (keysInfoRes.type) {
    case "version-incompatible":
      return handleExchageUpdateIncompatible(
        wex,
        exchangeBaseUrl,
        keysInfoRes.exchangeProtocolVersion,
      );
  }

  const keysInfo = keysInfoRes.res;

  if (keysInfo.baseUrl != exchangeBaseUrl) {
    const plan = wex.ws.exchangeMigrationPlan.get(exchangeBaseUrl);
    if (plan?.newExchangeBaseUrl === keysInfo.baseUrl) {
      const newExchangeClient = walletExchangeClient(
        plan.newExchangeBaseUrl,
        wex,
      );
      const newExchangeKeys = await newExchangeClient.getKeys();
      if (
        newExchangeKeys.case !== "ok" ||
        newExchangeKeys.body.base_url !== plan.newExchangeBaseUrl
      ) {
        logger.warn("ignoring migration record, new URL not reachable");
        const errorDetail: TalerErrorDetail = makeErrorDetail(
          TalerErrorCode.WALLET_EXCHANGE_BASE_URL_MISMATCH,
          {
            urlWallet: exchangeBaseUrl,
            urlExchange: keysInfo.baseUrl,
          },
        );
        return TaskRunResult.error(errorDetail);
      }
      await migrateExchange(wex, {
        oldExchangeBaseUrl: exchangeBaseUrl,
        newExchangeBaseUrl: plan.newExchangeBaseUrl,
        trigger: ExchangeMigrationReason.MismatchedBaseUrl,
      });
      return TaskRunResult.backoff();
    }
    logger.warn("exchange base URL mismatch");
    const errorDetail: TalerErrorDetail = makeErrorDetail(
      TalerErrorCode.WALLET_EXCHANGE_BASE_URL_MISMATCH,
      {
        urlWallet: exchangeBaseUrl,
        urlExchange: keysInfo.baseUrl,
      },
    );
    return TaskRunResult.error(errorDetail);
  }

  const version = LibtoolVersion.parseVersion(keysInfo.protocolVersion);
  if (!version) {
    // Should have been validated earlier.
    throw Error("unexpected invalid version");
  }

  const wireInfo = await validateWireInfo(
    wex,
    version.current,
    keysInfo,
    keysInfo.masterPublicKey,
  );

  const globalFees = await validateGlobalFees(
    wex,
    keysInfo.globalFees,
    keysInfo.masterPublicKey,
  );

  logger.trace("finished validating exchange /wire info");

  const tosMeta = await downloadTosMeta(wex, exchangeBaseUrl);

  logger.trace("updating exchange info in database");

  let ageMask = 0;
  for (const x of keysInfo.currentDenominations) {
    if (isCandidateWithdrawableDenom(x) && x.denomPub.age_mask != 0) {
      ageMask = x.denomPub.age_mask;
      break;
    }
  }
  let noFees = checkNoFees(keysInfo);
  let peerPaymentsDisabled = checkPeerPaymentsDisabled(keysInfo);

  const updated = await wex.db.runReadWriteTx(
    {
      storeNames: [
        "exchanges",
        "exchangeDetails",
        "exchangeSignKeys",
        "denominations",
        "coins",
        "refreshGroups",
        "recoupGroups",
        "coinAvailability",
        "denomLossEvents",
        "currencyInfo",
        "transactionsMeta",
      ],
    },
    async (tx) => {
      const r = await tx.exchanges.get(exchangeBaseUrl);
      if (!r) {
        logger.warn(`exchange ${exchangeBaseUrl} no longer present`);
        return;
      }

      wex.ws.clearAllCaches();

      const oldExchangeState = getExchangeState(r);
      const existingDetails = await getExchangeRecordsInternal(tx, r.baseUrl);
      let detailsPointerChanged = false;
      if (!existingDetails) {
        detailsPointerChanged = true;
      }
      let detailsIncompatible = false;
      let conflictHint: string | undefined = undefined;
      if (existingDetails) {
        if (existingDetails.masterPublicKey !== keysInfo.masterPublicKey) {
          detailsIncompatible = true;
          detailsPointerChanged = true;
          conflictHint = "master public key changed";
        } else if (existingDetails.currency !== keysInfo.currency) {
          detailsIncompatible = true;
          detailsPointerChanged = true;
          conflictHint = "currency changed";
        }
        // FIXME: We need to do some more consistency checks!
      }
      if (detailsIncompatible) {
        logger.warn(
          `exchange ${r.baseUrl} has incompatible data in /keys, not updating`,
        );
        // We don't support this gracefully right now.
        // See https://bugs.taler.net/n/8576
        r.updateStatus = ExchangeEntryDbUpdateStatus.UnavailableUpdate;
        r.unavailableReason = makeTalerErrorDetail(
          TalerErrorCode.WALLET_EXCHANGE_ENTRY_UPDATE_CONFLICT,
          {
            detail: conflictHint,
          },
        );
        r.updateRetryCounter = (r.updateRetryCounter ?? 0) + 1;
        r.nextUpdateStamp = computeDbBackoff(r.updateRetryCounter);
        r.nextRefreshCheckStamp = timestampPreciseToDb(
          AbsoluteTime.toPreciseTimestamp(AbsoluteTime.never()),
        );
        r.cachebreakNextUpdate = true;
        await tx.exchanges.put(r);
        return {
          oldExchangeState,
          newExchangeState: getExchangeState(r),
        };
      }
      delete r.unavailableReason;
      r.updateRetryCounter = 0;
      const newDetails: ExchangeDetailsRecord = {
        auditors: keysInfo.auditors,
        currency: keysInfo.currency,
        masterPublicKey: keysInfo.masterPublicKey,
        protocolVersionRange: keysInfo.protocolVersion,
        reserveClosingDelay: keysInfo.reserveClosingDelay,
        globalFees,
        exchangeBaseUrl: r.baseUrl,
        wireInfo,
        ageMask,
        walletBalanceLimits: keysInfo.walletBalanceLimits,
        hardLimits: keysInfo.hardLimits,
        zeroLimits: keysInfo.zeroLimits,
        bankComplianceLanguage: keysInfo.bankComplianceLanguage,
      };
      r.noFees = noFees;
      r.peerPaymentsDisabled = peerPaymentsDisabled;
      switch (tosMeta.type) {
        case "not-found":
          r.tosCurrentEtag = undefined;
          break;
        case "ok":
          r.tosCurrentEtag = tosMeta.etag;
          break;
      }
      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;
      r.cachebreakNextUpdate = false;
      await tx.exchanges.put(r);

      if (keysInfo.currencySpecification) {
        // Since this is the per-exchange currency info,
        // we update it when the exchange changes it.
        await WalletDbHelpers.upsertCurrencyInfo(tx, {
          currencySpec: keysInfo.currencySpecification,
          scopeInfo: {
            type: ScopeType.Exchange,
            currency: newDetails.currency,
            url: exchangeBaseUrl,
          },
          source: "exchange",
        });
      }

      const drRowId = await tx.exchangeDetails.put(newDetails);
      checkDbInvariant(
        typeof drRowId.key === "number",
        "exchange details key is not a 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),
        });
      }

      // In the future: Filter out old denominations by index
      const allOldDenoms =
        await tx.denominations.indexes.byExchangeBaseUrl.getAll(
          exchangeBaseUrl,
        );
      const oldDenomByDph = new Map<string, DenominationRecord>();
      for (const denom of allOldDenoms) {
        oldDenomByDph.set(denom.denomPubHash, denom);
      }

      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 = oldDenomByDph.get(currentDenom.denomPubHash);
        if (oldDenom) {
          // FIXME: Do consistency check, report to auditor if necessary.
          // See https://bugs.taler.net/n/8594

          // Mark lost denominations as lost.
          if (currentDenom.isLost && !oldDenom.isLost) {
            logger.warn(
              `marking denomination ${currentDenom.denomPubHash} of ${exchangeBaseUrl} as lost`,
            );
            oldDenom.isLost = true;
            await tx.denominations.put(currentDenom);
          }
        } else {
          await tx.denominations.put(currentDenom);
        }
      }

      // Update list issue date for all denominations,
      // and mark non-offered denominations as such.
      for (const x of allOldDenoms) {
        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 {
          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");

      const denomLossResult = await handleDenomLoss(
        wex,
        tx,
        newDetails.currency,
        exchangeBaseUrl,
      );

      await handleRecoup(wex, tx, exchangeBaseUrl, keysInfo.recoup);

      const newExchangeState = getExchangeState(r);

      return {
        exchange: r,
        exchangeDetails: newDetails,
        oldExchangeState,
        newExchangeState,
        denomLossResult,
      };
    },
  );

  if (!updated) {
    throw Error("something went wrong with updating the exchange");
  }

  if (updated.denomLossResult) {
    for (const notif of updated.denomLossResult.notifications) {
      wex.ws.notify(notif);
    }
  }

  logger.trace("done updating exchange info in database");

  wex.ws.notify({
    type: NotificationType.ExchangeStateTransition,
    exchangeBaseUrl,
    newExchangeState: updated.newExchangeState,
    oldExchangeState: updated.oldExchangeState,
  });

  // Always trigger auto-refresh after an exchange update.
  await doExchangeAutoRefresh(wex, exchangeBaseUrl);

  // Make sure an auto-refresh task is scheduled for this exchange.
  const autoRefreshTaskId =
    TaskIdentifiers.forExchangeAutoRefreshFromUrl(exchangeBaseUrl);
  await wex.taskScheduler.resetTaskRetries(autoRefreshTaskId);

  // Next invocation will cause the task to be run again
  // at the necessary time.
  return TaskRunResult.progress();
}

async function doExchangeAutoRefresh(
  wex: WalletExecutionContext,
  exchangeBaseUrl: string,
): Promise<void> {
  await updateWithdrawalDenomsForExchange(wex, exchangeBaseUrl);

  let minCheckThreshold = AbsoluteTime.addDuration(
    AbsoluteTime.now(),
    Duration.fromSpec({ days: 1 }),
  );

  await wex.db.runReadWriteTx(
    {
      storeNames: [
        "coinAvailability",
        "coinHistory",
        "coins",
        "denominations",
        "exchanges",
        "refreshGroups",
        "refreshSessions",
        "transactionsMeta",
      ],
    },
    async (tx) => {
      const exchange = await tx.exchanges.get(exchangeBaseUrl);
      if (!exchange || !exchange.detailsPointer) {
        return;
      }
      const coins = await tx.coins.indexes.byBaseUrl
        .iter(exchangeBaseUrl)
        .toArray();
      const refreshCoins: CoinRefreshRequest[] = [];
      for (const coin of coins) {
        if (coin.status !== CoinStatus.Fresh) {
          continue;
        }
        const denom = await tx.denominations.get([
          exchangeBaseUrl,
          coin.denomPubHash,
        ]);
        if (!denom) {
          logger.warn("denomination not in database");
          continue;
        }
        const executeThreshold = getAutoRefreshExecuteThresholdForDenom(denom);
        if (AbsoluteTime.isExpired(executeThreshold)) {
          refreshCoins.push({
            coinPub: coin.coinPub,
            amount: denom.value,
          });
        } else {
          const checkThreshold = getAutoRefreshCheckThreshold(denom);
          minCheckThreshold = AbsoluteTime.min(
            minCheckThreshold,
            checkThreshold,
          );
        }
      }
      if (refreshCoins.length > 0) {
        const res = await createRefreshGroup(
          wex,
          tx,
          exchange.detailsPointer?.currency,
          refreshCoins,
          RefreshReason.Scheduled,
          undefined,
        );
        logger.trace(
          `created refresh group for auto-refresh (${res.refreshGroupId})`,
        );
      }
      logger.trace(
        `next refresh check at ${AbsoluteTime.toIsoString(minCheckThreshold)}`,
      );
      exchange.nextRefreshCheckStamp = timestampPreciseToDb(
        AbsoluteTime.toPreciseTimestamp(minCheckThreshold),
      );
      wex.ws.exchangeCache.clear();
      await tx.exchanges.put(exchange);
    },
  );
}

export async function processTaskExchangeAutoRefresh(
  wex: WalletExecutionContext,
  exchangeBaseUrl: string,
): Promise<TaskRunResult> {
  logger.trace(`doing auto-refresh check for '${exchangeBaseUrl}'`);

  const oldExchangeRec = await wex.db.runReadOnlyTx(
    { storeNames: ["exchanges"] },
    async (tx) => {
      return tx.exchanges.get(exchangeBaseUrl);
    },
  );

  if (!oldExchangeRec) {
    return TaskRunResult.finished();
  }

  switch (oldExchangeRec.entryStatus) {
    case ExchangeEntryDbRecordStatus.Preset:
      logger.trace(
        "exchange auto-refresh check not necessary, exchange is preset",
      );
      return TaskRunResult.finished();
  }

  let nextRefreshCheckStamp = timestampAbsoluteFromDb(
    oldExchangeRec.nextRefreshCheckStamp,
  );
  if (
    !AbsoluteTime.isNever(nextRefreshCheckStamp) &&
    !AbsoluteTime.isExpired(nextRefreshCheckStamp)
  ) {
    logger.trace(
      `exchange refresh check for ${exchangeBaseUrl} not necessary, scheduled for ${AbsoluteTime.toIsoString(
        nextRefreshCheckStamp,
      )}`,
    );
    logger.trace(
      "exchange auto-refresh check not necessary, running again later",
    );
    return TaskRunResult.runAgainAt(nextRefreshCheckStamp);
  }

  logger.trace("exchange auto-refresh check necessary");

  await fetchFreshExchange(wex, exchangeBaseUrl);

  await doExchangeAutoRefresh(wex, exchangeBaseUrl);

  return TaskRunResult.progress();
}

interface DenomLossResult {
  notifications: WalletNotification[];
}

async function handleDenomLoss(
  wex: WalletExecutionContext,
  tx: WalletDbReadWriteTransaction<
    [
      "coinAvailability",
      "denominations",
      "denomLossEvents",
      "coins",
      "transactionsMeta",
    ]
  >,
  currency: string,
  exchangeBaseUrl: string,
): Promise<DenomLossResult> {
  const coinAvailabilityRecs =
    await tx.coinAvailability.indexes.byExchangeBaseUrl.getAll(exchangeBaseUrl);
  const denomsVanished: string[] = [];
  const denomsUnoffered: string[] = [];
  const denomsExpired: string[] = [];
  let amountVanished = Amount.zeroOfCurrency(currency);
  let amountExpired = Amount.zeroOfCurrency(currency);
  let amountUnoffered = Amount.zeroOfCurrency(currency);

  const result: DenomLossResult = {
    notifications: [],
  };

  for (const coinAv of coinAvailabilityRecs) {
    if (coinAv.freshCoinCount <= 0) {
      continue;
    }
    const n = coinAv.freshCoinCount;
    const denom = await tx.denominations.get([
      coinAv.exchangeBaseUrl,
      coinAv.denomPubHash,
    ]);
    const timestampExpireDeposit = !denom
      ? undefined
      : timestampAbsoluteFromDb(denom.stampExpireDeposit);
    if (!denom) {
      // Remove availability
      coinAv.freshCoinCount = 0;
      coinAv.visibleCoinCount = 0;
      await tx.coinAvailability.put(coinAv);
      denomsVanished.push(coinAv.denomPubHash);
      const total = Amount.from(coinAv.value).mult(n);
      amountVanished = amountVanished.add(total);
    } else if (!denom.isOffered) {
      // Remove availability
      coinAv.freshCoinCount = 0;
      coinAv.visibleCoinCount = 0;
      await tx.coinAvailability.put(coinAv);
      denomsUnoffered.push(coinAv.denomPubHash);
      const total = Amount.from(coinAv.value).mult(n);
      amountUnoffered = amountUnoffered.add(total);
    } else if (
      timestampExpireDeposit &&
      AbsoluteTime.isExpired(timestampExpireDeposit)
    ) {
      // Remove availability
      coinAv.freshCoinCount = 0;
      coinAv.visibleCoinCount = 0;
      await tx.coinAvailability.put(coinAv);
      denomsExpired.push(coinAv.denomPubHash);
      const total = Amount.from(coinAv.value).mult(n);
      amountExpired = amountExpired.add(total);
    } else {
      // Denomination is still fine!
      continue;
    }

    logger.warn(`denomination ${coinAv.denomPubHash} is a loss`);

    const coins = await tx.coins.indexes.byDenomPubHash.getAll(
      coinAv.denomPubHash,
    );
    for (const coin of coins) {
      switch (coin.status) {
        case CoinStatus.Fresh:
        case CoinStatus.FreshSuspended: {
          coin.status = CoinStatus.DenomLoss;
          await tx.coins.put(coin);
          break;
        }
      }
    }
  }

  if (denomsVanished.length > 0) {
    const denomLossEventId = encodeCrock(getRandomBytes(32));
    const rec: DenomLossEventRecord = {
      denomLossEventId,
      amount: amountVanished.toString(),
      currency,
      exchangeBaseUrl,
      denomPubHashes: denomsVanished,
      eventType: DenomLossEventType.DenomVanished,
      status: DenomLossStatus.Done,
      timestampCreated: timestampPreciseToDb(TalerPreciseTimestamp.now()),
    };
    await tx.denomLossEvents.add(rec);
    const ctx = new DenomLossTransactionContext(wex, denomLossEventId);
    await ctx.updateTransactionMeta(tx);
    result.notifications.push({
      type: NotificationType.TransactionStateTransition,
      transactionId: ctx.transactionId,
      oldTxState: {
        major: TransactionMajorState.None,
      },
      newTxState: {
        major: TransactionMajorState.Done,
      },
      newStId: rec.status,
    });
    result.notifications.push({
      type: NotificationType.BalanceChange,
      hintTransactionId: ctx.transactionId,
    });
  }

  if (denomsUnoffered.length > 0) {
    const denomLossEventId = encodeCrock(getRandomBytes(32));
    const rec: DenomLossEventRecord = {
      denomLossEventId,
      amount: amountUnoffered.toString(),
      currency,
      exchangeBaseUrl,
      denomPubHashes: denomsUnoffered,
      eventType: DenomLossEventType.DenomUnoffered,
      status: DenomLossStatus.Done,
      timestampCreated: timestampPreciseToDb(TalerPreciseTimestamp.now()),
    };
    await tx.denomLossEvents.add(rec);
    const ctx = new DenomLossTransactionContext(wex, denomLossEventId);
    await ctx.updateTransactionMeta(tx);
    result.notifications.push({
      type: NotificationType.TransactionStateTransition,
      transactionId: ctx.transactionId,
      oldTxState: {
        major: TransactionMajorState.None,
      },
      newTxState: {
        major: TransactionMajorState.Done,
      },
      newStId: rec.status,
    });
    result.notifications.push({
      type: NotificationType.BalanceChange,
      hintTransactionId: ctx.transactionId,
    });
  }

  if (denomsExpired.length > 0) {
    const denomLossEventId = encodeCrock(getRandomBytes(32));
    const rec: DenomLossEventRecord = {
      denomLossEventId,
      amount: amountExpired.toString(),
      currency,
      exchangeBaseUrl,
      denomPubHashes: denomsUnoffered,
      eventType: DenomLossEventType.DenomExpired,
      status: DenomLossStatus.Done,
      timestampCreated: timestampPreciseToDb(TalerPreciseTimestamp.now()),
    };
    await tx.denomLossEvents.add(rec);
    const transactionId = constructTransactionIdentifier({
      tag: TransactionType.DenomLoss,
      denomLossEventId,
    });
    result.notifications.push({
      type: NotificationType.TransactionStateTransition,
      transactionId,
      oldTxState: {
        major: TransactionMajorState.None,
      },
      newTxState: {
        major: TransactionMajorState.Done,
      },
      newStId: rec.status,
    });
    result.notifications.push({
      type: NotificationType.BalanceChange,
      hintTransactionId: transactionId,
    });
  }

  return result;
}

export function computeDenomLossTransactionStatus(
  rec: DenomLossEventRecord,
): TransactionState {
  switch (rec.status) {
    case DenomLossStatus.Aborted:
      return {
        major: TransactionMajorState.Aborted,
      };
    case DenomLossStatus.Done:
      return {
        major: TransactionMajorState.Done,
      };
  }
}

export class DenomLossTransactionContext implements TransactionContext {
  transactionId: TransactionIdStr;

  constructor(
    private wex: WalletExecutionContext,
    public denomLossEventId: string,
  ) {
    this.transactionId = constructTransactionIdentifier({
      tag: TransactionType.DenomLoss,
      denomLossEventId,
    });
  }

  get taskId(): TaskIdStr | undefined {
    return undefined;
  }

  async updateTransactionMeta(
    tx: WalletDbReadWriteTransaction<["denomLossEvents", "transactionsMeta"]>,
  ): Promise<void> {
    const denomLossRec = await tx.denomLossEvents.get(this.denomLossEventId);
    if (!denomLossRec) {
      await tx.transactionsMeta.delete(this.transactionId);
      return;
    }
    await tx.transactionsMeta.put({
      transactionId: this.transactionId,
      status: denomLossRec.status,
      timestamp: denomLossRec.timestampCreated,
      currency: denomLossRec.currency,
      exchanges: [denomLossRec.exchangeBaseUrl],
    });
  }

  abortTransaction(): Promise<void> {
    throw new Error("Method not implemented.");
  }

  suspendTransaction(): Promise<void> {
    throw new Error("Method not implemented.");
  }

  resumeTransaction(): Promise<void> {
    throw new Error("Method not implemented.");
  }

  failTransaction(): Promise<void> {
    throw new Error("Method not implemented.");
  }

  async deleteTransaction(): Promise<void> {
    await this.wex.db.runReadWriteTx(
      { storeNames: ["denomLossEvents"] },
      async (tx) => {
        const rec = await tx.denomLossEvents.get(this.denomLossEventId);
        if (!rec) {
          return;
        }
        const oldTxState = computeDenomLossTransactionStatus(rec);
        await tx.denomLossEvents.delete(this.denomLossEventId);
        applyNotifyTransition(tx.notify, this.transactionId, {
          oldTxState,
          newTxState: {
            major: TransactionMajorState.Deleted,
          },
          balanceEffect: BalanceEffect.Any,
          newStId: -1,
          oldStId: rec.status,
        });
      },
    );
  }

  async lookupFullTransaction(
    tx: WalletDbAllStoresReadOnlyTransaction,
  ): Promise<Transaction | undefined> {
    const rec = await tx.denomLossEvents.get(this.denomLossEventId);
    if (!rec) {
      return undefined;
    }
    const txState = computeDenomLossTransactionStatus(rec);
    return {
      type: TransactionType.DenomLoss,
      txState,
      scopes: await getScopeForAllExchanges(tx, [rec.exchangeBaseUrl]),
      txActions: [TransactionAction.Delete],
      amountRaw: Amounts.stringify(rec.amount),
      amountEffective: Amounts.stringify(rec.amount),
      timestamp: timestampPreciseFromDb(rec.timestampCreated),
      transactionId: constructTransactionIdentifier({
        tag: TransactionType.DenomLoss,
        denomLossEventId: rec.denomLossEventId,
      }),
      lossEventType: rec.eventType,
      exchangeBaseUrl: rec.exchangeBaseUrl,
      stId: rec.status,
    };
  }
}

async function handleRecoup(
  wex: WalletExecutionContext,
  tx: WalletDbReadWriteTransaction<
    [
      "denominations",
      "coins",
      "recoupGroups",
      "refreshGroups",
      "transactionsMeta",
      "exchanges",
    ]
  >,
  exchangeBaseUrl: string,
  recoup: Recoup[],
): Promise<void> {
  // Handle recoup
  const recoupDenomList = recoup;
  const newlyRevokedCoinPubs: string[] = [];
  logger.trace("recoup list from exchange", recoupDenomList);
  for (const recoupInfo of recoupDenomList) {
    const oldDenom = await tx.denominations.get([
      exchangeBaseUrl,
      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.getAll(
      recoupInfo.h_denom_pub,
    );
    for (const ac of affectedCoins) {
      newlyRevokedCoinPubs.push(ac.coinPub);
    }
  }
  if (newlyRevokedCoinPubs.length != 0) {
    logger.info("recouping coins", newlyRevokedCoinPubs);
    await createRecoupGroup(wex, tx, exchangeBaseUrl, newlyRevokedCoinPubs);
  }
}

function getAutoRefreshExecuteThresholdForDenom(
  d: DenominationRecord,
): AbsoluteTime {
  return getAutoRefreshExecuteThreshold({
    stampExpireWithdraw: timestampProtocolFromDb(d.stampExpireWithdraw),
    stampExpireDeposit: timestampProtocolFromDb(d.stampExpireDeposit),
  });
}

/**
 * Timestamp after which the wallet would do the next check for an auto-refresh.
 */
function getAutoRefreshCheckThreshold(d: DenominationRecord): AbsoluteTime {
  const expireWithdraw = AbsoluteTime.fromProtocolTimestamp(
    timestampProtocolFromDb(d.stampExpireWithdraw),
  );
  const expireDeposit = AbsoluteTime.fromProtocolTimestamp(
    timestampProtocolFromDb(d.stampExpireDeposit),
  );
  const delta = AbsoluteTime.difference(expireWithdraw, expireDeposit);
  const deltaDiv = durationMul(delta, 0.75);
  return AbsoluteTime.addDuration(expireWithdraw, deltaDiv);
}

/**
 * Find a payto:// URI of the exchange that is of one
 * of the given target types.
 *
 * Throws if no matching account was found.
 */
export async function getExchangePaytoUri(
  wex: WalletExecutionContext,
  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 wex.db.runReadOnlyTx(
    { storeNames: ["exchanges", "exchangeDetails"] },
    async (tx) => {
      return getExchangeRecordsInternal(tx, exchangeBaseUrl);
    },
  );
  const accounts = details?.wireInfo.accounts ?? [];
  for (const account of accounts) {
    const res = parsePaytoUri(account.payto_uri);
    if (!res) {
      continue;
    }
    if (supportedTargetTypes.includes(res.targetType)) {
      return account.payto_uri;
    }
  }
  throw Error(
    `no matching account found at exchange ${exchangeBaseUrl} for wire types ${j2s(
      supportedTargetTypes,
    )}`,
  );
}

/**
 * Get the exchange ToS in the requested format.
 * Try to download in the accepted format not cached.
 */
export async function getExchangeTos(
  wex: WalletExecutionContext,
  exchangeBaseUrl: string,
  acceptedFormat?: string[],
  acceptLanguage?: string,
): Promise<GetExchangeTosResult> {
  const exch = await fetchFreshExchange(wex, exchangeBaseUrl);

  switch (exch.tosStatus) {
    case ExchangeTosStatus.MissingTos:
      return {
        tosStatus: ExchangeTosStatus.MissingTos,
        acceptedEtag: undefined,
        contentLanguage: undefined,
        contentType: "text/plain",
        content: "NULL",
        currentEtag: "NULL",
        tosAvailableLanguages: [],
      };
  }

  const tosDownload = await downloadTosFromAcceptedFormat(
    wex,
    exchangeBaseUrl,
    getExchangeRequestTimeout(),
    acceptedFormat,
    acceptLanguage,
  );

  await wex.db.runReadWriteTx({ storeNames: ["exchanges"] }, async (tx) => {
    const updateExchangeEntry = await tx.exchanges.get(exchangeBaseUrl);
    if (updateExchangeEntry) {
      updateExchangeEntry.tosCurrentEtag = tosDownload.tosEtag;
      wex.ws.exchangeCache.clear();
      await tx.exchanges.put(updateExchangeEntry);
    }
  });

  return {
    acceptedEtag: exch.tosAcceptedEtag,
    currentEtag: tosDownload.tosEtag,
    content: tosDownload.tosText,
    contentType: tosDownload.tosContentType,
    contentLanguage: tosDownload.tosContentLanguage,
    tosStatus: exch.tosStatus,
    tosAvailableLanguages: tosDownload.tosAvailableLanguages,
  };
}

/**
 * Parsed information about an exchange,
 * obtained by requesting /keys.
 */
export interface ExchangeInfo {
  keys: ExchangeKeysDownloadSuccessResult;
}

/**
 * Helper function to download the exchange /keys info.
 *
 * Only used for testing / dbless wallet.
 */
export async function downloadExchangeInfo(
  exchangeBaseUrl: string,
  http: HttpRequestLibrary,
): Promise<ExchangeInfo> {
  const keysInfo = await downloadExchangeKeysInfo(
    exchangeBaseUrl,
    http,
    Duration.getForever(),
    CancellationToken.CONTINUE,
    false,
  );
  if (keysInfo.type !== "ok") {
    throw Error(`bad keys download: ${keysInfo.type}`);
  }
  return {
    keys: keysInfo.res,
  };
}

/**
 * List all exchange entries known to the wallet.
 */
export async function listExchanges(
  wex: WalletExecutionContext,
  req: ListExchangesRequest,
): Promise<ExchangesListResponse> {
  const exchanges: ExchangeListItem[] = [];
  await wex.db.runReadOnlyTx(
    {
      storeNames: [
        "exchanges",
        "reserves",
        "operationRetries",
        "exchangeDetails",
        "globalCurrencyAuditors",
        "globalCurrencyExchanges",
      ],
    },
    async (tx) => {
      const exchangeRecords = await tx.exchanges.iter().toArray();
      for (const exchangeRec of exchangeRecords) {
        const taskId = constructTaskIdentifier({
          tag: PendingTaskType.ExchangeUpdate,
          exchangeBaseUrl: exchangeRec.baseUrl,
        });
        const exchangeDetails = await getExchangeRecordsInternal(
          tx,
          exchangeRec.baseUrl,
        );
        const opRetryRecord = await tx.operationRetries.get(taskId);
        let reserveRec: ReserveRecord | undefined = undefined;
        if (exchangeRec.currentMergeReserveRowId != null) {
          reserveRec = await tx.reserves.get(
            exchangeRec.currentMergeReserveRowId,
          );
          checkDbInvariant(!!reserveRec, "reserve record not found");
        }
        if (req.filterByScope) {
          const inScope = await checkExchangeInScopeTx(
            tx,
            exchangeRec.baseUrl,
            req.filterByScope,
          );
          if (!inScope) {
            continue;
          }
        }
        const li = await makeExchangeListItem(
          wex,
          tx,
          exchangeRec,
          exchangeDetails,
          reserveRec,
          opRetryRecord?.lastError,
        );
        if (req.filterByExchangeEntryStatus) {
          if (req.filterByExchangeEntryStatus !== li.exchangeEntryStatus) {
            continue;
          }
        }
        exchanges.push(li);
      }
    },
  );
  return { exchanges };
}

/**
 * Transition an exchange to the "used" entry state if necessary.
 *
 * Should be called whenever the exchange is actively used by the client (for withdrawals etc.).
 *
 * The caller should emit the returned notification iff the current transaction
 * succeeded.
 */
export async function markExchangeUsed(
  tx: WalletDbReadWriteTransaction<["exchanges"]>,
  exchangeBaseUrl: string,
): Promise<void> {
  logger.trace(`marking exchange ${exchangeBaseUrl} as used`);
  const exch = await tx.exchanges.get(exchangeBaseUrl);
  if (!exch) {
    logger.info(`exchange ${exchangeBaseUrl} NOT found`);
    return;
  }

  const oldExchangeState = getExchangeState(exch);
  switch (exch.entryStatus) {
    case ExchangeEntryDbRecordStatus.Ephemeral:
    case ExchangeEntryDbRecordStatus.Preset: {
      exch.entryStatus = ExchangeEntryDbRecordStatus.Used;
      await tx.exchanges.put(exch);
      const newExchangeState = getExchangeState(exch);
      tx.notify({
        type: NotificationType.ExchangeStateTransition,
        exchangeBaseUrl,
        newExchangeState: newExchangeState,
        oldExchangeState: oldExchangeState,
      });
      return;
    }
    default:
      return;
  }
}

/**
 * Get detailed information about the exchange including a timeline
 * for the fees charged by the exchange.
 */
export async function getExchangeDetailedInfo(
  wex: WalletExecutionContext,
  exchangeBaseurl: string,
): Promise<ExchangeDetailedResponse> {
  const exchange = await wex.db.runReadOnlyTx(
    { storeNames: ["exchanges", "exchangeDetails", "denominations"] },
    async (tx) => {
      const ex = await tx.exchanges.get(exchangeBaseurl);
      const dp = ex?.detailsPointer;
      if (!dp) {
        return;
      }
      const { currency } = dp;
      const exchangeDetails = await getExchangeRecordsInternal(tx, ex.baseUrl);
      if (!exchangeDetails) {
        return;
      }
      const denominationRecords =
        await tx.denominations.indexes.byExchangeBaseUrl.getAll(ex.baseUrl);

      if (!denominationRecords) {
        return;
      }

      const denominations: DenominationInfo[] = denominationRecords.map((x) =>
        DenominationRecord.toDenomInfo(x),
      );

      return {
        info: {
          exchangeBaseUrl: ex.baseUrl,
          currency,
          paytoUris: exchangeDetails.wireInfo.accounts.map((x) => x.payto_uri),
          auditors: exchangeDetails.auditors,
          wireInfo: exchangeDetails.wireInfo,
          globalFees: exchangeDetails.globalFees,
        },
        denominations,
      };
    },
  );

  if (!exchange) {
    throw Error(`exchange with base url "${exchangeBaseurl}" not found`);
  }

  const denoms = exchange.denominations.map((d) => ({
    ...d,
    group: Amounts.stringifyValue(d.value),
  }));
  const denomFees: DenomOperationMap<FeeDescription[]> = {
    deposit: createTimeline(
      denoms,
      "denomPubHash",
      "stampStart",
      "stampExpireDeposit",
      "feeDeposit",
      "group",
      selectBestForOverlappingDenominations,
    ),
    refresh: createTimeline(
      denoms,
      "denomPubHash",
      "stampStart",
      "stampExpireWithdraw",
      "feeRefresh",
      "group",
      selectBestForOverlappingDenominations,
    ),
    refund: createTimeline(
      denoms,
      "denomPubHash",
      "stampStart",
      "stampExpireWithdraw",
      "feeRefund",
      "group",
      selectBestForOverlappingDenominations,
    ),
    withdraw: createTimeline(
      denoms,
      "denomPubHash",
      "stampStart",
      "stampExpireWithdraw",
      "feeWithdraw",
      "group",
      selectBestForOverlappingDenominations,
    ),
  };

  const transferFees = Object.entries(
    exchange.info.wireInfo.feesForType,
  ).reduce(
    (prev, [wireType, infoForType]) => {
      const feesByGroup = [
        ...infoForType.map((w) => ({
          ...w,
          fee: Amounts.stringify(w.closingFee),
          group: "closing",
        })),
        ...infoForType.map((w) => ({ ...w, fee: w.wireFee, group: "wire" })),
      ];
      prev[wireType] = createTimeline(
        feesByGroup,
        "sig",
        "startStamp",
        "endStamp",
        "fee",
        "group",
        selectMinimumFee,
      );
      return prev;
    },
    {} as Record<string, FeeDescription[]>,
  );

  const globalFeesByGroup = [
    ...exchange.info.globalFees.map((w) => ({
      ...w,
      fee: w.accountFee,
      group: "account",
    })),
    ...exchange.info.globalFees.map((w) => ({
      ...w,
      fee: w.historyFee,
      group: "history",
    })),
    ...exchange.info.globalFees.map((w) => ({
      ...w,
      fee: w.purseFee,
      group: "purse",
    })),
  ];

  const globalFees = createTimeline(
    globalFeesByGroup,
    "signature",
    "startDate",
    "endDate",
    "fee",
    "group",
    selectMinimumFee,
  );

  return {
    exchange: {
      ...exchange.info,
      denomFees,
      transferFees,
      globalFees,
    },
  };
}

async function internalGetExchangeResources(
  tx: WalletDbReadOnlyTransaction<["exchanges", "coins", "withdrawalGroups"]>,
  exchangeBaseUrl: string,
): Promise<GetExchangeResourcesResponse> {
  let numWithdrawals = 0;
  let numCoins = 0;
  numCoins = await tx.coins.indexes.byBaseUrl.count(exchangeBaseUrl);
  numWithdrawals =
    await tx.withdrawalGroups.indexes.byExchangeBaseUrl.count(exchangeBaseUrl);
  const total = numWithdrawals + numCoins;
  return {
    hasResources: total != 0,
  };
}

/**
 * Purge information in the database associated with the exchange.
 *
 * Deletes information specific to the exchange and withdrawals,
 * but keeps some transactions (payments, p2p, refreshes) around.
 */
async function purgeExchange(
  wex: WalletExecutionContext,
  tx: WalletDbAllStoresReadWriteTransaction,
  exchangeRec: ExchangeEntryRecord,
  purgeTransactions?: boolean,
): Promise<{ notifs: WalletNotification[] }> {
  const exchangeBaseUrl = exchangeRec.baseUrl;
  const notifs: WalletNotification[] = [];
  const detRecs = await tx.exchangeDetails.indexes.byExchangeBaseUrl.getAll();
  // Remove all exchange detail records for that exchange
  for (const r of detRecs) {
    if (r.rowId == null) {
      // Should never happen, as rowId is the primary key.
      continue;
    }
    if (r.exchangeBaseUrl !== exchangeBaseUrl) {
      continue;
    }
    await tx.exchangeDetails.delete(r.rowId);
    const signkeyRecs =
      await tx.exchangeSignKeys.indexes.byExchangeDetailsRowId.getAll(r.rowId);
    for (const rec of signkeyRecs) {
      await tx.exchangeSignKeys.delete([r.rowId, rec.signkeyPub]);
    }
  }

  const oldExchangeState = getExchangeState(exchangeRec);

  await tx.exchanges.delete(exchangeBaseUrl);
  notifs.push({
    type: NotificationType.ExchangeStateTransition,
    oldExchangeState,
    newExchangeState: undefined,
    exchangeBaseUrl,
  });

  {
    const coinAvailabilityRecs =
      await tx.coinAvailability.indexes.byExchangeBaseUrl.getAll(
        exchangeBaseUrl,
      );
    for (const rec of coinAvailabilityRecs) {
      await tx.coinAvailability.delete([
        exchangeBaseUrl,
        rec.denomPubHash,
        rec.maxAge,
      ]);
    }
  }

  {
    const coinRecs = await tx.coins.indexes.byBaseUrl.getAll(exchangeBaseUrl);
    for (const rec of coinRecs) {
      await tx.coins.delete(rec.coinPub);
      await tx.coinHistory.delete(rec.coinPub);
    }
  }

  {
    const denomRecs =
      await tx.denominations.indexes.byExchangeBaseUrl.getAll(exchangeBaseUrl);
    for (const rec of denomRecs) {
      await tx.denominations.delete(rec.denomPubHash);
    }
  }

  // Always delete withdrawals, even if no explicit
  // transaction deletion was requested.
  {
    const withdrawalGroupRecs =
      await tx.withdrawalGroups.indexes.byExchangeBaseUrl.getAll(
        exchangeBaseUrl,
      );
    for (const wg of withdrawalGroupRecs) {
      const ctx = new WithdrawTransactionContext(wex, wg.withdrawalGroupId);
      const res = await ctx.deleteTransactionInTx(tx);
      notifs.push(...res.notifs);
    }
  }

  if (purgeTransactions) {
    // Remove from refreshGroups and refreshSessions
    {
      await tx.refreshGroups.iter().forEachAsync(async (rg) => {
        if (rg.infoPerExchange && rg.infoPerExchange[exchangeBaseUrl] != null) {
          const ctx = new RefreshTransactionContext(wex, rg.refreshGroupId);
          const res = await ctx.deleteTransactionInTx(tx);
          notifs.push(...res.notifs);
        }
      });
    }
    // Remove from recoupGroups
    {
      const recoupGroups =
        await tx.recoupGroups.indexes.byExchangeBaseUrl.getAll(exchangeBaseUrl);
      for (const rg of recoupGroups) {
        const ctx = new RecoupTransactionContext(wex, rg.recoupGroupId);
        const res = await ctx.deleteTransactionInTx(tx);
        notifs.push(...res.notifs);
      }
    }
    // Remove from deposits
    {
      // FIXME: Index would be useful here
      await tx.depositGroups.iter().forEachAsync(async (dg) => {
        if (dg.infoPerExchange && dg.infoPerExchange[exchangeBaseUrl]) {
          const ctx = new DepositTransactionContext(wex, dg.depositGroupId);
          const res = await ctx.deleteTransactionInTx(tx);
          notifs.push(...res.notifs);
        }
      });
    }
    // Remove from purchases, refundGroups, refundItems
    {
      const purchases =
        await tx.purchases.indexes.byExchange.getAll(exchangeBaseUrl);
      for (const purch of purchases) {
        const refunds = await tx.refundGroups.indexes.byProposalId.getAll(
          purch.proposalId,
        );
        for (const r of refunds) {
          const refundCtx = new RefundTransactionContext(wex, r.refundGroupId);
          const res = await refundCtx.deleteTransactionInTx(tx);
          notifs.push(...res.notifs);
        }
        const payCtx = new PayMerchantTransactionContext(wex, purch.proposalId);
        const res = await payCtx.deleteTransactionInTx(tx);
        notifs.push(...res.notifs);
      }
    }
    // Remove from peerPullCredit
    {
      await tx.peerPullCredit.iter().forEachAsync(async (rec) => {
        if (rec.exchangeBaseUrl === exchangeBaseUrl) {
          const ctx = new PeerPullCreditTransactionContext(wex, rec.pursePub);
          const res = await ctx.deleteTransactionInTx(tx);
          notifs.push(...res.notifs);
        }
      });
    }
    // Remove from peerPullDebit
    {
      await tx.peerPullDebit.iter().forEachAsync(async (rec) => {
        if (rec.exchangeBaseUrl === exchangeBaseUrl) {
          const ctx = new PeerPullDebitTransactionContext(
            wex,
            rec.peerPullDebitId,
          );
          const res = await ctx.deleteTransactionInTx(tx);
          notifs.push(...res.notifs);
        }
      });
    }
    // Remove from peerPushCredit
    {
      await tx.peerPushCredit.iter().forEachAsync(async (rec) => {
        if (rec.exchangeBaseUrl === exchangeBaseUrl) {
          const ctx = new PeerPushCreditTransactionContext(
            wex,
            rec.peerPushCreditId,
          );
          const res = await ctx.deleteTransactionInTx(tx);
          notifs.push(...res.notifs);
        }
      });
    }
    // Remove from peerPushDebit
    {
      await tx.peerPushDebit.iter().forEachAsync(async (rec) => {
        if (rec.exchangeBaseUrl === exchangeBaseUrl) {
          const ctx = new PeerPushDebitTransactionContext(wex, rec.pursePub);
          const res = await ctx.deleteTransactionInTx(tx);
          notifs.push(...res.notifs);
        }
      });
    }
  }

  // FIXME: Is this even necessary? Each deletion should already do it.
  await rematerializeTransactions(wex, tx);

  return { notifs };
}

export async function deleteExchange(
  wex: WalletExecutionContext,
  req: DeleteExchangeRequest,
): Promise<void> {
  let inUse: boolean = false;
  const exchangeBaseUrl = req.exchangeBaseUrl;
  const notifs = await wex.db.runAllStoresReadWriteTx({}, async (tx) => {
    const exchangeRec = await tx.exchanges.get(exchangeBaseUrl);
    if (!exchangeRec) {
      // Nothing to delete!
      logger.info("no exchange found to delete");
      return;
    }
    const res = await internalGetExchangeResources(tx, exchangeBaseUrl);
    if (res.hasResources && !req.purge) {
      inUse = true;
      return;
    }
    const purgeRes = await purgeExchange(wex, tx, exchangeRec, true);
    wex.ws.exchangeCache.clear();
    return purgeRes.notifs;
  });

  if (inUse) {
    throw TalerError.fromUncheckedDetail({
      code: TalerErrorCode.WALLET_EXCHANGE_ENTRY_USED,
      hint: "Exchange in use.",
    });
  }
  for (const notif of notifs ?? []) {
    wex.ws.notify(notif);
  }
}

export async function getExchangeResources(
  wex: WalletExecutionContext,
  exchangeBaseUrl: string,
): Promise<GetExchangeResourcesResponse> {
  // Withdrawals include internal withdrawals from peer transactions
  const res = await wex.db.runReadOnlyTx(
    { storeNames: ["exchanges", "withdrawalGroups", "coins"] },
    async (tx) => {
      const exchangeRecord = await tx.exchanges.get(exchangeBaseUrl);
      if (!exchangeRecord) {
        return undefined;
      }
      return internalGetExchangeResources(tx, exchangeBaseUrl);
    },
  );
  if (!res) {
    throw Error("exchange not found");
  }
  return res;
}

/**
 * Find the currently applicable wire fee for an exchange.
 */
export async function getExchangeWireFee(
  wex: WalletExecutionContext,
  wireType: string,
  baseUrl: string,
  time: TalerProtocolTimestamp,
): Promise<WireFee> {
  const exchangeDetails = await wex.db.runReadOnlyTx(
    { storeNames: ["exchangeDetails", "exchanges"] },
    async (tx) => {
      const ex = await tx.exchanges.get(baseUrl);
      if (!ex || !ex.detailsPointer) return undefined;
      return await tx.exchangeDetails.indexes.byPointer.get([
        baseUrl,
        ex.detailsPointer.currency,
        ex.detailsPointer.masterPublicKey,
      ]);
    },
  );

  if (!exchangeDetails) {
    throw Error(`exchange missing: ${baseUrl}`);
  }

  const fees = exchangeDetails.wireInfo.feesForType[wireType];
  if (!fees || fees.length === 0) {
    throw Error(
      `exchange ${baseUrl} doesn't have fees for wire type ${wireType}`,
    );
  }
  const fee = fees.find((x) => {
    return AbsoluteTime.isBetween(
      AbsoluteTime.fromProtocolTimestamp(time),
      AbsoluteTime.fromProtocolTimestamp(x.startStamp),
      AbsoluteTime.fromProtocolTimestamp(x.endStamp),
    );
  });
  if (!fee) {
    throw Error(
      `exchange ${exchangeDetails.exchangeBaseUrl} doesn't have fees for wire type ${wireType} at ${time.t_s}`,
    );
  }

  return fee;
}

export type BalanceThresholdCheckResult =
  | {
      result: "ok";
    }
  | {
      result: "violation";
      /** Threshold that needs to be requested to proceed. */
      nextThreshold: AmountString;
      walletKycStatus: ExchangeWalletKycStatus | undefined;
      walletKycAccessToken: string | undefined;
    };

export async function checkIncomingAmountLegalUnderKycBalanceThreshold(
  wex: WalletExecutionContext,
  exchangeBaseUrl: string,
  amountIncoming: AmountLike,
): Promise<BalanceThresholdCheckResult> {
  logger.trace(`checking ${exchangeBaseUrl} +${amountIncoming} for KYC`);
  return await wex.db.runReadOnlyTx(
    {
      storeNames: [
        "exchanges",
        "exchangeDetails",
        "reserves",
        "coinAvailability",
      ],
    },
    async (tx): Promise<BalanceThresholdCheckResult> => {
      const exchangeRec = await tx.exchanges.get(exchangeBaseUrl);
      if (!exchangeRec) {
        throw Error("exchange not found");
      }
      const det = await getExchangeRecordsInternal(tx, exchangeBaseUrl);
      if (!det) {
        throw Error("exchange not found");
      }
      const coinAvRecs =
        await tx.coinAvailability.indexes.byExchangeBaseUrl.getAll(
          exchangeBaseUrl,
        );
      let balAmount = Amounts.zeroOfCurrency(det.currency);
      for (const av of coinAvRecs) {
        const n = av.freshCoinCount + (av.pendingRefreshOutputCount ?? 0);
        balAmount = Amounts.add(
          balAmount,
          Amounts.mult(av.value, n).amount,
        ).amount;
      }
      const balExpected = Amounts.add(balAmount, amountIncoming).amount;

      // Check if we already have KYC for a sufficient threshold.

      const reserveId = exchangeRec.currentMergeReserveRowId;
      let reserveRec: ReserveRecord | undefined;
      if (reserveId) {
        reserveRec = await tx.reserves.get(reserveId);
        checkDbInvariant(!!reserveRec, "reserve");
        // FIXME: also consider KYC expiration!
        if (reserveRec.thresholdNext) {
          logger.trace(
            `Checking expected balance ${Amounts.stringify(
              balExpected,
            )} against threshold ${Amounts.stringify(
              reserveRec.thresholdNext,
            )}`,
          );
          if (Amounts.cmp(balExpected, reserveRec.thresholdNext) <= 0) {
            logger.trace(
              `Next threshold ${Amounts.stringify(
                reserveRec.thresholdNext,
              )} not yet breached`,
            );
            return {
              result: "ok",
            };
          }
        } else if (reserveRec.status === ReserveRecordStatus.Done) {
          // We don't know what the next threshold is, but we've passed *some* KYC
          // check. We don't have enough information, so we allow the balance increase.
          logger.warn(
            `No next balance threshold, assuming balance KYC is okay`,
          );
          return {
            result: "ok",
          };
        }
      }

      // No luck, check the next limit we should request, if any.

      const limits = det.walletBalanceLimits;
      if (!limits) {
        logger.info("no balance limits defined");
        return {
          result: "ok",
        };
      }
      limits.sort((a, b) => Amounts.cmp(a, b));
      logger.trace(`applicable limits: ${j2s(limits)}`);
      let limViolated: AmountString | undefined = undefined;
      for (let i = 0; i < limits.length; i++) {
        if (Amounts.cmp(limits[i], balExpected) <= 0) {
          limViolated = limits[i];
          const limNext = limits[i + 1];
          if (limNext == null || Amounts.cmp(limNext, balExpected) > 0) {
            break;
          }
        }
      }
      if (!limViolated) {
        logger.trace("balance limit okay");
        return {
          result: "ok",
        };
      } else {
        logger.info(`balance limit ${limViolated} would be violated`);
        return {
          result: "violation",
          nextThreshold: limViolated,
          walletKycStatus: reserveRec?.status
            ? getKycStatusFromReserveStatus(reserveRec.status)
            : undefined,
          walletKycAccessToken: reserveRec?.kycAccessToken,
        };
      }
    },
  );
}

/**
 * Wait until kyc has passed for the wallet.
 *
 * If passed==false, already return when legitimization
 * is requested.
 */
export async function waitExchangeWalletKyc(
  wex: WalletExecutionContext,
  exchangeBaseUrl: string,
  amount: AmountLike,
  passed: boolean,
): Promise<void> {
  await genericWaitForState(wex, {
    async checkState(): Promise<boolean> {
      return await wex.db.runReadOnlyTx(
        {
          storeNames: ["exchanges", "reserves"],
        },
        async (tx) => {
          const exchange = await tx.exchanges.get(exchangeBaseUrl);
          if (!exchange) {
            throw new Error("exchange not found");
          }
          const reserveId = exchange.currentMergeReserveRowId;
          if (reserveId == null) {
            logger.warn("KYC does not exist yet");
            return false;
          }
          const reserve = await tx.reserves.get(reserveId);
          if (!reserve) {
            throw Error("reserve not found");
          }
          if (passed) {
            if (
              reserve.thresholdGranted &&
              Amounts.cmp(reserve.thresholdGranted, amount) >= 0
            ) {
              return true;
            }
            return false;
          } else {
            if (
              reserve.thresholdGranted &&
              Amounts.cmp(reserve.thresholdGranted, amount) >= 0
            ) {
              return true;
            }
            if (reserve.status === ReserveRecordStatus.PendingLegi) {
              return true;
            }
            return false;
          }
        },
      );
    },
    filterNotification(notif) {
      return (
        notif.type === NotificationType.ExchangeStateTransition &&
        notif.exchangeBaseUrl === exchangeBaseUrl
      );
    },
  });
}

export async function handleTestingWaitExchangeState(
  wex: WalletExecutionContext,
  req: TestingWaitExchangeStateRequest,
): Promise<EmptyObject> {
  await genericWaitForState(wex, {
    async checkState(): Promise<boolean> {
      const exchangeEntry = await lookupExchangeByUri(wex, {
        exchangeBaseUrl: req.exchangeBaseUrl,
      });
      if (req.walletKycStatus) {
        if (req.walletKycStatus !== exchangeEntry.walletKycStatus) {
          return false;
        }
      }
      return true;
    },
    filterNotification(notif) {
      return (
        notif.type === NotificationType.ExchangeStateTransition &&
        notif.exchangeBaseUrl === req.exchangeBaseUrl
      );
    },
  });
  return {};
}

export async function handleTestingWaitExchangeWalletKyc(
  wex: WalletExecutionContext,
  req: TestingWaitWalletKycRequest,
): Promise<EmptyObject> {
  await waitExchangeWalletKyc(wex, req.exchangeBaseUrl, req.amount, req.passed);
  return {};
}

export async function handleTestingPlanMigrateExchangeBaseUrl(
  wex: WalletExecutionContext,
  req: TestingPlanMigrateExchangeBaseUrlRequest,
): Promise<EmptyObject> {
  wex.ws.exchangeMigrationPlan.set(req.oldExchangeBaseUrl, {
    newExchangeBaseUrl: req.newExchangeBaseUrl,
  });
  return {};
}

/**
 * Start a wallet KYC process.
 *
 * Does nothing if the KYC process has already been started.
 */
export async function handleStartExchangeWalletKyc(
  wex: WalletExecutionContext,
  req: StartExchangeWalletKycRequest,
): Promise<EmptyObject> {
  const newReservePair = await wex.cryptoApi.createEddsaKeypair({});
  const dbRes = await wex.db.runReadWriteTx(
    {
      storeNames: ["exchanges", "reserves"],
    },
    async (tx) => {
      const exchange = await tx.exchanges.get(req.exchangeBaseUrl);
      if (!exchange) {
        throw Error("exchange not found");
      }
      const oldExchangeState = getExchangeState(exchange);
      let mergeReserveRowId = exchange.currentMergeReserveRowId;
      if (mergeReserveRowId == null) {
        const putRes = await tx.reserves.put({
          reservePriv: newReservePair.priv,
          reservePub: newReservePair.pub,
        });
        checkDbInvariant(typeof putRes.key === "number", "primary key type");
        mergeReserveRowId = putRes.key;
        exchange.currentMergeReserveRowId = mergeReserveRowId;
        await tx.exchanges.put(exchange);
      }
      const reserveRec = await tx.reserves.get(mergeReserveRowId);
      checkDbInvariant(reserveRec != null, "reserve record exists");
      if (
        reserveRec.thresholdGranted == null ||
        Amounts.cmp(reserveRec.thresholdGranted, req.amount) < 0
      ) {
        if (
          reserveRec.thresholdRequested == null ||
          Amounts.cmp(reserveRec.thresholdRequested, req.amount) < 0
        ) {
          reserveRec.thresholdRequested = req.amount;
          reserveRec.status = ReserveRecordStatus.PendingLegiInit;
          await tx.reserves.put(reserveRec);
          return {
            notification: {
              type: NotificationType.ExchangeStateTransition,
              exchangeBaseUrl: exchange.baseUrl,
              oldExchangeState,
              newExchangeState: getExchangeState(exchange),
            } satisfies WalletNotification,
          };
        } else {
          logger.info(
            `another KYC process is already active for ${req.exchangeBaseUrl} over ${reserveRec.thresholdRequested}`,
          );
          return undefined;
        }
      } else {
        // FIXME: Check expiration once exchange tells us!
        logger.info(
          `KYC already granted for ${req.exchangeBaseUrl} over ${req.amount}, granted ${reserveRec.thresholdGranted}`,
        );
        return undefined;
      }
    },
  );
  if (dbRes && dbRes.notification) {
    wex.ws.notify(dbRes.notification);
  }
  const taskId = constructTaskIdentifier({
    tag: PendingTaskType.ExchangeWalletKyc,
    exchangeBaseUrl: req.exchangeBaseUrl,
  });
  wex.taskScheduler.startShepherdTask(taskId);
  return {};
}

async function handleExchangeKycPendingWallet(
  wex: WalletExecutionContext,
  exchange: ExchangeEntryRecord,
  reserve: ReserveRecord,
): Promise<TaskRunResult> {
  checkDbInvariant(!!reserve.thresholdRequested, "threshold");
  const threshold = reserve.thresholdRequested;
  const sigResp = await wex.cryptoApi.signWalletAccountSetup({
    reservePriv: reserve.reservePriv,
    reservePub: reserve.reservePub,
    threshold,
  });
  const requestUrl = new URL("kyc-wallet", exchange.baseUrl);
  const body: WalletKycRequest = {
    balance: reserve.thresholdRequested,
    reserve_pub: reserve.reservePub,
    reserve_sig: sigResp.sig,
  };
  logger.info(`kyc-wallet request body: ${j2s(body)}`);
  const res = await cancelableLongPoll(wex, requestUrl, {
    method: "POST",
    body,
  });

  logger.info(`kyc-wallet response status is ${res.status}`);

  switch (res.status) {
    case HttpStatusCode.Ok: {
      // KYC somehow already passed
      const walletKycResp = await readSuccessResponseJsonOrThrow(
        res,
        codecForAmlWalletKycCheckResponse(),
      );
      return handleExchangeKycSuccess(
        wex,
        exchange.baseUrl,
        walletKycResp.next_threshold,
      );
    }
    case HttpStatusCode.NoContent: {
      // KYC disabled at exchange.
      return handleExchangeKycSuccess(wex, exchange.baseUrl, undefined);
    }
    case HttpStatusCode.Forbidden: {
      // Did not work!
      const err = await readTalerErrorResponse(res);
      throwUnexpectedRequestError(res, err);
    }
    case HttpStatusCode.UnavailableForLegalReasons: {
      const kycBody = await readResponseJsonOrThrow(
        res,
        codecForLegitimizationNeededResponse(),
      );
      return handleExchangeKycRespLegi(wex, exchange.baseUrl, reserve, kycBody);
    }
    default: {
      const err = await readTalerErrorResponse(res);
      throwUnexpectedRequestError(res, err);
    }
  }
}

async function handleExchangeKycSuccess(
  wex: WalletExecutionContext,
  exchangeBaseUrl: string,
  nextThreshold: AmountLike | undefined,
): Promise<TaskRunResult> {
  logger.info(`kyc check for ${exchangeBaseUrl} satisfied`);
  const dbRes = await wex.db.runReadWriteTx(
    { storeNames: ["exchanges", "reserves"] },
    async (tx) => {
      const exchange = await tx.exchanges.get(exchangeBaseUrl);
      if (!exchange) {
        throw Error("exchange not found");
      }
      const oldExchangeState = getExchangeState(exchange);
      const reserveId = exchange.currentMergeReserveRowId;
      if (reserveId == null) {
        throw Error("expected exchange to have reserve ID");
      }
      const reserve = await tx.reserves.get(reserveId);
      checkDbInvariant(!!reserve, "merge reserve should exist");
      switch (reserve.status) {
        case ReserveRecordStatus.PendingLegiInit:
        case ReserveRecordStatus.PendingLegi:
          break;
        default:
          throw Error("unexpected state (concurrent modification?)");
      }
      reserve.status = ReserveRecordStatus.Done;
      reserve.thresholdGranted = reserve.thresholdRequested;
      delete reserve.thresholdRequested;
      delete reserve.requirementRow;

      if (nextThreshold) {
        reserve.thresholdNext = Amounts.stringify(nextThreshold);
      }

      await tx.reserves.put(reserve);
      logger.info(`newly granted threshold: ${reserve.thresholdGranted}`);
      return {
        notification: {
          type: NotificationType.ExchangeStateTransition,
          exchangeBaseUrl: exchange.baseUrl,
          oldExchangeState,
          newExchangeState: getExchangeState(exchange),
        } satisfies WalletNotification,
      };
    },
  );
  if (dbRes && dbRes.notification) {
    wex.ws.notify(dbRes.notification);
  }
  return TaskRunResult.progress();
}

function findNextBalanceLimit(
  accountKycStatus: AccountKycStatus,
  currentGranted: AmountLike | undefined,
): AmountString | undefined {
  for (const lim of accountKycStatus.limits ?? []) {
    if (
      lim.operation_type === LimitOperationType.balance &&
      (currentGranted == null || Amounts.cmp(lim.threshold, currentGranted) > 0)
    ) {
      return lim.threshold;
    }
  }
  return undefined;
}

/**
 * The exchange has just told us that we need some legitimization
 * from the user. Request more details and store the result in the database.
 */
async function handleExchangeKycRespLegi(
  wex: WalletExecutionContext,
  exchangeBaseUrl: string,
  reserve: ReserveRecord,
  kycBody: LegitimizationNeededResponse,
): Promise<TaskRunResult> {
  const sigResp = await wex.cryptoApi.signWalletKycAuth({
    accountPriv: reserve.reservePriv,
    accountPub: reserve.reservePub,
  });
  const reqUrl = new URL(`kyc-check/${kycBody.h_payto}`, exchangeBaseUrl);
  const resp = await cancelableFetch(wex, reqUrl, {
    method: "GET",
    headers: {
      ["Account-Owner-Signature"]: sigResp.sig,
      ["Account-Owner-Pub"]: reserve.reservePub,
    },
  });

  logger.info(`kyc-check response status ${resp.status}`);

  switch (resp.status) {
    case HttpStatusCode.Ok: {
      // FIXME: Store information about next limit!
      const accountKycStatus = await readSuccessResponseJsonOrThrow(
        resp,
        codecForAccountKycStatus(),
      );
      logger.trace(`balance KYC account status: ${j2s(accountKycStatus)}`);
      const nextLimit = findNextBalanceLimit(
        accountKycStatus,
        reserve.thresholdGranted,
      );
      return handleExchangeKycSuccess(wex, exchangeBaseUrl, nextLimit);
    }
    case HttpStatusCode.Accepted: {
      // Store the result in the DB!
      break;
    }
    case HttpStatusCode.NoContent: {
      // KYC not configured, so already satisfied
      return handleExchangeKycSuccess(wex, exchangeBaseUrl, undefined);
    }
    default: {
      const err = await readTalerErrorResponse(resp);
      throwUnexpectedRequestError(resp, err);
    }
  }

  const accountKycStatusResp = await readResponseJsonOrThrow(
    resp,
    codecForAccountKycStatus(),
  );

  const dbRes = await wex.db.runReadWriteTx(
    { storeNames: ["exchanges", "reserves"] },
    async (tx) => {
      const exchange = await tx.exchanges.get(exchangeBaseUrl);
      if (!exchange) {
        throw Error("exchange not found");
      }
      const oldExchangeState = getExchangeState(exchange);
      const reserveId = exchange.currentMergeReserveRowId;
      if (reserveId == null) {
        throw Error("expected exchange to have reserve ID");
      }
      const reserve = await tx.reserves.get(reserveId);
      checkDbInvariant(!!reserve, "merge reserve should exist");
      switch (reserve.status) {
        case ReserveRecordStatus.PendingLegiInit:
          break;
        default:
          throw Error("unexpected state (concurrent modification?)");
      }
      reserve.status = ReserveRecordStatus.PendingLegi;
      reserve.requirementRow = kycBody.requirement_row;
      reserve.amlReview = accountKycStatusResp.aml_review;
      reserve.kycAccessToken = accountKycStatusResp.access_token;

      await tx.reserves.put(reserve);
      return {
        notification: {
          type: NotificationType.ExchangeStateTransition,
          exchangeBaseUrl: exchange.baseUrl,
          oldExchangeState,
          newExchangeState: getExchangeState(exchange),
        } satisfies WalletNotification,
      };
    },
  );
  if (dbRes && dbRes.notification) {
    wex.ws.notify(dbRes.notification);
  }
  return TaskRunResult.progress();
}

/**
 * Legitimization was requested from the user by the exchange.
 *
 * Long-poll for the legitimization to succeed.
 */
async function handleExchangeKycPendingLegitimization(
  wex: WalletExecutionContext,
  exchange: ExchangeEntryRecord,
  reserve: ReserveRecord,
): Promise<TaskRunResult> {
  // FIXME: Cache this signature
  const sigResp = await wex.cryptoApi.signWalletKycAuth({
    accountPriv: reserve.reservePriv,
    accountPub: reserve.reservePub,
  });

  const reservePayto = stringifyReservePaytoUri(
    exchange.baseUrl,
    reserve.reservePub,
  );

  const paytoHash = encodeCrock(hashFullPaytoUri(reservePayto));

  const reqUrl = new URL(`kyc-check/${paytoHash}`, exchange.baseUrl);
  // We already have the access token.
  // Long-poll for target 3 <-> KYC status to be OK
  reqUrl.searchParams.set("lpt", "3");
  logger.info(`long-polling wallet KYC status at ${reqUrl.href}`);
  const resp = await cancelableLongPoll(wex, reqUrl, {
    headers: {
      ["Account-Owner-Signature"]: sigResp.sig,
      ["Account-Owner-Pub"]: reserve.reservePub,
    },
  });

  logger.info(`kyc-check (long-poll) response status ${resp.status}`);

  switch (resp.status) {
    case HttpStatusCode.Ok: {
      // FIXME: Store information about next limit!
      const accountKycStatus = await readSuccessResponseJsonOrThrow(
        resp,
        codecForAccountKycStatus(),
      );
      logger.info(`balance KYC account status: ${j2s(accountKycStatus)}`);
      const nextLimit = findNextBalanceLimit(
        accountKycStatus,
        reserve.thresholdGranted,
      );
      logger.info(
        `setting next limit to: ${
          nextLimit ? Amounts.stringify(nextLimit) : "undefined"
        }`,
      );
      return handleExchangeKycSuccess(wex, exchange.baseUrl, nextLimit);
    }
    case HttpStatusCode.Accepted:
      // FIXME: Do we ever need to update the access token?
      return TaskRunResult.longpollReturnedPending();
    case HttpStatusCode.NoContent: {
      // KYC not configured, so already satisfied
      return handleExchangeKycSuccess(wex, exchange.baseUrl, undefined);
    }
    default: {
      const err = await readTalerErrorResponse(resp);
      throwUnexpectedRequestError(resp, err);
    }
  }
}

export async function processExchangeKyc(
  wex: WalletExecutionContext,
  exchangeBaseUrl: string,
): Promise<TaskRunResult> {
  const res = await wex.db.runReadOnlyTx(
    { storeNames: ["exchanges", "reserves"] },
    async (tx) => {
      const exchange = await tx.exchanges.get(exchangeBaseUrl);
      if (!exchange) {
        return undefined;
      }
      const reserveId = exchange.currentMergeReserveRowId;
      let reserve: ReserveRecord | undefined = undefined;
      if (reserveId != null) {
        reserve = await tx.reserves.get(reserveId);
      }
      return { exchange, reserve };
    },
  );
  if (!res) {
    logger.warn(`exchange ${exchangeBaseUrl} not found, not processing KYC`);
    return TaskRunResult.finished();
  }
  if (!res.reserve) {
    return TaskRunResult.finished();
  }
  switch (res.reserve.status) {
    case undefined:
      // No KYC requested
      return TaskRunResult.finished();
    case ReserveRecordStatus.Done:
      return TaskRunResult.finished();
    case ReserveRecordStatus.SuspendedLegiInit:
    case ReserveRecordStatus.SuspendedLegi:
      return TaskRunResult.finished();
    case ReserveRecordStatus.PendingLegiInit:
      return handleExchangeKycPendingWallet(wex, res.exchange, res.reserve);
    case ReserveRecordStatus.PendingLegi:
      return handleExchangeKycPendingLegitimization(
        wex,
        res.exchange,
        res.reserve,
      );
  }
}

export async function checkExchangeInScopeTx(
  tx: WalletDbReadOnlyTransaction<
    [
      "globalCurrencyExchanges",
      "globalCurrencyAuditors",
      "exchanges",
      "exchangeDetails",
    ]
  >,
  exchangeBaseUrl: string,
  scope: ScopeInfo,
): Promise<boolean> {
  logger.trace(
    `checking if exchange ${exchangeBaseUrl} is in scope ${j2s(scope)}`,
  );
  switch (scope.type) {
    case ScopeType.Exchange: {
      return scope.url === exchangeBaseUrl;
    }
    case ScopeType.Global: {
      const exchangeDetails = await getExchangeRecordsInternal(
        tx,
        exchangeBaseUrl,
      );
      if (!exchangeDetails) {
        logger.trace(`no details for ${exchangeBaseUrl}`);
        return false;
      }
      const gr =
        await tx.globalCurrencyExchanges.indexes.byCurrencyAndUrlAndPub.get([
          exchangeDetails.currency,
          exchangeBaseUrl,
          exchangeDetails.masterPublicKey,
        ]);
      logger.trace(`global currency record: ${j2s(gr)}`);
      return gr != null;
    }
    case ScopeType.Auditor:
      throw Error("auditor scope not supported yet");
  }
}

/**
 * Find a preferred exchange based on when we withdrew last from this exchange.
 */
export async function getPreferredExchangeForCurrency(
  wex: WalletExecutionContext,
  currency: string,
  restrictScope?: ScopeInfo,
): Promise<string | undefined> {
  if (restrictScope) {
    checkLogicInvariant(restrictScope.currency === currency);
  }
  if (logger.shouldLogTrace()) {
    logger.trace(
      `finding preferred exchange for currency ${currency}, scope ${j2s(
        restrictScope,
      )}`,
    );
  }
  // Find an exchange with the matching currency.
  // Prefer exchanges with the most recent withdrawal.
  const url = await wex.db.runReadOnlyTx(
    {
      storeNames: [
        "exchanges",
        "globalCurrencyAuditors",
        "globalCurrencyExchanges",
        "exchangeDetails",
      ],
    },
    async (tx) => {
      const exchanges = await tx.exchanges.iter().toArray();
      logger.trace(`have ${exchanges.length} exchanges`);
      let candidate = undefined;
      for (const e of exchanges) {
        if (e.detailsPointer?.currency !== currency) {
          logger.trace("no details pointer");
          continue;
        }
        const inScope =
          !restrictScope ||
          (await checkExchangeInScopeTx(tx, e.baseUrl, restrictScope));
        if (!inScope) {
          logger.trace("not in scope");
          continue;
        }
        if (!candidate) {
          candidate = e;
          continue;
        }
        if (candidate.lastWithdrawal && !e.lastWithdrawal) {
          continue;
        }
        const exchangeLastWithdrawal = timestampOptionalPreciseFromDb(
          e.lastWithdrawal,
        );
        const candidateLastWithdrawal = timestampOptionalPreciseFromDb(
          candidate.lastWithdrawal,
        );
        if (exchangeLastWithdrawal && candidateLastWithdrawal) {
          if (
            AbsoluteTime.cmp(
              AbsoluteTime.fromPreciseTimestamp(exchangeLastWithdrawal),
              AbsoluteTime.fromPreciseTimestamp(candidateLastWithdrawal),
            ) > 0
          ) {
            candidate = e;
          }
        }
      }
      if (candidate) {
        return candidate.baseUrl;
      }
      return undefined;
    },
  );
  return url;
}

interface MigrateExchangeRequest {
  oldExchangeBaseUrl: string;
  newExchangeBaseUrl: string;
  trigger: ExchangeMigrationReason;
}

export async function migrateExchange(
  wex: WalletExecutionContext,
  req: MigrateExchangeRequest,
): Promise<void> {
  await wex.db.runAllStoresReadWriteTx({}, async (tx) => {
    const migrationRec = await tx.exchangeBaseUrlMigrationLog.get([
      req.oldExchangeBaseUrl,
      req.newExchangeBaseUrl,
    ]);
    if (migrationRec) {
      logger.warn(
        `exchange ${migrationRec.oldExchangeBaseUrl} already migrated`,
      );
      return;
    }

    const exch = await tx.exchanges.get(req.oldExchangeBaseUrl);
    if (!exch) {
      logger.warn(`exchange ${req.oldExchangeBaseUrl} does not exist anymore`);
      return;
    }

    let existingNewExchangeSt: ExchangeEntryState | undefined = undefined;

    await tx.exchangeBaseUrlMigrationLog.put({
      oldExchangeBaseUrl: req.oldExchangeBaseUrl,
      newExchangeBaseUrl: req.newExchangeBaseUrl,
      timestamp: timestampPreciseToDb(TalerPreciseTimestamp.now()),
      reason: req.trigger,
    });

    await tx.exchangeBaseUrlFixups.put({
      exchangeBaseUrl: req.oldExchangeBaseUrl,
      replacement: req.newExchangeBaseUrl,
    });

    {
      const denomKeys =
        await tx.denominations.indexes.byExchangeBaseUrl.getAllKeys(
          req.oldExchangeBaseUrl,
        );

      for (const dk of denomKeys) {
        const rec = await tx.denominations.get(dk);
        if (!rec) {
          continue;
        }
        rec.exchangeBaseUrl = req.newExchangeBaseUrl;
        await tx.denominations.put(rec);
      }
    }

    {
      await tx.denomLossEvents.iter().forEachAsync(async (rec) => {
        if (rec.exchangeBaseUrl === req.oldExchangeBaseUrl) {
          rec.exchangeBaseUrl = req.newExchangeBaseUrl;
          await tx.denomLossEvents.put(rec);
        }
      });
    }

    {
      await tx.recoupGroups.indexes.byExchangeBaseUrl
        .iter(req.oldExchangeBaseUrl)
        .forEachAsync(async (rec) => {
          rec.exchangeBaseUrl = req.newExchangeBaseUrl;
          await tx.recoupGroups.put(rec);
        });
    }

    {
      const existingNewExchangeDetails =
        await tx.exchangeDetails.indexes.byExchangeBaseUrl.get(
          req.newExchangeBaseUrl,
        );
      if (!existingNewExchangeDetails) {
        await tx.exchangeDetails.indexes.byExchangeBaseUrl
          .iter(req.oldExchangeBaseUrl)
          .forEachAsync(async (rec) => {
            rec.exchangeBaseUrl = req.newExchangeBaseUrl;
            await tx.exchangeDetails.put(rec);
          });
      }
    }

    {
      const rec = await tx.exchanges.get(req.oldExchangeBaseUrl);
      if (rec) {
        existingNewExchangeSt = {
          exchangeEntryStatus: getExchangeEntryStatusFromRecord(rec),
          exchangeUpdateStatus: getExchangeUpdateStatusFromRecord(rec),
          tosStatus: getExchangeTosStatusFromRecord(rec),
        };
        rec.baseUrl = req.newExchangeBaseUrl;
        await tx.exchanges.delete(req.oldExchangeBaseUrl);
        await tx.exchanges.put(rec);
      }
    }

    {
      await tx.coins.indexes.byBaseUrl
        .iter(req.oldExchangeBaseUrl)
        .forEachAsync(async (rec) => {
          rec.exchangeBaseUrl = req.newExchangeBaseUrl;
          await tx.coins.put(rec);
        });
    }

    {
      await tx.coinAvailability.indexes.byExchangeBaseUrl
        .iter(req.oldExchangeBaseUrl)
        .forEachAsync(async (rec) => {
          await tx.coinAvailability.delete([
            rec.exchangeBaseUrl,
            rec.denomPubHash,
            rec.maxAge,
          ]);
          rec.exchangeBaseUrl = req.newExchangeBaseUrl;
          await tx.coinAvailability.put(rec);
        });
    }

    {
      await tx.peerPullCredit.iter().forEachAsync(async (rec) => {
        if (rec.exchangeBaseUrl === req.oldExchangeBaseUrl) {
          rec.exchangeBaseUrl = req.newExchangeBaseUrl;
          await tx.peerPullCredit.put(rec);
        }
      });
    }

    {
      await tx.peerPullDebit.iter().forEachAsync(async (rec) => {
        if (rec.exchangeBaseUrl === req.oldExchangeBaseUrl) {
          rec.exchangeBaseUrl = req.newExchangeBaseUrl;
          await tx.peerPullDebit.put(rec);
        }
      });
    }

    {
      await tx.peerPushCredit.iter().forEachAsync(async (rec) => {
        if (rec.exchangeBaseUrl === req.oldExchangeBaseUrl) {
          rec.exchangeBaseUrl = req.newExchangeBaseUrl;
          await tx.peerPushCredit.put(rec);
        }
      });
    }

    {
      await tx.peerPushDebit.iter().forEachAsync(async (rec) => {
        if (rec.exchangeBaseUrl === req.oldExchangeBaseUrl) {
          rec.exchangeBaseUrl = req.newExchangeBaseUrl;
          await tx.peerPushDebit.put(rec);
        }
      });
    }

    {
      await tx.withdrawalGroups.indexes.byExchangeBaseUrl
        .iter(req.oldExchangeBaseUrl)
        .forEachAsync(async (rec) => {
          rec.exchangeBaseUrl = req.newExchangeBaseUrl;
          await tx.withdrawalGroups.put(rec);
        });
    }

    {
      await tx.refreshGroups.iter().forEachAsync(async (rec) => {
        if (
          rec.infoPerExchange &&
          rec.infoPerExchange[req.oldExchangeBaseUrl] != null
        ) {
          rec.infoPerExchange[req.newExchangeBaseUrl] =
            rec.infoPerExchange[req.oldExchangeBaseUrl];
          delete rec.infoPerExchange[req.oldExchangeBaseUrl];
          await tx.refreshGroups.put(rec);
        }
      });
    }

    {
      await tx.depositGroups.iter().forEachAsync(async (rec) => {
        if (
          rec.infoPerExchange &&
          rec.infoPerExchange[req.oldExchangeBaseUrl] != null
        ) {
          rec.infoPerExchange[req.newExchangeBaseUrl] =
            rec.infoPerExchange[req.oldExchangeBaseUrl];
          delete rec.infoPerExchange[req.oldExchangeBaseUrl];
          await tx.depositGroups.put(rec);
        }
      });
    }

    tx.notify({
      type: NotificationType.ExchangeStateTransition,
      exchangeBaseUrl: req.oldExchangeBaseUrl,
      oldExchangeState: getExchangeState(exch),
      newExchangeState: undefined,
    });

    tx.notify({
      type: NotificationType.ExchangeStateTransition,
      exchangeBaseUrl: req.newExchangeBaseUrl,
      oldExchangeState: existingNewExchangeSt,
      newExchangeState: getExchangeState(exch),
    });
  });
}
