/*
 This file is part of GNU Taler
 (C) 2015-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/>
 */

/**
 * High-level wallet operations that should be independent from the underlying
 * browser extension interface.
 */

/**
 * Imports.
 */
import {
  AccessStats,
  BridgeIDBFactory,
  IDBDatabase,
} from "@gnu-taler/idb-bridge";
import {
  AbortTransactionRequest,
  AbsoluteTime,
  AcceptBankIntegratedWithdrawalRequest,
  AcceptManualWithdrawalRequest,
  AcceptManualWithdrawalResult,
  AcceptWithdrawalResponse,
  ActiveTask,
  AddBankAccountRequest,
  AddBankAccountResponse,
  AddExchangeRequest,
  AddExchangeResponse,
  AddGlobalCurrencyAuditorRequest,
  AddGlobalCurrencyExchangeRequest,
  AmountJson,
  AmountString,
  Amounts,
  AsyncCondition,
  CancellationToken,
  CanonicalizeBaseUrlRequest,
  CanonicalizeBaseUrlResponse,
  Codec,
  CoinDumpJson,
  CoinStatus,
  CompleteBaseUrlRequest,
  CompleteBaseUrlResult,
  ConfirmPayRequest,
  ConfirmPayResult,
  CoreApiResponse,
  CreateStoredBackupResponse,
  DeleteDiscountRequest,
  DeleteExchangeRequest,
  DeleteStoredBackupRequest,
  DenominationInfo,
  Duration,
  EmptyObject,
  ExportDbToFileRequest,
  ExportDbToFileResponse,
  FailTransactionRequest,
  ForgetBankAccountRequest,
  GetActiveTasksResponse,
  GetBankAccountByIdRequest,
  GetBankAccountByIdResponse,
  GetBankingChoicesForPaytoRequest,
  GetBankingChoicesForPaytoResponse,
  GetChoicesForPaymentRequest,
  GetChoicesForPaymentResult,
  GetCurrencySpecificationRequest,
  GetCurrencySpecificationResponse,
  GetDepositWireTypesForCurrencyRequest,
  GetDepositWireTypesForCurrencyResponse,
  GetDepositWireTypesRequest,
  GetDepositWireTypesResponse,
  GetExchangeTosRequest,
  GetExchangeTosResult,
  GetQrCodesForPaytoRequest,
  GetQrCodesForPaytoResponse,
  HintNetworkAvailabilityRequest,
  ImportDbFromFileRequest,
  ImportDbRequest,
  InitRequest,
  InitResponse,
  IntegrationTestArgs,
  IntegrationTestV2Args,
  ListBankAccountsRequest,
  ListBankAccountsResponse,
  ListDiscountsRequest,
  ListDiscountsResponse,
  ListGlobalCurrencyAuditorsResponse,
  ListGlobalCurrencyExchangesResponse,
  Logger,
  LongpollQueue,
  NotificationType,
  ObservabilityContext,
  ObservabilityEventType,
  ObservableHttpClientLibrary,
  OpenedPromise,
  PartialWalletRunConfig,
  PrepareWithdrawExchangeRequest,
  PrepareWithdrawExchangeResponse,
  RecoverStoredBackupRequest,
  RemoveGlobalCurrencyAuditorRequest,
  RemoveGlobalCurrencyExchangeRequest,
  RunFixupRequest,
  ScopeType,
  SharePaymentRequest,
  SharePaymentResult,
  StartRefundQueryRequest,
  StoredBackupList,
  SuspendTransactionRequest,
  TalerBankIntegrationHttpClient,
  TalerError,
  TalerErrorCode,
  TalerExchangeHttpClient,
  TalerMerchantInstanceHttpClient,
  TalerProtocolTimestamp,
  TalerUriAction,
  TestingGetDenomStatsRequest,
  TestingGetDenomStatsResponse,
  TestingGetReserveHistoryRequest,
  TestingSetTimetravelRequest,
  TimerAPI,
  TimerGroup,
  TransactionIdStr,
  TransactionType,
  TransactionsResponse,
  UpdateExchangeEntryRequest,
  ValidateIbanRequest,
  ValidateIbanResponse,
  WalletBankAccountInfo,
  WalletCoreVersion,
  WalletNotification,
  WalletRunConfig,
  WireTypeDetails,
  WithdrawTestBalanceRequest,
  canonicalizeBaseUrl,
  checkDbInvariant,
  codecForAbortTransaction,
  codecForAcceptBankIntegratedWithdrawalRequest,
  codecForAcceptExchangeTosRequest,
  codecForAcceptManualWithdrawalRequest,
  codecForAcceptPeerPullPaymentRequest,
  codecForAddBankAccountRequest,
  codecForAddExchangeRequest,
  codecForAddGlobalCurrencyAuditorRequest,
  codecForAddGlobalCurrencyExchangeRequest,
  codecForAny,
  codecForApplyDevExperiment,
  codecForCanonicalizeBaseUrlRequest,
  codecForCheckDepositRequest,
  codecForCheckPayTemplateRequest,
  codecForCheckPeerPullPaymentRequest,
  codecForCheckPeerPushDebitRequest,
  codecForCompleteBaseUrlRequest,
  codecForConfirmPayRequest,
  codecForConfirmPeerPushPaymentRequest,
  codecForConfirmWithdrawalRequestRequest,
  codecForConvertAmountRequest,
  codecForCreateDepositGroupRequest,
  codecForDeleteDiscountRequest,
  codecForDeleteExchangeRequest,
  codecForDeleteStoredBackupRequest,
  codecForDeleteTransactionRequest,
  codecForEmptyObject,
  codecForExportDbToFileRequest,
  codecForFailTransactionRequest,
  codecForForceRefreshRequest,
  codecForForgetBankAccount,
  codecForGetBalanceDetailRequest,
  codecForGetBankAccountByIdRequest,
  codecForGetBankingChoicesForPaytoRequest,
  codecForGetChoicesForPaymentRequest,
  codecForGetCurrencyInfoRequest,
  codecForGetDepositWireTypesForCurrencyRequest,
  codecForGetDepositWireTypesRequest,
  codecForGetDonauStatementsRequest,
  codecForGetExchangeEntryByUrlRequest,
  codecForGetExchangeResourcesRequest,
  codecForGetExchangeTosRequest,
  codecForGetMaxDepositAmountRequest,
  codecForGetMaxPeerPushDebitAmountRequest,
  codecForGetQrCodesForPaytoRequest,
  codecForGetTransactionsV2Request,
  codecForGetWithdrawalDetailsForAmountRequest,
  codecForGetWithdrawalDetailsForUri,
  codecForHintNetworkAvailabilityRequest,
  codecForImportDbFromFileRequest,
  codecForImportDbRequest,
  codecForInitRequest,
  codecForInitiatePeerPullPaymentRequest,
  codecForInitiatePeerPushDebitRequest,
  codecForIntegrationTestArgs,
  codecForIntegrationTestV2Args,
  codecForListBankAccounts,
  codecForListDiscountsRequest,
  codecForListExchangesRequest,
  codecForPrepareBankIntegratedWithdrawalRequest,
  codecForPreparePayRequest,
  codecForPreparePayTemplateRequest,
  codecForPreparePeerPullPaymentRequest,
  codecForPreparePeerPushCreditRequest,
  codecForPrepareRefundRequest,
  codecForPrepareWithdrawExchangeRequest,
  codecForRecoverStoredBackupRequest,
  codecForRemoveGlobalCurrencyAuditorRequest,
  codecForRemoveGlobalCurrencyExchangeRequest,
  codecForResumeTransaction,
  codecForRetryTransactionRequest,
  codecForRunFixupRequest,
  codecForSetCoinSuspendedRequest,
  codecForSetDonauRequest,
  codecForSetWalletDeviceIdRequest,
  codecForSharePaymentRequest,
  codecForStartExchangeWalletKycRequest,
  codecForStartRefundQueryRequest,
  codecForSuspendTransaction,
  codecForTestPayArgs,
  codecForTestingGetDenomStatsRequest,
  codecForTestingGetReserveHistoryRequest,
  codecForTestingPlanMigrateExchangeBaseUrlRequest,
  codecForTestingSetTimetravelRequest,
  codecForTestingWaitWalletKycRequest,
  codecForTransactionByIdRequest,
  codecForTransactionsRequest,
  codecForUpdateExchangeEntryRequest,
  codecForUserAttentionByIdRequest,
  codecForUserAttentionsRequest,
  codecForValidateIbanRequest,
  codecForWithdrawTestBalance,
  encodeCrock,
  getErrorDetailFromException,
  getQrCodesForPayto,
  getRandomBytes,
  j2s,
  openPromise,
  parsePaytoUri,
  parseTalerUri,
  performanceNow,
  safeStringifyException,
  setDangerousTimetravel,
  setGlobalLogLevelFromString,
  stringifyScopeInfo,
  validateIban,
} from "@gnu-taler/taler-util";
import {
  readSuccessResponseJsonOrThrow,
  type HttpRequestLibrary,
} from "@gnu-taler/taler-util/http";
import {
  getUserAttentions,
  getUserAttentionsUnreadCount,
  markAttentionRequestAsRead,
} from "./attention.js";
import {
  addBackupProvider,
  codecForAddBackupProviderRequest,
  codecForRemoveBackupProvider,
  codecForRunBackupCycle,
  getBackupInfo,
  getBackupRecovery,
  loadBackupRecovery,
  removeBackupProvider,
  runBackupCycle,
  setWalletDeviceId,
} from "./backup/index.js";
import { getBalanceDetail, getBalances } from "./balance.js";
import {
  getMaxDepositAmount,
  getMaxPeerPushDebitAmount,
} from "./coinSelection.js";
import { cancelableFetch } from "./common.js";
import { TalerCryptoInterface } from "./crypto/cryptoImplementation.js";
import {
  CryptoDispatcher,
  CryptoWorkerFactory,
} from "./crypto/workers/crypto-dispatcher.js";
import {
  CoinSourceType,
  ConfigRecordKey,
  DenominationRecord,
  WalletDbHelpers,
  WalletDbReadOnlyTransaction,
  WalletStoresV1,
  applyFixups,
  clearDatabase,
  exportDb,
  importDb,
  openStoredBackupsDatabase,
  openTalerDatabase,
  timestampAbsoluteFromDb,
  timestampProtocolToDb,
  walletDbFixups,
} from "./db.js";
import {
  checkDepositGroup,
  createDepositGroup,
  generateDepositGroupTxId,
} from "./deposits.js";
import { DevExperimentHttpLib, applyDevExperiment } from "./dev-experiments.js";
import {
  handleGetDonau,
  handleGetDonauStatements,
  handleSetDonau,
} from "./donau.js";
import {
  ReadyExchangeSummary,
  acceptExchangeTermsOfService,
  addPresetExchangeEntry,
  deleteExchange,
  fetchFreshExchange,
  forgetExchangeTermsOfService,
  getExchangeDetailedInfo,
  getExchangeResources,
  getExchangeTos,
  getExchangeWireDetailsInTx,
  handleStartExchangeWalletKyc,
  handleTestingPlanMigrateExchangeBaseUrl,
  handleTestingWaitExchangeState,
  handleTestingWaitExchangeWalletKyc,
  listExchanges,
  lookupExchangeByUri,
  markExchangeUsed,
} from "./exchanges.js";
import { convertDepositAmount } from "./instructedAmountConversion.js";
import {
  ObservableDbAccess,
  ObservableTaskScheduler,
  observeTalerCrypto,
} from "./observable-wrappers.js";
import {
  checkPayForTemplate,
  confirmPay,
  getChoicesForPayment,
  preparePayForTemplate,
  preparePayForUri,
  sharePayment,
  startQueryRefund,
  startRefundQueryForUri,
} from "./pay-merchant.js";
import {
  checkPeerPullCredit,
  initiatePeerPullPayment,
} from "./pay-peer-pull-credit.js";
import {
  confirmPeerPullDebit,
  preparePeerPullDebit,
} from "./pay-peer-pull-debit.js";
import {
  confirmPeerPushCredit,
  preparePeerPushCredit,
} from "./pay-peer-push-credit.js";
import {
  checkPeerPushDebit,
  checkPeerPushDebitV2,
  initiatePeerPushDebit,
} from "./pay-peer-push-debit.js";
import {
  AfterCommitInfo,
  DbAccess,
  DbAccessImpl,
  TriggerSpec,
} from "./query.js";
import { forceRefresh } from "./refresh.js";
import {
  TaskScheduler,
  TaskSchedulerImpl,
  convertTaskToTransactionId,
  getActiveTaskIds,
} from "./shepherd.js";
import {
  WithdrawTestBalanceResult,
  runIntegrationTest,
  runIntegrationTest2,
  testPay,
  waitTasksDone,
  waitTransactionState,
  waitUntilAllTransactionsFinal,
  waitUntilRefreshesDone,
  withdrawTestBalance,
} from "./testing.js";
import { deleteDiscount, listDiscounts } from "./tokenFamilies.js";
import {
  abortTransaction,
  deleteTransaction,
  failTransaction,
  getTransactionById,
  getTransactions,
  getTransactionsV2,
  parseTransactionIdentifier,
  rematerializeTransactions,
  restartAll as restartAllRunningTasks,
  resumeTransaction,
  retryAll,
  retryTransaction,
  suspendTransaction,
} from "./transactions.js";
import {
  WALLET_BANK_CONVERSION_API_PROTOCOL_VERSION,
  WALLET_COREBANK_API_PROTOCOL_VERSION,
  WALLET_CORE_API_PROTOCOL_VERSION,
  WALLET_EXCHANGE_PROTOCOL_VERSION,
  WALLET_MERCHANT_PROTOCOL_VERSION,
} from "./versions.js";
import {
  WalletApiOperation,
  WalletCoreApiClient,
  WalletCoreRequestType,
  WalletCoreResponseType,
} from "./wallet-api-types.js";
import {
  acceptBankIntegratedWithdrawal,
  confirmWithdrawal,
  createManualWithdrawal,
  getWithdrawalDetailsForAmount,
  getWithdrawalDetailsForUri,
  prepareBankIntegratedWithdrawal,
} from "./withdraw.js";

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

/**
 * Execution context for code that is run in the wallet.
 *
 * Typically the execution context is either for a wallet-core
 * request handler or for a shepherded task.
 */
export interface WalletExecutionContext {
  readonly ws: InternalWalletState;
  readonly cryptoApi: TalerCryptoInterface;
  readonly cancellationToken: CancellationToken;
  readonly http: HttpRequestLibrary;
  readonly db: DbAccess<typeof WalletStoresV1>;
  readonly oc: ObservabilityContext;
  readonly cts: CancellationToken.Source | undefined;
  readonly taskScheduler: TaskScheduler;
}

export function walletExchangeClient(
  baseUrl: string,
  wex: WalletExecutionContext,
): TalerExchangeHttpClient {
  return new TalerExchangeHttpClient(baseUrl, {
    httpClient: wex.http,
    cancelationToken: wex.cancellationToken,
    longPollQueue: wex.ws.longpollQueue,
  });
}

export function walletMerchantClient(
  baseUrl: string,
  wex: WalletExecutionContext,
): TalerMerchantInstanceHttpClient {
  return new TalerMerchantInstanceHttpClient(
    baseUrl,
    wex.http,
    undefined,
    wex.cancellationToken,
  );
}

export const EXCHANGE_COINS_LOCK = "exchange-coins-lock";
export const EXCHANGE_RESERVES_LOCK = "exchange-reserves-lock";

export type NotificationListener = (n: WalletNotification) => void;

type CancelFn = () => void;

/**
 * Insert the hard-coded defaults for exchanges, coins and
 * auditors into the database, unless these defaults have
 * already been applied.
 */
async function fillDefaults(wex: WalletExecutionContext): Promise<void> {
  const notifications: WalletNotification[] = [];
  await wex.db.runReadWriteTx(
    { storeNames: ["config", "exchanges"] },
    async (tx) => {
      const appliedRec = await tx.config.get("currencyDefaultsApplied");
      let alreadyApplied = appliedRec ? !!appliedRec.value : false;
      if (alreadyApplied) {
        logger.trace("defaults already applied");
        return;
      }
      for (const exch of wex.ws.config.builtin.exchanges) {
        const resp = await addPresetExchangeEntry(
          tx,
          exch.exchangeBaseUrl,
          exch.currencyHint,
        );
        if (resp.notification) {
          notifications.push(resp.notification);
        }
      }
      await tx.config.put({
        key: ConfigRecordKey.CurrencyDefaultsApplied,
        value: true,
      });
    },
  );
  for (const notif of notifications) {
    wex.ws.notify(notif);
  }
}

/**
 * Incremented each time we want to re-materialize transactions.
 */
const MATERIALIZED_TRANSACTIONS_VERSION = 1;

async function migrateMaterializedTransactions(
  wex: WalletExecutionContext,
): Promise<void> {
  await wex.db.runAllStoresReadWriteTx({}, async (tx) => {
    const ver = await tx.config.get("materializedTransactionsVersion");
    if (ver) {
      if (ver.key !== ConfigRecordKey.MaterializedTransactionsVersion) {
        logger.error("invalid configuration (materializedTransactionsVersion)");
        return;
      }
      if (ver.value == MATERIALIZED_TRANSACTIONS_VERSION) {
        return;
      }
      if (ver.value > MATERIALIZED_TRANSACTIONS_VERSION) {
        logger.error(
          "database is newer than code (materializedTransactionsVersion)",
        );
        return;
      }
    }

    await rematerializeTransactions(wex, tx);

    await tx.config.put({
      key: ConfigRecordKey.MaterializedTransactionsVersion,
      value: MATERIALIZED_TRANSACTIONS_VERSION,
    });
  });
}

export async function getDenomInfo(
  wex: WalletExecutionContext,
  tx: WalletDbReadOnlyTransaction<["denominations"]>,
  exchangeBaseUrl: string,
  denomPubHash: string,
): Promise<DenominationInfo | undefined> {
  const key = `${exchangeBaseUrl}:${denomPubHash}`;
  return wex.ws.denomInfoCache.getOrPut(key, async () => {
    const d = await tx.denominations.get([exchangeBaseUrl, denomPubHash]);
    if (d != null) {
      return DenominationRecord.toDenomInfo(d);
    } else {
      return undefined;
    }
  });
}

/**
 * List bank accounts known to the wallet from
 * previous withdrawals.
 */
async function handleListBankAccounts(
  wex: WalletExecutionContext,
  req: ListBankAccountsRequest,
): Promise<ListBankAccountsResponse> {
  const accounts: WalletBankAccountInfo[] = [];
  const currency = req.currency;
  await wex.db.runReadOnlyTx({ storeNames: ["bankAccountsV2"] }, async (tx) => {
    const knownAccounts = await tx.bankAccountsV2.iter().toArray();
    for (const r of knownAccounts) {
      if (currency && r.currencies && !r.currencies.includes(currency)) {
        continue;
      }
      const payto = parsePaytoUri(r.paytoUri);
      if (payto) {
        accounts.push({
          bankAccountId: r.bankAccountId,
          paytoUri: r.paytoUri,
          label: r.label,
          kycCompleted: r.kycCompleted,
          currencies: r.currencies,
        });
      }
    }
  });
  return { accounts };
}

async function handleGetBankAccountById(
  wex: WalletExecutionContext,
  req: GetBankAccountByIdRequest,
): Promise<GetBankAccountByIdResponse> {
  const acct = await wex.db.runReadOnlyTx(
    { storeNames: ["bankAccountsV2"] },
    async (tx) => {
      return tx.bankAccountsV2.get(req.bankAccountId);
    },
  );
  if (!acct) {
    throw Error(`bank account ${req.bankAccountId} not found`);
  }
  return acct;
}

/**
 * Remove a known bank account.
 */
async function forgetBankAccount(
  wex: WalletExecutionContext,
  bankAccountId: string,
): Promise<void> {
  await wex.db.runReadWriteTx(
    { storeNames: ["bankAccountsV2"] },
    async (tx) => {
      const account = await tx.bankAccountsV2.get(bankAccountId);
      if (!account) {
        throw Error(`account not found: ${bankAccountId}`);
      }
      tx.bankAccountsV2.delete(account.bankAccountId);
    },
  );
  wex.ws.notify({
    type: NotificationType.BankAccountChange,
    bankAccountId,
  });
  return;
}

async function setCoinSuspended(
  wex: WalletExecutionContext,
  coinPub: string,
  suspended: boolean,
): Promise<void> {
  await wex.db.runReadWriteTx(
    { storeNames: ["coins", "coinAvailability"] },
    async (tx) => {
      const c = await tx.coins.get(coinPub);
      if (!c) {
        logger.warn(`coin ${coinPub} not found, won't suspend`);
        return;
      }
      const coinAvailability = await tx.coinAvailability.get([
        c.exchangeBaseUrl,
        c.denomPubHash,
        c.maxAge,
      ]);
      checkDbInvariant(
        !!coinAvailability,
        `no denom info for ${c.denomPubHash} age ${c.maxAge}`,
      );
      if (suspended) {
        if (c.status !== CoinStatus.Fresh) {
          return;
        }
        if (coinAvailability.freshCoinCount === 0) {
          throw Error(
            `invalid coin count ${coinAvailability.freshCoinCount} in DB`,
          );
        }
        coinAvailability.freshCoinCount--;
        c.status = CoinStatus.FreshSuspended;
      } else {
        if (c.status == CoinStatus.Dormant) {
          return;
        }
        coinAvailability.freshCoinCount++;
        c.status = CoinStatus.Fresh;
      }
      await tx.coins.put(c);
      await tx.coinAvailability.put(coinAvailability);
    },
  );
}

/**
 * Dump the public information of coins we have in an easy-to-process format.
 */
async function dumpCoins(wex: WalletExecutionContext): Promise<CoinDumpJson> {
  const coinsJson: CoinDumpJson = { coins: [] };
  logger.info("dumping coins");
  await wex.db.runReadOnlyTx(
    { storeNames: ["coins", "coinHistory", "denominations"] },
    async (tx) => {
      const coins = await tx.coins.iter().toArray();
      for (const c of coins) {
        const denom = await tx.denominations.get([
          c.exchangeBaseUrl,
          c.denomPubHash,
        ]);
        if (!denom) {
          logger.warn("no denom found for coin");
          continue;
        }
        const cs = c.coinSource;
        let refreshParentCoinPub: string | undefined;
        if (cs.type == CoinSourceType.Refresh) {
          refreshParentCoinPub = cs.oldCoinPub;
        }
        let withdrawalReservePub: string | undefined;
        if (cs.type == CoinSourceType.Withdraw) {
          withdrawalReservePub = cs.reservePub;
        }
        const denomInfo = await getDenomInfo(
          wex,
          tx,
          c.exchangeBaseUrl,
          c.denomPubHash,
        );
        if (!denomInfo) {
          logger.warn("no denomination found for coin");
          continue;
        }
        const historyRec = await tx.coinHistory.get(c.coinPub);
        coinsJson.coins.push({
          coinPub: c.coinPub,
          denomPub: denomInfo.denomPub,
          denomPubHash: c.denomPubHash,
          denomValue: denom.value,
          exchangeBaseUrl: c.exchangeBaseUrl,
          refreshParentCoinPub: refreshParentCoinPub,
          withdrawalReservePub: withdrawalReservePub,
          coinStatus: c.status,
          ageCommitmentProof: c.ageCommitmentProof,
          history: historyRec ? historyRec.history : [],
        });
      }
    },
  );
  return coinsJson;
}

/**
 * Get an API client from an internal wallet state object.
 */
let id = 0;
async function getClientFromWalletState(
  ws: InternalWalletState,
): Promise<WalletCoreApiClient> {
  const client: WalletCoreApiClient = {
    async call(op, payload): Promise<any> {
      id = (id + 1) % (Number.MAX_SAFE_INTEGER - 100);
      const res = await dispatchWalletCoreApiRequest(
        ws,
        op,
        String(id),
        payload,
      );
      switch (res.type) {
        case "error":
          throw TalerError.fromUncheckedDetail(res.error);
        case "response":
          return res.result;
      }
    },
  };
  return client;
}

async function createStoredBackup(
  wex: WalletExecutionContext,
): Promise<CreateStoredBackupResponse> {
  const backup = await exportDb(wex.ws.idbFactory);
  const backupsDb = await openStoredBackupsDatabase(wex.ws.idbFactory);
  const name = `backup-${new Date().getTime()}`;
  await backupsDb.runAllStoresReadWriteTx({}, async (tx) => {
    await tx.backupMeta.add({
      name,
    });
    await tx.backupData.add(backup, name);
  });
  return {
    name,
  };
}

async function listStoredBackups(
  wex: WalletExecutionContext,
): Promise<StoredBackupList> {
  const storedBackups: StoredBackupList = {
    storedBackups: [],
  };
  const backupsDb = await openStoredBackupsDatabase(wex.ws.idbFactory);
  await backupsDb.runAllStoresReadWriteTx({}, async (tx) => {
    await tx.backupMeta.iter().forEach((x) => {
      storedBackups.storedBackups.push({
        name: x.name,
      });
    });
  });
  return storedBackups;
}

async function deleteStoredBackup(
  wex: WalletExecutionContext,
  req: DeleteStoredBackupRequest,
): Promise<void> {
  const backupsDb = await openStoredBackupsDatabase(wex.ws.idbFactory);
  await backupsDb.runAllStoresReadWriteTx({}, async (tx) => {
    await tx.backupData.delete(req.name);
    await tx.backupMeta.delete(req.name);
  });
}

async function recoverStoredBackup(
  wex: WalletExecutionContext,
  req: RecoverStoredBackupRequest,
): Promise<void> {
  logger.info(`Recovering stored backup ${req.name}`);
  const { name } = req;
  const backupsDb = await openStoredBackupsDatabase(wex.ws.idbFactory);
  const bd = await backupsDb.runAllStoresReadWriteTx({}, async (tx) => {
    const backupMeta = tx.backupMeta.get(name);
    if (!backupMeta) {
      throw Error("backup not found");
    }
    const backupData = await tx.backupData.get(name);
    if (!backupData) {
      throw Error("no backup data (DB corrupt)");
    }
    return backupData;
  });
  logger.info(`backup found, now importing`);
  await importDb(wex.db.idbHandle(), bd);
  await wex.db.runAllStoresReadWriteTx({}, async (tx) => {
    await rematerializeTransactions(wex, tx);
  });
  logger.info(`import done`);
}

async function handlePrepareWithdrawExchange(
  wex: WalletExecutionContext,
  req: PrepareWithdrawExchangeRequest,
): Promise<PrepareWithdrawExchangeResponse> {
  const parsedUri = parseTalerUri(req.talerUri);
  if (parsedUri?.type !== TalerUriAction.WithdrawExchange) {
    throw Error("expected a taler://withdraw-exchange URI");
  }
  const exchangeBaseUrl = parsedUri.exchangeBaseUrl;
  const exchange = await fetchFreshExchange(wex, exchangeBaseUrl);
  if (parsedUri.amount) {
    const amt = Amounts.parseOrThrow(parsedUri.amount);
    if (amt.currency !== exchange.currency) {
      throw Error("mismatch of currency (URI vs exchange)");
    }
  }
  return {
    exchangeBaseUrl,
    amount: parsedUri.amount,
  };
}

async function handleRetryPendingNow(
  wex: WalletExecutionContext,
): Promise<EmptyObject> {
  logger.error("retryPendingNow currently not implemented");
  return {};
}

async function handleSharePayment(
  wex: WalletExecutionContext,
  req: SharePaymentRequest,
): Promise<SharePaymentResult> {
  return await sharePayment(wex, req.merchantBaseUrl, req.orderId);
}

async function handleDeleteStoredBackup(
  wex: WalletExecutionContext,
  req: DeleteStoredBackupRequest,
): Promise<EmptyObject> {
  await deleteStoredBackup(wex, req);
  return {};
}

async function handleRecoverStoredBackup(
  wex: WalletExecutionContext,
  req: RecoverStoredBackupRequest,
): Promise<EmptyObject> {
  await recoverStoredBackup(wex, req);
  return {};
}

const urlCharRegex = /^[a-zA-Z0-9\-_.~!*'();:@&=+$,/?%#[\]]+$/;

export async function handleCompleteExchangeBaseUrl(
  wex: WalletExecutionContext,
  req: CompleteBaseUrlRequest,
): Promise<CompleteBaseUrlResult> {
  const trimmedUrl = req.url.trim();

  if (!urlCharRegex.test(trimmedUrl)) {
    return {
      status: "bad-syntax",
      error: {
        code: TalerErrorCode.WALLET_CORE_API_BAD_REQUEST,
      },
    };
  }

  // FIXME: Do completion via network.
  return {
    completion: canonicalizeBaseUrl(trimmedUrl),
    status: "ok",
  };
}

async function handleSetWalletRunConfig(
  wex: WalletExecutionContext,
  req: InitRequest,
) {
  if (logger.shouldLogTrace()) {
    const initType = wex.ws.initCalled
      ? "repeat initialization"
      : "first initialization";
    logger.trace(`init request (${initType}): ${j2s(req)}`);
  }

  // Write to the DB to make sure that we're failing early in
  // case the DB is not writeable.
  try {
    await wex.db.runReadWriteTx({ storeNames: ["config"] }, async (tx) => {
      tx.config.put({
        key: ConfigRecordKey.LastInitInfo,
        value: timestampProtocolToDb(TalerProtocolTimestamp.now()),
      });
    });
  } catch (e) {
    logger.error("error writing to database during initialization");
    throw TalerError.fromDetail(TalerErrorCode.WALLET_DB_UNAVAILABLE, {
      innerError: getErrorDetailFromException(e),
    });
  }
  wex.ws.initWithConfig(applyRunConfigDefaults(req.config));

  if (wex.ws.config.testing.skipDefaults) {
    logger.trace("skipping defaults");
  } else {
    logger.trace("filling defaults");
    await fillDefaults(wex);
  }

  if (req.config?.logLevel) {
    setGlobalLogLevelFromString(req.config.logLevel);
  }

  await migrateMaterializedTransactions(wex);

  const resp: InitResponse = {
    versionInfo: await handleGetVersion(wex),
  };

  if (req.config?.lazyTaskLoop) {
    logger.trace("lazily starting task loop");
  } else {
    await wex.taskScheduler.ensureRunning();
  }

  wex.ws.initCalled = true;
  return resp;
}

async function handleWithdrawTestkudos(wex: WalletExecutionContext) {
  return await withdrawTestBalance(wex, {
    amount: "TESTKUDOS:10" as AmountString,
    corebankApiBaseUrl: "https://bank.test.taler.net/",
    exchangeBaseUrl: "https://exchange.test.taler.net/",
  });
}

async function handleWithdrawTestBalance(
  wex: WalletExecutionContext,
  req: WithdrawTestBalanceRequest,
): Promise<WithdrawTestBalanceResult> {
  return await withdrawTestBalance(wex, req);
}

async function handleRunIntegrationTest(
  wex: WalletExecutionContext,
  req: IntegrationTestArgs,
): Promise<EmptyObject> {
  await runIntegrationTest(wex, req);
  return {};
}

async function handleRunIntegrationTestV2(
  wex: WalletExecutionContext,
  req: IntegrationTestV2Args,
): Promise<EmptyObject> {
  await runIntegrationTest2(wex, req);
  return {};
}

async function handleValidateIban(
  wex: WalletExecutionContext,
  req: ValidateIbanRequest,
): Promise<ValidateIbanResponse> {
  const valRes = validateIban(req.iban);
  const resp: ValidateIbanResponse = {
    valid: valRes.type === "valid",
  };
  return resp;
}

async function handleAddExchange(
  wex: WalletExecutionContext,
  req: AddExchangeRequest,
): Promise<AddExchangeResponse> {
  let exchangeBaseUrl: string;
  if (req.exchangeBaseUrl) {
    logger.warn(
      "Deprecated request property: AddExchangeRequest.exchangeBaseUrl",
    );
    exchangeBaseUrl = req.exchangeBaseUrl;
  } else if (req.uri) {
    if (req.uri.startsWith("http")) {
      const canonUrl = canonicalizeBaseUrl(req.uri);
      if (req.uri != canonUrl) {
        throw Error("exchange base URL must be canonicalized");
      }
      exchangeBaseUrl = req.uri;
    } else if (req.uri.startsWith("taler")) {
      const p = parseTalerUri(req.uri);
      if (p?.type !== TalerUriAction.AddExchange) {
        throw Error("invalid taler://add-exchange/ URI");
      }
      exchangeBaseUrl = p.exchangeBaseUrl;
    } else {
      throw Error("AddExchangeRequest.uri must be http(s) or taler URI");
    }
  } else {
    throw Error(
      "AddExchangeRequest must either specify uri or exchangeBaseUrl",
    );
  }

  if (req.allowCompletion) {
    const completeRes = await handleCompleteExchangeBaseUrl(wex, {
      url: exchangeBaseUrl,
    });
    if (completeRes.status != "ok") {
      throw TalerError.fromUncheckedDetail(completeRes.error);
    }
    exchangeBaseUrl = completeRes.completion;
  }

  await fetchFreshExchange(wex, exchangeBaseUrl, {});
  // Exchange has been explicitly added upon user request.
  // Thus, we mark it as "used".
  if (!req.ephemeral) {
    await wex.db.runAllStoresReadWriteTx({}, async (tx) => {
      await markExchangeUsed(tx, exchangeBaseUrl);
    });
  }
  return {
    exchangeBaseUrl,
  };
}

async function handleUpdateExchangeEntry(
  wex: WalletExecutionContext,
  req: UpdateExchangeEntryRequest,
): Promise<EmptyObject> {
  await fetchFreshExchange(wex, req.exchangeBaseUrl, {
    forceUpdate: !!req.force,
  });
  return {};
}

async function handleTestingGetDenomStats(
  wex: WalletExecutionContext,
  req: TestingGetDenomStatsRequest,
): Promise<TestingGetDenomStatsResponse> {
  const denomStats: TestingGetDenomStatsResponse = {
    numKnown: 0,
    numLost: 0,
    numOffered: 0,
  };
  await wex.db.runReadOnlyTx({ storeNames: ["denominations"] }, async (tx) => {
    const denoms = await tx.denominations.indexes.byExchangeBaseUrl.getAll(
      req.exchangeBaseUrl,
    );
    for (const d of denoms) {
      denomStats.numKnown++;
      if (d.isOffered) {
        denomStats.numOffered++;
      }
      if (d.isLost) {
        denomStats.numLost++;
      }
    }
  });
  return denomStats;
}

async function handleAddBankAccount(
  wex: WalletExecutionContext,
  req: AddBankAccountRequest,
): Promise<AddBankAccountResponse> {
  const acctId = await wex.db.runReadWriteTx(
    { storeNames: ["bankAccountsV2"] },
    async (tx) => {
      let currencies = req.currencies;
      let myId: string;
      const oldAcct = await tx.bankAccountsV2.indexes.byPaytoUri.get(
        req.paytoUri,
      );
      if (req.replaceBankAccountId) {
        myId = req.replaceBankAccountId;
      } else if (oldAcct) {
        myId = oldAcct.bankAccountId;
        currencies = [
          ...new Set([
            ...(req.currencies ?? []),
            ...(oldAcct.currencies ?? []),
          ]),
        ];
      } else {
        // New Account!
        myId = `acct:${encodeCrock(getRandomBytes(32))}`;
      }
      await tx.bankAccountsV2.put({
        bankAccountId: myId,
        paytoUri: req.paytoUri,
        label: req.label,
        currencies,
        kycCompleted: false,
      });
      return myId;
    },
  );
  wex.ws.notify({
    type: NotificationType.BankAccountChange,
    bankAccountId: acctId,
  });
  return {
    bankAccountId: acctId,
  };
}

async function handleForgetBankAccount(
  wex: WalletExecutionContext,
  req: ForgetBankAccountRequest,
): Promise<EmptyObject> {
  await forgetBankAccount(wex, req.bankAccountId);
  return {};
}

// FIXME: Doesn't have proper type!
async function handleTestingGetReserveHistory(
  wex: WalletExecutionContext,
  req: TestingGetReserveHistoryRequest,
): Promise<any> {
  const reserve = await wex.db.runReadOnlyTx(
    { storeNames: ["reserves"] },
    async (tx) => {
      return tx.reserves.indexes.byReservePub.get(req.reservePub);
    },
  );
  if (!reserve) {
    throw Error("no reserve pub found");
  }
  const sigResp = await wex.cryptoApi.signReserveHistoryReq({
    reservePriv: reserve.reservePriv,
    startOffset: 0,
  });
  const exchangeBaseUrl = req.exchangeBaseUrl;
  const url = new URL(`reserves/${req.reservePub}/history`, exchangeBaseUrl);
  const resp = await cancelableFetch(wex, url, {
    headers: { ["Taler-Reserve-History-Signature"]: sigResp.sig },
  });
  const historyJson = await readSuccessResponseJsonOrThrow(resp, codecForAny());
  return historyJson;
}

async function handleAcceptManualWithdrawal(
  wex: WalletExecutionContext,
  req: AcceptManualWithdrawalRequest,
): Promise<AcceptManualWithdrawalResult> {
  const res = await createManualWithdrawal(wex, {
    amount: Amounts.parseOrThrow(req.amount),
    exchangeBaseUrl: req.exchangeBaseUrl,
    restrictAge: req.restrictAge,
    forceReservePriv: req.forceReservePriv,
  });
  return res;
}

async function handleGetExchangeTos(
  wex: WalletExecutionContext,
  req: GetExchangeTosRequest,
): Promise<GetExchangeTosResult> {
  return getExchangeTos(
    wex,
    req.exchangeBaseUrl,
    req.acceptedFormat,
    req.acceptLanguage,
  );
}

async function handleGetQrCodesForPayto(
  wex: WalletExecutionContext,
  req: GetQrCodesForPaytoRequest,
): Promise<GetQrCodesForPaytoResponse> {
  return {
    codes: getQrCodesForPayto(req.paytoUri),
  };
}

async function handleGetBankingChoicesForPayto(
  wex: WalletExecutionContext,
  req: GetBankingChoicesForPaytoRequest,
): Promise<GetBankingChoicesForPaytoResponse> {
  const parsedPayto = parsePaytoUri(req.paytoUri);
  if (!parsedPayto) {
    throw Error("invalid payto URI");
  }
  const amount = parsedPayto.params["amount"];
  if (!amount) {
    logger.warn("payto URI has no amount");
    return {
      choices: [],
    };
  }
  const currency = Amounts.currencyOf(amount);
  switch (currency) {
    case "KUDOS":
      return {
        choices: [
          {
            label: "Demobank Website",
            type: "link",
            uri: `https://bank.demo.taler.net/webui/#/transfer/${encodeURIComponent(
              req.paytoUri,
            )}`,
          },
          {
            label: "Demobank App",
            type: "link",
            uri: `https://bank.demo.taler.net/app/transfer/${encodeURIComponent(
              req.paytoUri,
            )}`,
          },
        ],
      };
      break;
    default:
      return {
        choices: [],
      };
  }
}

async function handleGetChoicesForPayment(
  wex: WalletExecutionContext,
  req: GetChoicesForPaymentRequest,
): Promise<GetChoicesForPaymentResult> {
  return await getChoicesForPayment(wex, req.transactionId, req.forcedCoinSel);
}

async function handleConfirmPay(
  wex: WalletExecutionContext,
  req: ConfirmPayRequest,
): Promise<ConfirmPayResult> {
  return await confirmPay(wex, {
    transactionId: req.transactionId,
    choiceIndex: req.choiceIndex,
    forcedCoinSel: undefined,
    sessionIdOverride: req.sessionId,
    useDonau: req.useDonau,
  });
}

async function handleListDiscounts(
  wex: WalletExecutionContext,
  req: ListDiscountsRequest,
): Promise<ListDiscountsResponse> {
  return await listDiscounts(wex, req.tokenIssuePubHash, req.merchantBaseUrl);
}

async function handleDeleteDiscount(
  wex: WalletExecutionContext,
  req: DeleteDiscountRequest,
): Promise<EmptyObject> {
  return await deleteDiscount(wex, req.tokenFamilyHash);
}

async function handleAbortTransaction(
  wex: WalletExecutionContext,
  req: AbortTransactionRequest,
): Promise<EmptyObject> {
  await abortTransaction(wex, req.transactionId);
  return {};
}

async function handleSuspendTransaction(
  wex: WalletExecutionContext,
  req: SuspendTransactionRequest,
): Promise<EmptyObject> {
  await suspendTransaction(wex, req.transactionId);
  return {};
}

async function handleGetActiveTasks(
  wex: WalletExecutionContext,
  req: EmptyObject,
): Promise<GetActiveTasksResponse> {
  const allTasksId = (await getActiveTaskIds(wex.ws)).taskIds;

  const tasksInfo = await Promise.all(
    allTasksId.map(async (id) => {
      return await wex.db.runReadOnlyTx(
        { storeNames: ["operationRetries"] },
        async (tx) => {
          return tx.operationRetries.get(id);
        },
      );
    }),
  );

  const tasks = allTasksId.map((taskId, i): ActiveTask => {
    const transaction = convertTaskToTransactionId(taskId);
    const d = tasksInfo[i];

    const firstTry = !d
      ? undefined
      : timestampAbsoluteFromDb(d.retryInfo.firstTry);
    const nextTry = !d
      ? undefined
      : timestampAbsoluteFromDb(d.retryInfo.nextRetry);
    const counter = d?.retryInfo.retryCounter;
    const lastError = d?.lastError;

    return {
      taskId: taskId,
      retryCounter: counter,
      firstTry,
      nextTry,
      lastError,
      transaction,
    };
  });
  return { tasks };
}

async function handleFailTransaction(
  wex: WalletExecutionContext,
  req: FailTransactionRequest,
): Promise<EmptyObject> {
  await failTransaction(wex, req.transactionId);
  return {};
}

async function handleTestingGetSampleTransactions(
  wex: WalletExecutionContext,
  req: EmptyObject,
): Promise<TransactionsResponse> {
  // FIXME!
  return { transactions: [] };
  // These are out of date!
  //return { transactions: sampleWalletCoreTransactions };
}

async function handleStartRefundQuery(
  wex: WalletExecutionContext,
  req: StartRefundQueryRequest,
): Promise<EmptyObject> {
  const txIdParsed = parseTransactionIdentifier(req.transactionId);
  if (!txIdParsed) {
    throw Error("invalid transaction ID");
  }
  if (txIdParsed.tag !== TransactionType.Payment) {
    throw Error("expected payment transaction ID");
  }
  await startQueryRefund(wex, txIdParsed.proposalId);
  return {};
}

async function handleHintNetworkAvailability(
  wex: WalletExecutionContext,
  req: HintNetworkAvailabilityRequest,
): Promise<EmptyObject> {
  // If network was already available, don't do anything
  if (wex.ws.networkAvailable === req.isNetworkAvailable) {
    return {};
  }
  wex.ws.networkAvailable = req.isNetworkAvailable;
  // When network becomes available, restart tasks as they're blocked
  // waiting for the network.
  // When network goes down, restart tasks so they notice the network
  // is down and wait.
  await restartAllRunningTasks(wex);
  return {};
}

async function handleGetDepositWireTypes(
  wex: WalletExecutionContext,
  req: GetDepositWireTypesRequest,
): Promise<GetDepositWireTypesResponse> {
  const wtSet: Set<string> = new Set();
  const wireTypeDetails: WireTypeDetails[] = [];
  const talerBankHostnames: string[] = [];
  await wex.db.runReadOnlyTx(
    { storeNames: ["exchanges", "exchangeDetails"] },
    async (tx) => {
      const exchanges = await tx.exchanges.getAll();
      for (const exchange of exchanges) {
        const det = await getExchangeWireDetailsInTx(tx, exchange.baseUrl);
        if (!det) {
          continue;
        }
        if (req.currency !== undefined && det.currency !== req.currency) {
          continue;
        }
        for (const acc of det.wireInfo.accounts) {
          let usable = true;
          for (const dr of acc.debit_restrictions) {
            if (dr.type === "deny") {
              usable = false;
              break;
            }
          }
          if (!usable) {
            continue;
          }
          const parsedPayto = parsePaytoUri(acc.payto_uri);
          if (!parsedPayto) {
            continue;
          }
          if (
            parsedPayto.isKnown &&
            parsedPayto.targetType === "x-taler-bank"
          ) {
            if (!talerBankHostnames.includes(parsedPayto.host)) {
              talerBankHostnames.push(parsedPayto.host);
            }
          }
          if (!wtSet.has(parsedPayto.targetType)) {
            wtSet.add(parsedPayto.targetType);
            wireTypeDetails.push({
              paymentTargetType: parsedPayto.targetType,
              // Will possibly extended later by other exchanges
              // with the same wire type.
              talerBankHostnames,
            });
          }
        }
      }
    },
  );
  return {
    wireTypeDetails,
  };
}

async function handleGetDepositWireTypesForCurrency(
  wex: WalletExecutionContext,
  req: GetDepositWireTypesForCurrencyRequest,
): Promise<GetDepositWireTypesForCurrencyResponse> {
  const wtSet: Set<string> = new Set();
  const wireTypeDetails: WireTypeDetails[] = [];
  const talerBankHostnames: string[] = [];
  await wex.db.runReadOnlyTx(
    { storeNames: ["exchanges", "exchangeDetails"] },
    async (tx) => {
      const exchanges = await tx.exchanges.getAll();
      for (const exchange of exchanges) {
        const det = await getExchangeWireDetailsInTx(tx, exchange.baseUrl);
        if (!det) {
          continue;
        }
        if (det.currency !== req.currency) {
          continue;
        }
        for (const acc of det.wireInfo.accounts) {
          let usable = true;
          for (const dr of acc.debit_restrictions) {
            if (dr.type === "deny") {
              usable = false;
              break;
            }
          }
          if (!usable) {
            continue;
          }
          const parsedPayto = parsePaytoUri(acc.payto_uri);
          if (!parsedPayto) {
            continue;
          }
          if (
            parsedPayto.isKnown &&
            parsedPayto.targetType === "x-taler-bank"
          ) {
            if (!talerBankHostnames.includes(parsedPayto.host)) {
              talerBankHostnames.push(parsedPayto.host);
            }
          }
          if (!wtSet.has(parsedPayto.targetType)) {
            wtSet.add(parsedPayto.targetType);
            wireTypeDetails.push({
              paymentTargetType: parsedPayto.targetType,
              // Will possibly extended later by other exchanges
              // with the same wire type.
              talerBankHostnames,
            });
          }
        }
      }
    },
  );
  return {
    wireTypes: [...wtSet],
    wireTypeDetails,
  };
}

async function handleListGlobalCurrencyExchanges(
  wex: WalletExecutionContext,
  _req: EmptyObject,
): Promise<ListGlobalCurrencyExchangesResponse> {
  const resp: ListGlobalCurrencyExchangesResponse = {
    exchanges: [],
  };
  await wex.db.runReadOnlyTx(
    { storeNames: ["globalCurrencyExchanges"] },
    async (tx) => {
      const gceList = await tx.globalCurrencyExchanges.iter().toArray();
      for (const gce of gceList) {
        resp.exchanges.push({
          currency: gce.currency,
          exchangeBaseUrl: gce.exchangeBaseUrl,
          exchangeMasterPub: gce.exchangeMasterPub,
        });
      }
    },
  );
  return resp;
}

async function handleListGlobalCurrencyAuditors(
  wex: WalletExecutionContext,
  _req: EmptyObject,
): Promise<ListGlobalCurrencyAuditorsResponse> {
  const resp: ListGlobalCurrencyAuditorsResponse = {
    auditors: [],
  };
  await wex.db.runReadOnlyTx(
    { storeNames: ["globalCurrencyAuditors"] },
    async (tx) => {
      const gcaList = await tx.globalCurrencyAuditors.iter().toArray();
      for (const gca of gcaList) {
        resp.auditors.push({
          currency: gca.currency,
          auditorBaseUrl: gca.auditorBaseUrl,
          auditorPub: gca.auditorPub,
        });
      }
    },
  );
  return resp;
}

export async function handleAddGlobalCurrencyExchange(
  wex: WalletExecutionContext,
  req: AddGlobalCurrencyExchangeRequest,
): Promise<EmptyObject> {
  await wex.db.runReadWriteTx(
    { storeNames: ["globalCurrencyExchanges", "currencyInfo"] },
    async (tx) => {
      const key = [req.currency, req.exchangeBaseUrl, req.exchangeMasterPub];
      const existingRec =
        await tx.globalCurrencyExchanges.indexes.byCurrencyAndUrlAndPub.get(
          key,
        );
      if (existingRec) {
        return;
      }
      wex.ws.exchangeCache.clear();
      const info = await tx.currencyInfo.get(
        stringifyScopeInfo({
          type: ScopeType.Exchange,
          currency: req.currency,
          url: req.exchangeBaseUrl,
        }),
      );
      if (info) {
        info.scopeInfoStr = stringifyScopeInfo({
          type: ScopeType.Global,
          currency: req.currency,
        });
        await tx.currencyInfo.put(info);
      }
      await tx.globalCurrencyExchanges.add({
        currency: req.currency,
        exchangeBaseUrl: req.exchangeBaseUrl,
        exchangeMasterPub: req.exchangeMasterPub,
      });
    },
  );
  return {};
}

async function handleRemoveGlobalCurrencyAuditor(
  wex: WalletExecutionContext,
  req: RemoveGlobalCurrencyAuditorRequest,
): Promise<EmptyObject> {
  await wex.db.runReadWriteTx(
    { storeNames: ["globalCurrencyAuditors"] },
    async (tx) => {
      const key = [req.currency, req.auditorBaseUrl, req.auditorPub];
      const existingRec =
        await tx.globalCurrencyAuditors.indexes.byCurrencyAndUrlAndPub.get(key);
      if (!existingRec) {
        return;
      }
      checkDbInvariant(!!existingRec.id, `no global currency for ${j2s(key)}`);
      await tx.globalCurrencyAuditors.delete(existingRec.id);
      wex.ws.exchangeCache.clear();
    },
  );
  return {};
}

export async function handleRemoveGlobalCurrencyExchange(
  wex: WalletExecutionContext,
  req: RemoveGlobalCurrencyExchangeRequest,
): Promise<EmptyObject> {
  await wex.db.runReadWriteTx(
    { storeNames: ["globalCurrencyExchanges", "currencyInfo"] },
    async (tx) => {
      const key = [req.currency, req.exchangeBaseUrl, req.exchangeMasterPub];
      const existingRec =
        await tx.globalCurrencyExchanges.indexes.byCurrencyAndUrlAndPub.get(
          key,
        );
      if (!existingRec) {
        return;
      }
      wex.ws.exchangeCache.clear();
      checkDbInvariant(!!existingRec.id, `no global exchange for ${j2s(key)}`);
      await tx.currencyInfo.delete(
        stringifyScopeInfo({
          type: ScopeType.Global,
          currency: req.currency,
        }),
      );
      await tx.globalCurrencyExchanges.delete(existingRec.id);
    },
  );
  return {};
}

async function handleAddGlobalCurrencyAuditor(
  wex: WalletExecutionContext,
  req: AddGlobalCurrencyAuditorRequest,
): Promise<EmptyObject> {
  await wex.db.runReadWriteTx(
    { storeNames: ["globalCurrencyAuditors"] },
    async (tx) => {
      const key = [req.currency, req.auditorBaseUrl, req.auditorPub];
      const existingRec =
        await tx.globalCurrencyAuditors.indexes.byCurrencyAndUrlAndPub.get(key);
      if (existingRec) {
        return;
      }
      await tx.globalCurrencyAuditors.add({
        currency: req.currency,
        auditorBaseUrl: req.auditorBaseUrl,
        auditorPub: req.auditorPub,
      });
      wex.ws.exchangeCache.clear();
    },
  );
  return {};
}

async function handleShutdown(
  wex: WalletExecutionContext,
  _req: EmptyObject,
): Promise<EmptyObject> {
  wex.ws.stop();
  return {};
}

async function handleTestingSetTimetravel(
  wex: WalletExecutionContext,
  req: TestingSetTimetravelRequest,
): Promise<EmptyObject> {
  setDangerousTimetravel(req.offsetMs);
  await wex.taskScheduler.reload();
  return {};
}

async function handleCanonicalizeBaseUrl(
  _wex: WalletExecutionContext,
  req: CanonicalizeBaseUrlRequest,
): Promise<CanonicalizeBaseUrlResponse> {
  return {
    url: canonicalizeBaseUrl(req.url),
  };
}

async function handleDeleteExchange(
  wex: WalletExecutionContext,
  req: DeleteExchangeRequest,
): Promise<EmptyObject> {
  await deleteExchange(wex, req);
  return {};
}

async function handleCreateStoredBackup(
  wex: WalletExecutionContext,
  _req: EmptyObject,
): Promise<CreateStoredBackupResponse> {
  return await createStoredBackup(wex);
}

async function handleExportDbToFile(
  wex: WalletExecutionContext,
  req: ExportDbToFileRequest,
): Promise<ExportDbToFileResponse> {
  const res = await wex.ws.dbImplementation.exportToFile(
    req.directory,
    req.stem,
    req.forceFormat,
  );
  return {
    path: res.path,
  };
}

async function handleImportDb(
  wex: WalletExecutionContext,
  req: ImportDbRequest,
): Promise<EmptyObject> {
  // FIXME: This should atomically re-materialize transactions!
  await importDb(wex.db.idbHandle(), req.dump);
  await wex.db.runAllStoresReadWriteTx({}, async (tx) => {
    await rematerializeTransactions(wex, tx);
  });
  return {};
}

async function handleImportDbFromFile(
  wex: WalletExecutionContext,
  req: ImportDbFromFileRequest,
): Promise<EmptyObject> {
  if (req.path.endsWith(".json")) {
    const dump = await wex.ws.dbImplementation.readBackupJson(req.path);
    return await handleImportDb(wex, {
      dump,
    });
  } else {
    throw Error("DB file import only supports .json files at the moment");
  }
}

async function handleAcceptBankIntegratedWithdrawal(
  wex: WalletExecutionContext,
  req: AcceptBankIntegratedWithdrawalRequest,
): Promise<AcceptWithdrawalResponse> {
  return await acceptBankIntegratedWithdrawal(wex, {
    selectedExchange: req.exchangeBaseUrl,
    talerWithdrawUri: req.talerWithdrawUri,
    forcedDenomSel: req.forcedDenomSel,
    restrictAge: req.restrictAge,
    amount: req.amount,
  });
}

async function handleGetCurrencySpecification(
  wex: WalletExecutionContext,
  req: GetCurrencySpecificationRequest,
): Promise<GetCurrencySpecificationResponse> {
  const spec = await wex.db.runReadOnlyTx(
    {
      storeNames: ["currencyInfo"],
    },
    async (tx) => {
      return WalletDbHelpers.getCurrencyInfo(tx, req.scope);
    },
  );
  if (spec) {
    return {
      currencySpecification: spec.currencySpec,
    };
  }
  // Hard-coded mock for KUDOS and TESTKUDOS
  if (req.scope.currency === "KUDOS") {
    const kudosResp: GetCurrencySpecificationResponse = {
      currencySpecification: {
        name: "Kudos (Taler Demonstrator)",
        num_fractional_input_digits: 2,
        num_fractional_normal_digits: 2,
        num_fractional_trailing_zero_digits: 2,
        alt_unit_names: {
          "0": "ク",
        },
      },
    };
    return kudosResp;
  } else if (req.scope.currency === "TESTKUDOS") {
    const testkudosResp: GetCurrencySpecificationResponse = {
      currencySpecification: {
        name: "Test (Taler Unstable Demonstrator)",
        num_fractional_input_digits: 0,
        num_fractional_normal_digits: 0,
        num_fractional_trailing_zero_digits: 0,
        alt_unit_names: {
          "0": "テ",
        },
      },
    };
    return testkudosResp;
  }
  const defaultResp: GetCurrencySpecificationResponse = {
    currencySpecification: {
      name: req.scope.currency,
      num_fractional_input_digits: 2,
      num_fractional_normal_digits: 2,
      num_fractional_trailing_zero_digits: 2,
      alt_unit_names: {
        "0": req.scope.currency,
      },
    },
  };
  return defaultResp;
}

export async function handleHintApplicationResumed(
  wex: WalletExecutionContext,
  req: EmptyObject,
): Promise<EmptyObject> {
  logger.info("handling hintApplicationResumed");
  await restartAllRunningTasks(wex);
  return {};
}

export async function handleTestingRunFixup(
  wex: WalletExecutionContext,
  req: RunFixupRequest,
): Promise<EmptyObject> {
  for (const fixup of walletDbFixups) {
    if (fixup.name != req.id) {
      continue;
    }
    await wex.db.runAllStoresReadWriteTx({}, async (tx) => {
      await fixup.fn(tx);
      await rematerializeTransactions(wex, tx);
    });
    return {};
  }
  throw Error("fixup not found");
}

async function handleGetVersion(
  wex: WalletExecutionContext,
): Promise<WalletCoreVersion> {
  const result: WalletCoreVersion = {
    implementationSemver: walletCoreBuildInfo.implementationSemver,
    implementationGitHash: walletCoreBuildInfo.implementationGitHash,
    hash: undefined,
    version: WALLET_CORE_API_PROTOCOL_VERSION,
    exchange: WALLET_EXCHANGE_PROTOCOL_VERSION,
    merchant: WALLET_MERCHANT_PROTOCOL_VERSION,
    bankConversionApiRange: WALLET_BANK_CONVERSION_API_PROTOCOL_VERSION,
    bankIntegrationApiRange: TalerBankIntegrationHttpClient.PROTOCOL_VERSION,
    corebankApiRange: WALLET_COREBANK_API_PROTOCOL_VERSION,
    bank: TalerBankIntegrationHttpClient.PROTOCOL_VERSION,
    devMode: wex.ws.config.testing.devModeActive,
  };
  return result;
}

interface HandlerWithValidator<Tag extends WalletApiOperation> {
  codec: Codec<WalletCoreRequestType<Tag>>;
  handler: (
    wex: WalletExecutionContext,
    req: WalletCoreRequestType<Tag>,
  ) => Promise<WalletCoreResponseType<Tag>>;
}

const handlers: { [T in WalletApiOperation]: HandlerWithValidator<T> } = {
  [WalletApiOperation.TestingWaitExchangeState]: {
    codec: codecForAny(),
    handler: handleTestingWaitExchangeState,
  },
  [WalletApiOperation.GetDonauStatements]: {
    codec: codecForGetDonauStatementsRequest(),
    handler: handleGetDonauStatements,
  },
  [WalletApiOperation.GetDonau]: {
    codec: codecForEmptyObject(),
    handler: handleGetDonau,
  },
  [WalletApiOperation.SetDonau]: {
    codec: codecForSetDonauRequest(),
    handler: handleSetDonau,
  },
  [WalletApiOperation.TestingGetDbStats]: {
    codec: codecForEmptyObject(),
    handler: async (wex) => {
      return wex.ws.dbImplementation.getStats();
    },
  },
  [WalletApiOperation.ExportDbToFile]: {
    codec: codecForExportDbToFileRequest(),
    handler: handleExportDbToFile,
  },
  [WalletApiOperation.HintApplicationResumed]: {
    codec: codecForEmptyObject(),
    handler: handleHintApplicationResumed,
  },
  [WalletApiOperation.TestingRunFixup]: {
    codec: codecForRunFixupRequest(),
    handler: handleTestingRunFixup,
  },
  [WalletApiOperation.AbortTransaction]: {
    codec: codecForAbortTransaction(),
    handler: handleAbortTransaction,
  },
  [WalletApiOperation.CreateStoredBackup]: {
    codec: codecForEmptyObject(),
    handler: handleCreateStoredBackup,
  },
  [WalletApiOperation.DeleteStoredBackup]: {
    codec: codecForDeleteStoredBackupRequest(),
    handler: handleDeleteStoredBackup,
  },
  [WalletApiOperation.ListStoredBackups]: {
    codec: codecForEmptyObject(),
    handler: listStoredBackups,
  },
  [WalletApiOperation.CompleteExchangeBaseUrl]: {
    codec: codecForCompleteBaseUrlRequest(),
    handler: handleCompleteExchangeBaseUrl,
  },
  [WalletApiOperation.SetWalletRunConfig]: {
    codec: codecForInitRequest(),
    handler: handleSetWalletRunConfig,
  },
  // Alias for SetWalletRunConfig
  [WalletApiOperation.InitWallet]: {
    codec: codecForInitRequest(),
    handler: handleSetWalletRunConfig,
  },
  [WalletApiOperation.RecoverStoredBackup]: {
    codec: codecForRecoverStoredBackupRequest(),
    handler: handleRecoverStoredBackup,
  },
  [WalletApiOperation.WithdrawTestkudos]: {
    codec: codecForEmptyObject(),
    handler: handleWithdrawTestkudos,
  },
  [WalletApiOperation.WithdrawTestBalance]: {
    codec: codecForWithdrawTestBalance(),
    handler: handleWithdrawTestBalance,
  },
  [WalletApiOperation.RunIntegrationTest]: {
    codec: codecForIntegrationTestArgs(),
    handler: handleRunIntegrationTest,
  },
  [WalletApiOperation.RunIntegrationTestV2]: {
    codec: codecForIntegrationTestV2Args(),
    handler: handleRunIntegrationTestV2,
  },
  [WalletApiOperation.ValidateIban]: {
    codec: codecForValidateIbanRequest(),
    handler: handleValidateIban,
  },
  [WalletApiOperation.TestPay]: {
    codec: codecForTestPayArgs(),
    handler: testPay,
  },
  [WalletApiOperation.GetTransactions]: {
    codec: codecForTransactionsRequest(),
    handler: getTransactions,
  },
  [WalletApiOperation.GetTransactionsV2]: {
    codec: codecForGetTransactionsV2Request(),
    handler: getTransactionsV2,
  },
  [WalletApiOperation.GetTransactionById]: {
    codec: codecForTransactionByIdRequest(),
    handler: getTransactionById,
  },
  [WalletApiOperation.AddExchange]: {
    codec: codecForAddExchangeRequest(),
    handler: handleAddExchange,
  },
  [WalletApiOperation.TestingPing]: {
    codec: codecForEmptyObject(),
    handler: async () => ({}),
  },
  [WalletApiOperation.UpdateExchangeEntry]: {
    codec: codecForUpdateExchangeEntryRequest(),
    handler: handleUpdateExchangeEntry,
  },
  [WalletApiOperation.TestingGetDenomStats]: {
    codec: codecForTestingGetDenomStatsRequest(),
    handler: handleTestingGetDenomStats,
  },
  [WalletApiOperation.ListExchanges]: {
    codec: codecForListExchangesRequest(),
    handler: listExchanges,
  },
  [WalletApiOperation.GetExchangeEntryByUrl]: {
    codec: codecForGetExchangeEntryByUrlRequest(),
    handler: lookupExchangeByUri,
  },
  [WalletApiOperation.GetExchangeDetailedInfo]: {
    codec: codecForGetExchangeEntryByUrlRequest(),
    handler: (wex, req) => getExchangeDetailedInfo(wex, req.exchangeBaseUrl),
  },
  [WalletApiOperation.ListBankAccounts]: {
    codec: codecForListBankAccounts(),
    handler: handleListBankAccounts,
  },
  [WalletApiOperation.GetBankAccountById]: {
    codec: codecForGetBankAccountByIdRequest(),
    handler: handleGetBankAccountById,
  },
  [WalletApiOperation.AddBankAccount]: {
    codec: codecForAddBankAccountRequest(),
    handler: handleAddBankAccount,
  },
  [WalletApiOperation.ForgetBankAccount]: {
    codec: codecForForgetBankAccount(),
    handler: handleForgetBankAccount,
  },
  [WalletApiOperation.GetWithdrawalDetailsForUri]: {
    codec: codecForGetWithdrawalDetailsForUri(),
    handler: (wex, req) =>
      getWithdrawalDetailsForUri(wex, req.talerWithdrawUri),
  },
  [WalletApiOperation.TestingGetReserveHistory]: {
    codec: codecForTestingGetReserveHistoryRequest(),
    handler: handleTestingGetReserveHistory,
  },
  [WalletApiOperation.AcceptManualWithdrawal]: {
    codec: codecForAcceptManualWithdrawalRequest(),
    handler: handleAcceptManualWithdrawal,
  },
  [WalletApiOperation.GetWithdrawalDetailsForAmount]: {
    codec: codecForGetWithdrawalDetailsForAmountRequest(),
    handler: getWithdrawalDetailsForAmount,
  },
  [WalletApiOperation.GetBalances]: {
    codec: codecForEmptyObject(),
    handler: getBalances,
  },
  [WalletApiOperation.GetBalanceDetail]: {
    codec: codecForGetBalanceDetailRequest(),
    handler: getBalanceDetail,
  },
  [WalletApiOperation.GetUserAttentionRequests]: {
    codec: codecForUserAttentionsRequest(),
    handler: getUserAttentions,
  },
  [WalletApiOperation.MarkAttentionRequestAsRead]: {
    codec: codecForUserAttentionByIdRequest(),
    handler: async (wex, req) => {
      await markAttentionRequestAsRead(wex, req);
      return {};
    },
  },
  [WalletApiOperation.GetUserAttentionUnreadCount]: {
    codec: codecForUserAttentionsRequest(),
    handler: getUserAttentionsUnreadCount,
  },
  [WalletApiOperation.SetExchangeTosAccepted]: {
    codec: codecForAcceptExchangeTosRequest(),
    handler: async (wex, req) => {
      await acceptExchangeTermsOfService(wex, req.exchangeBaseUrl);
      return {};
    },
  },
  [WalletApiOperation.SetExchangeTosForgotten]: {
    codec: codecForAcceptExchangeTosRequest(),
    handler: async (wex, req) => {
      await forgetExchangeTermsOfService(wex, req.exchangeBaseUrl);
      return {};
    },
  },
  [WalletApiOperation.AcceptBankIntegratedWithdrawal]: {
    codec: codecForAcceptBankIntegratedWithdrawalRequest(),
    handler: handleAcceptBankIntegratedWithdrawal,
  },
  [WalletApiOperation.ConfirmWithdrawal]: {
    codec: codecForConfirmWithdrawalRequestRequest(),
    handler: confirmWithdrawal,
  },
  [WalletApiOperation.PrepareBankIntegratedWithdrawal]: {
    codec: codecForPrepareBankIntegratedWithdrawalRequest(),
    handler: prepareBankIntegratedWithdrawal,
  },
  [WalletApiOperation.GetExchangeTos]: {
    codec: codecForGetExchangeTosRequest(),
    handler: handleGetExchangeTos,
  },
  [WalletApiOperation.RetryPendingNow]: {
    codec: codecForEmptyObject(),
    handler: handleRetryPendingNow,
  },
  [WalletApiOperation.SharePayment]: {
    codec: codecForSharePaymentRequest(),
    handler: handleSharePayment,
  },
  [WalletApiOperation.PrepareWithdrawExchange]: {
    codec: codecForPrepareWithdrawExchangeRequest(),
    handler: handlePrepareWithdrawExchange,
  },
  [WalletApiOperation.CheckPayForTemplate]: {
    codec: codecForCheckPayTemplateRequest(),
    handler: checkPayForTemplate,
  },
  [WalletApiOperation.PreparePayForUri]: {
    codec: codecForPreparePayRequest(),
    handler: (wex, req) => preparePayForUri(wex, req.talerPayUri),
  },
  [WalletApiOperation.PreparePayForTemplate]: {
    codec: codecForPreparePayTemplateRequest(),
    handler: preparePayForTemplate,
  },
  [WalletApiOperation.GetQrCodesForPayto]: {
    codec: codecForGetQrCodesForPaytoRequest(),
    handler: handleGetQrCodesForPayto,
  },
  [WalletApiOperation.GetChoicesForPayment]: {
    codec: codecForGetChoicesForPaymentRequest(),
    handler: handleGetChoicesForPayment,
  },
  [WalletApiOperation.ConfirmPay]: {
    codec: codecForConfirmPayRequest(),
    handler: handleConfirmPay,
  },
  [WalletApiOperation.ListDiscounts]: {
    codec: codecForListDiscountsRequest(),
    handler: handleListDiscounts,
  },
  [WalletApiOperation.DeleteDiscount]: {
    codec: codecForDeleteDiscountRequest(),
    handler: handleDeleteDiscount,
  },
  [WalletApiOperation.SuspendTransaction]: {
    codec: codecForSuspendTransaction(),
    handler: handleSuspendTransaction,
  },
  [WalletApiOperation.GetActiveTasks]: {
    codec: codecForEmptyObject(),
    handler: handleGetActiveTasks,
  },
  [WalletApiOperation.FailTransaction]: {
    codec: codecForFailTransactionRequest(),
    handler: handleFailTransaction,
  },
  [WalletApiOperation.ResumeTransaction]: {
    codec: codecForResumeTransaction(),
    handler: async (wex, req) => {
      await resumeTransaction(wex, req.transactionId);
      return {};
    },
  },
  [WalletApiOperation.DumpCoins]: {
    codec: codecForEmptyObject(),
    handler: dumpCoins,
  },
  [WalletApiOperation.SetCoinSuspended]: {
    codec: codecForSetCoinSuspendedRequest(),
    handler: async (wex, req) => {
      await setCoinSuspended(wex, req.coinPub, req.suspended);
      return {};
    },
  },
  [WalletApiOperation.TestingGetSampleTransactions]: {
    codec: codecForEmptyObject(),
    handler: handleTestingGetSampleTransactions,
  },
  [WalletApiOperation.StartRefundQueryForUri]: {
    codec: codecForPrepareRefundRequest(),
    handler: (wex, req) => startRefundQueryForUri(wex, req.talerRefundUri),
  },
  [WalletApiOperation.StartRefundQuery]: {
    codec: codecForStartRefundQueryRequest(),
    handler: handleStartRefundQuery,
  },
  [WalletApiOperation.AddBackupProvider]: {
    codec: codecForAddBackupProviderRequest(),
    handler: addBackupProvider,
  },
  [WalletApiOperation.RunBackupCycle]: {
    codec: codecForRunBackupCycle(),
    handler: async (wex, req) => {
      await runBackupCycle(wex, req);
      return {};
    },
  },
  [WalletApiOperation.RemoveBackupProvider]: {
    codec: codecForRemoveBackupProvider(),
    handler: async (wex, req) => {
      await removeBackupProvider(wex, req);
      return {};
    },
  },
  [WalletApiOperation.ExportBackupRecovery]: {
    codec: codecForEmptyObject(),
    handler: getBackupRecovery,
  },
  [WalletApiOperation.TestingWaitTransactionState]: {
    codec: codecForAny(),
    handler: async (wex, req) => {
      await waitTransactionState(wex, req);
      return {};
    },
  },
  [WalletApiOperation.GetCurrencySpecification]: {
    codec: codecForGetCurrencyInfoRequest(),
    handler: handleGetCurrencySpecification,
  },
  [WalletApiOperation.ImportBackupRecovery]: {
    codec: codecForAny(),
    handler: async (wex, req) => {
      await loadBackupRecovery(wex, req);
      return {};
    },
  },
  [WalletApiOperation.HintNetworkAvailability]: {
    codec: codecForHintNetworkAvailabilityRequest(),
    handler: handleHintNetworkAvailability,
  },
  [WalletApiOperation.ConvertDepositAmount]: {
    codec: codecForConvertAmountRequest,
    handler: convertDepositAmount,
  },
  [WalletApiOperation.GetMaxDepositAmount]: {
    codec: codecForGetMaxDepositAmountRequest(),
    handler: getMaxDepositAmount,
  },
  [WalletApiOperation.GetMaxPeerPushDebitAmount]: {
    codec: codecForGetMaxPeerPushDebitAmountRequest(),
    handler: getMaxPeerPushDebitAmount,
  },
  [WalletApiOperation.GetBackupInfo]: {
    codec: codecForEmptyObject(),
    handler: getBackupInfo,
  },
  [WalletApiOperation.CheckDeposit]: {
    codec: codecForCheckDepositRequest(),
    handler: checkDepositGroup,
  },
  [WalletApiOperation.GenerateDepositGroupTxId]: {
    codec: codecForEmptyObject(),
    handler: async (wex, req) => {
      return {
        transactionId: generateDepositGroupTxId() as TransactionIdStr,
      };
    },
  },
  [WalletApiOperation.CreateDepositGroup]: {
    codec: codecForCreateDepositGroupRequest(),
    handler: createDepositGroup,
  },
  [WalletApiOperation.DeleteTransaction]: {
    codec: codecForDeleteTransactionRequest(),
    handler: async (wex, req) => {
      await deleteTransaction(wex, req.transactionId);
      return {};
    },
  },
  [WalletApiOperation.RetryTransaction]: {
    codec: codecForRetryTransactionRequest(),
    handler: async (wex, req) => {
      await retryTransaction(wex, req.transactionId);
      return {};
    },
  },
  [WalletApiOperation.SetWalletDeviceId]: {
    codec: codecForSetWalletDeviceIdRequest(),
    handler: async (wex, req) => {
      await setWalletDeviceId(wex, req.walletDeviceId);
      return {};
    },
  },
  [WalletApiOperation.TestCrypto]: {
    codec: codecForEmptyObject(),
    handler: async (wex, req) => {
      return await wex.cryptoApi.hashString({ str: "hello world" });
    },
  },
  [WalletApiOperation.ClearDb]: {
    codec: codecForEmptyObject(),
    handler: async (wex, req) => {
      await clearDatabase(wex.db.idbHandle());
      await wex.taskScheduler.reload();
      wex.ws.clearAllCaches();
      return {};
    },
  },
  [WalletApiOperation.Recycle]: {
    codec: codecForEmptyObject(),
    handler: async (wex, req) => {
      throw Error("not implemented");
    },
  },
  [WalletApiOperation.ExportDb]: {
    codec: codecForEmptyObject(),
    handler: async (wex, req) => {
      const dbDump = await exportDb(wex.ws.idbFactory);
      return dbDump;
    },
  },
  [WalletApiOperation.GetDepositWireTypes]: {
    codec: codecForGetDepositWireTypesRequest(),
    handler: handleGetDepositWireTypes,
  },
  [WalletApiOperation.GetDepositWireTypesForCurrency]: {
    codec: codecForGetDepositWireTypesForCurrencyRequest(),
    handler: handleGetDepositWireTypesForCurrency,
  },
  [WalletApiOperation.ListGlobalCurrencyExchanges]: {
    codec: codecForEmptyObject(),
    handler: handleListGlobalCurrencyExchanges,
  },
  [WalletApiOperation.ListGlobalCurrencyAuditors]: {
    codec: codecForEmptyObject(),
    handler: handleListGlobalCurrencyAuditors,
  },
  [WalletApiOperation.AddGlobalCurrencyExchange]: {
    codec: codecForAddGlobalCurrencyExchangeRequest(),
    handler: handleAddGlobalCurrencyExchange,
  },
  [WalletApiOperation.RemoveGlobalCurrencyExchange]: {
    codec: codecForRemoveGlobalCurrencyExchangeRequest(),
    handler: handleRemoveGlobalCurrencyExchange,
  },
  [WalletApiOperation.AddGlobalCurrencyAuditor]: {
    codec: codecForAddGlobalCurrencyAuditorRequest(),
    handler: handleAddGlobalCurrencyAuditor,
  },
  [WalletApiOperation.TestingWaitTasksDone]: {
    codec: codecForEmptyObject(),
    handler: async (wex, req) => {
      await waitTasksDone(wex);
      return {};
    },
  },
  [WalletApiOperation.TestingResetAllRetries]: {
    codec: codecForEmptyObject(),
    handler: async (wex, req) => {
      await retryAll(wex);
      return {};
    },
  },
  [WalletApiOperation.RemoveGlobalCurrencyAuditor]: {
    codec: codecForRemoveGlobalCurrencyAuditorRequest(),
    handler: handleRemoveGlobalCurrencyAuditor,
  },
  [WalletApiOperation.ImportDb]: {
    codec: codecForImportDbRequest(),
    handler: handleImportDb,
  },
  [WalletApiOperation.ImportDbFromFile]: {
    codec: codecForImportDbFromFileRequest(),
    handler: handleImportDbFromFile,
  },
  [WalletApiOperation.CheckPeerPushDebit]: {
    codec: codecForCheckPeerPushDebitRequest(),
    handler: checkPeerPushDebit,
  },
  [WalletApiOperation.CheckPeerPushDebitV2]: {
    codec: codecForCheckPeerPushDebitRequest(),
    handler: checkPeerPushDebitV2,
  },
  [WalletApiOperation.InitiatePeerPushDebit]: {
    codec: codecForInitiatePeerPushDebitRequest(),
    handler: initiatePeerPushDebit,
  },
  [WalletApiOperation.PreparePeerPushCredit]: {
    codec: codecForPreparePeerPushCreditRequest(),
    handler: preparePeerPushCredit,
  },
  [WalletApiOperation.ConfirmPeerPushCredit]: {
    codec: codecForConfirmPeerPushPaymentRequest(),
    handler: confirmPeerPushCredit,
  },
  [WalletApiOperation.CheckPeerPullCredit]: {
    codec: codecForPreparePeerPullPaymentRequest(),
    handler: checkPeerPullCredit,
  },
  [WalletApiOperation.InitiatePeerPullCredit]: {
    codec: codecForInitiatePeerPullPaymentRequest(),
    handler: initiatePeerPullPayment,
  },
  [WalletApiOperation.PreparePeerPullDebit]: {
    codec: codecForCheckPeerPullPaymentRequest(),
    handler: preparePeerPullDebit,
  },
  [WalletApiOperation.ConfirmPeerPullDebit]: {
    codec: codecForAcceptPeerPullPaymentRequest(),
    handler: confirmPeerPullDebit,
  },
  [WalletApiOperation.ApplyDevExperiment]: {
    codec: codecForApplyDevExperiment(),
    handler: async (wex, req) => {
      await applyDevExperiment(wex, req.devExperimentUri);
      return {};
    },
  },
  [WalletApiOperation.Shutdown]: {
    codec: codecForEmptyObject(),
    handler: handleShutdown,
  },
  [WalletApiOperation.GetVersion]: {
    codec: codecForEmptyObject(),
    handler: handleGetVersion,
  },
  [WalletApiOperation.TestingWaitTransactionsFinal]: {
    codec: codecForEmptyObject(),
    handler: async (wex, req) => {
      await waitUntilAllTransactionsFinal(wex);
      return {};
    },
  },
  [WalletApiOperation.TestingWaitRefreshesFinal]: {
    codec: codecForEmptyObject(),
    handler: async (wex, req) => {
      await waitUntilRefreshesDone(wex);
      return {};
    },
  },
  [WalletApiOperation.TestingSetTimetravel]: {
    codec: codecForTestingSetTimetravelRequest(),
    handler: async (wex, req) => {
      await handleTestingSetTimetravel(wex, req);
      return {};
    },
  },
  [WalletApiOperation.DeleteExchange]: {
    codec: codecForDeleteExchangeRequest(),
    handler: handleDeleteExchange,
  },
  [WalletApiOperation.GetExchangeResources]: {
    codec: codecForGetExchangeResourcesRequest(),
    handler: async (wex, req) => {
      return await getExchangeResources(wex, req.exchangeBaseUrl);
    },
  },
  [WalletApiOperation.CanonicalizeBaseUrl]: {
    codec: codecForCanonicalizeBaseUrlRequest(),
    handler: handleCanonicalizeBaseUrl,
  },
  [WalletApiOperation.ForceRefresh]: {
    codec: codecForForceRefreshRequest(),
    handler: async (wex, req) => {
      await forceRefresh(wex, req);
      return {};
    },
  },
  [WalletApiOperation.ExportBackup]: {
    codec: codecForAny(),
    handler: async (wex, req) => {
      throw Error("not implemented");
    },
  },
  [WalletApiOperation.ListAssociatedRefreshes]: {
    codec: codecForAny(),
    handler: async (wex, req) => {
      throw Error("not implemented");
    },
  },
  [WalletApiOperation.GetBankingChoicesForPayto]: {
    codec: codecForGetBankingChoicesForPaytoRequest(),
    handler: handleGetBankingChoicesForPayto,
  },
  [WalletApiOperation.StartExchangeWalletKyc]: {
    codec: codecForStartExchangeWalletKycRequest(),
    handler: handleStartExchangeWalletKyc,
  },
  [WalletApiOperation.TestingWaitExchangeWalletKyc]: {
    codec: codecForTestingWaitWalletKycRequest(),
    handler: handleTestingWaitExchangeWalletKyc,
  },
  [WalletApiOperation.TestingPlanMigrateExchangeBaseUrl]: {
    codec: codecForTestingPlanMigrateExchangeBaseUrlRequest(),
    handler: handleTestingPlanMigrateExchangeBaseUrl,
  },
};

/**
 * Implementation of the "wallet-core" API.
 */
async function dispatchRequestInternal(
  wex: WalletExecutionContext,
  operation: WalletApiOperation,
  payload: unknown,
): Promise<WalletCoreResponseType<typeof operation>> {
  if (!wex.ws.initCalled && operation !== WalletApiOperation.InitWallet) {
    throw Error(
      `wallet must be initialized before running operation ${operation}`,
    );
  }

  const h: HandlerWithValidator<any> = handlers[operation];

  if (!h) {
    throw TalerError.fromDetail(
      TalerErrorCode.WALLET_CORE_API_OPERATION_UNKNOWN,
      {
        operation,
      },
      "unknown operation",
    );
  }

  const req = h.codec.decode(payload);
  return await h.handler(wex, req);
}

export function getObservedWalletExecutionContext(
  ws: InternalWalletState,
  cancellationToken: CancellationToken,
  cts: CancellationToken.Source | undefined,
  oc: ObservabilityContext,
): WalletExecutionContext {
  const db = ws.createDbAccessHandle(cancellationToken);
  const wex: WalletExecutionContext = {
    ws,
    cancellationToken,
    cts,
    cryptoApi: observeTalerCrypto(ws.cryptoApi, oc),
    db: new ObservableDbAccess(db, oc),
    http: new ObservableHttpClientLibrary(ws.http, oc),
    taskScheduler: new ObservableTaskScheduler(ws.taskScheduler, oc),
    oc,
  };
  return wex;
}

export function getNormalWalletExecutionContext(
  ws: InternalWalletState,
  cancellationToken: CancellationToken,
  cts: CancellationToken.Source | undefined,
  oc: ObservabilityContext,
): WalletExecutionContext {
  const db = ws.createDbAccessHandle(cancellationToken);
  const wex: WalletExecutionContext = {
    ws,
    cancellationToken,
    cts,
    cryptoApi: ws.cryptoApi,
    db,
    get http() {
      if (ws.initCalled) {
        return ws.http;
      }
      throw Error("wallet not initialized");
    },
    taskScheduler: ws.taskScheduler,
    oc,
  };
  return wex;
}

/**
 * Handle a request to the wallet-core API.
 */
async function dispatchWalletCoreApiRequest(
  ws: InternalWalletState,
  operation: string,
  id: string,
  payload: unknown,
): Promise<CoreApiResponse> {
  if (operation !== WalletApiOperation.InitWallet) {
    if (!ws.initCalled) {
      throw Error("init must be called first");
    }
  }

  await ws.ensureWalletDbOpen();

  let wex: WalletExecutionContext;
  let oc: ObservabilityContext;

  const cts = CancellationToken.create();

  if (ws.initCalled && ws.config.testing.emitObservabilityEvents) {
    oc = {
      observe(evt) {
        ws.notify({
          type: NotificationType.RequestObservabilityEvent,
          operation,
          requestId: id,
          event: evt,
        });
      },
    };

    wex = getObservedWalletExecutionContext(ws, cts.token, cts, oc);
  } else {
    oc = {
      observe(evt) {},
    };
    wex = getNormalWalletExecutionContext(ws, cts.token, cts, oc);
  }

  try {
    const start = performanceNow();
    await ws.ensureWalletDbOpen();
    oc.observe({
      type: ObservabilityEventType.RequestStart,
    });
    const result = await dispatchRequestInternal(
      wex,
      operation as any,
      payload,
    );
    const end = performanceNow();
    oc.observe({
      type: ObservabilityEventType.RequestFinishSuccess,
      durationMs: Number((end - start) / 1000n / 1000n),
    });
    return {
      type: "response",
      operation,
      id,
      result,
    };
  } catch (e: any) {
    const err = getErrorDetailFromException(e);
    logger.info(
      `finished wallet core request ${operation} with error: ${j2s(err)}`,
    );
    oc.observe({
      type: ObservabilityEventType.RequestFinishError,
    });
    return {
      type: "error",
      operation,
      id,
      error: err,
    };
  }
}

function applyRunConfigDefaults(wcp?: PartialWalletRunConfig): WalletRunConfig {
  if (wcp?.features?.allowHttp != null) {
    logger.warn(`allowHttp flag not supported anymore`);
  }
  return {
    builtin: {
      exchanges: wcp?.builtin?.exchanges ?? [
        {
          exchangeBaseUrl: "https://exchange.demo.taler.net/",
          currencyHint: "KUDOS",
        },
      ],
    },
    features: {
      allowHttp: true,
      enableV1Contracts: wcp?.features?.enableV1Contracts ?? false,
    },
    testing: {
      devModeActive: wcp?.testing?.devModeActive ?? false,
      insecureTrustExchange: wcp?.testing?.insecureTrustExchange ?? false,
      preventThrottling: wcp?.testing?.preventThrottling ?? false,
      skipDefaults: wcp?.testing?.skipDefaults ?? false,
      emitObservabilityEvents: wcp?.testing?.emitObservabilityEvents ?? false,
    },
    lazyTaskLoop: wcp?.lazyTaskLoop ?? false,
    logLevel: wcp?.logLevel ?? "INFO",
  };
}

export type HttpFactory = (config: WalletRunConfig) => HttpRequestLibrary;

export interface WalletDatabaseImplementation {
  idbFactory: BridgeIDBFactory;
  getStats: () => AccessStats;
  exportToFile: (
    directory: string,
    stem: string,
    forceFormat?: string,
  ) => Promise<{ path: string }>;
  readBackupJson(path: string): Promise<any>;
}

/**
 * Public handle to a running wallet.
 */
export class Wallet {
  private ws: InternalWalletState;
  private _client: WalletCoreApiClient | undefined;

  private constructor(
    dbImplementation: WalletDatabaseImplementation,
    httpFactory: HttpFactory,
    timer: TimerAPI,
    cryptoWorkerFactory: CryptoWorkerFactory,
  ) {
    this.ws = new InternalWalletState(
      dbImplementation,
      httpFactory,
      timer,
      cryptoWorkerFactory,
    );
  }

  get client(): WalletCoreApiClient {
    if (!this._client) {
      throw Error();
    }
    return this._client;
  }

  static async create(
    dbImplementation: WalletDatabaseImplementation,
    httpFactory: HttpFactory,
    timer: TimerAPI,
    cryptoWorkerFactory: CryptoWorkerFactory,
  ): Promise<Wallet> {
    const w = new Wallet(
      dbImplementation,
      httpFactory,
      timer,
      cryptoWorkerFactory,
    );
    w._client = await getClientFromWalletState(w.ws);
    return w;
  }

  addNotificationListener(f: (n: WalletNotification) => void): CancelFn {
    return this.ws.addNotificationListener(f);
  }

  async handleCoreApiRequest(
    operation: string,
    id: string,
    payload: unknown,
  ): Promise<CoreApiResponse> {
    await this.ws.ensureWalletDbOpen();
    return dispatchWalletCoreApiRequest(this.ws, operation, id, payload);
  }
}

export interface DevExperimentState {
  blockRefreshes?: boolean;
  /** Pretend that exchanges have no fees.*/
  pretendNoFees?: boolean;
  /** Pretend exchange has no withdrawable denoms. */
  pretendNoDenoms?: boolean;
  pretendPostWopFailed?: boolean;
  merchantDepositInsufficient?: boolean;
  /** Map from base URL to faked version for /config or /keys */
  fakeProtoVer?: Map<
    string,
    {
      fakeVer: string;
    }
  >;

  blockPayResponse?: boolean;

  /** Migration test for confirmPay */
  flagConfirmPayNoWait?: boolean;
}

export class Cache<T> {
  private map: Map<string, [AbsoluteTime, T]> = new Map();

  constructor(
    private maxCapacity: number,
    private cacheDuration: Duration,
  ) {}

  get(key: string): T | undefined {
    const r = this.map.get(key);
    if (!r) {
      return undefined;
    }

    if (AbsoluteTime.isExpired(r[0])) {
      this.map.delete(key);
      return undefined;
    }

    return r[1];
  }

  async getOrPut(key: string, lambda: () => Promise<T>): Promise<T>;
  async getOrPut(
    key: string,
    lambda: () => Promise<T | undefined>,
  ): Promise<T | undefined>;
  async getOrPut(
    key: string,
    lambda: () => Promise<T | undefined>,
  ): Promise<T | undefined> {
    const cached = this.get(key);
    if (cached != null) {
      return cached;
    } else {
      const computed = await lambda();
      if (computed != null) {
        this.put(key, computed);
      }
      return computed;
    }
  }

  clear(): void {
    this.map.clear();
  }

  put(key: string, value: T): void {
    if (this.map.size > this.maxCapacity) {
      this.map.clear();
    }
    const expiry = AbsoluteTime.addDuration(
      AbsoluteTime.now(),
      this.cacheDuration,
    );
    this.map.set(key, [expiry, value]);
  }
}

/**
 * Implementation of triggers for the wallet DB.
 */
class WalletDbTriggerSpec implements TriggerSpec {
  constructor(public ws: InternalWalletState) {}

  afterCommit(info: AfterCommitInfo): void {
    if (info.mode !== "readwrite") {
      return;
    }
    logger.trace(
      `in after commit callback for readwrite, modified ${j2s([
        ...info.modifiedStores,
      ])}`,
    );
    const modified = info.accessedStores;
    if (
      modified.has(WalletStoresV1.exchanges.storeName) ||
      modified.has(WalletStoresV1.exchangeDetails.storeName) ||
      modified.has(WalletStoresV1.denominations.storeName) ||
      modified.has(WalletStoresV1.globalCurrencyAuditors.storeName) ||
      modified.has(WalletStoresV1.globalCurrencyExchanges.storeName)
    ) {
      this.ws.clearAllCaches();
    }
  }
}

/**
 * Internal state of the wallet.
 *
 * This ties together all the operation implementations.
 */
export class InternalWalletState {
  cryptoApi: TalerCryptoInterface;
  private cryptoDispatcher: CryptoDispatcher;

  readonly timerGroup: TimerGroup;
  stopped = false;

  private listeners: NotificationListener[] = [];

  initCalled = false;

  refreshCostCache: Cache<AmountJson> = new Cache(
    1000,
    Duration.fromSpec({ minutes: 1 }),
  );

  denomInfoCache: Cache<DenominationInfo> = new Cache(
    1000,
    Duration.fromSpec({ minutes: 1 }),
  );

  exchangeCache: Cache<ReadyExchangeSummary> = new Cache(
    1000,
    Duration.fromSpec({ minutes: 1 }),
  );

  /**
   * Promises that are waiting for a particular resource.
   */
  private resourceWaiters: Record<string, OpenedPromise<void>[]> = {};

  /**
   * Resources that are currently locked.
   */
  private resourceLocks: Set<string> = new Set();

  taskScheduler: TaskScheduler = new TaskSchedulerImpl(this);

  private _config: Readonly<WalletRunConfig> | undefined;

  private _indexedDbHandle: IDBDatabase | undefined = undefined;

  private _dbAccessHandle: DbAccess<typeof WalletStoresV1> | undefined;

  private _http: HttpRequestLibrary | undefined = undefined;

  devExperimentState: DevExperimentState = {};

  clientCancellationMap: Map<string, CancellationToken.Source> = new Map();

  longpollQueue = new LongpollQueue();

  private loadingDb: boolean = false;

  private loadingDbCond: AsyncCondition = new AsyncCondition();

  public get idbFactory(): BridgeIDBFactory {
    return this.dbImplementation.idbFactory;
  }

  /**
   * Planned exchange migrations.
   * Maps the old exchange base URL to a new one.
   */
  exchangeMigrationPlan: Map<
    string,
    {
      newExchangeBaseUrl: string;
    }
  > = new Map();

  get db(): DbAccess<typeof WalletStoresV1> {
    if (!this._dbAccessHandle) {
      this._dbAccessHandle = this.createDbAccessHandle(
        CancellationToken.CONTINUE,
      );
    }
    return this._dbAccessHandle;
  }

  /**
   * When set to false, all tasks that require network will be stopped and
   * retried until connection is restored.
   *
   * Set to true by default for compatibility with clients that don't hint
   * network availability via hintNetworkAvailability.
   */
  private _networkAvailable = true;

  get networkAvailable(): boolean {
    return this._networkAvailable;
  }

  set networkAvailable(status: boolean) {
    this._networkAvailable = status;
  }

  /**
   * Reference counter for active integration tests
   * that ignore ToS acceptance rules.
   */
  refcntIgnoreTos: number = 0;

  clearAllCaches(): void {
    this.exchangeCache.clear();
    this.denomInfoCache.clear();
    this.refreshCostCache.clear();
  }

  initWithConfig(newConfig: WalletRunConfig): void {
    this._config = newConfig;

    this._http = this.httpFactory(newConfig);

    if (this.config.testing.devModeActive) {
      logger.warn("using dev experiment http lib");
      this._http = new DevExperimentHttpLib(this.http, this.devExperimentState);
    }
  }

  createDbAccessHandle(
    cancellationToken: CancellationToken,
  ): DbAccess<typeof WalletStoresV1> {
    if (!this._indexedDbHandle) {
      throw Error("db not initialized");
    }
    const iws = this;
    return new DbAccessImpl(
      this._indexedDbHandle,
      WalletStoresV1,
      new WalletDbTriggerSpec(this),
      cancellationToken,
      (notifs: WalletNotification[]): void => {
        for (const notif of notifs) {
          iws.notify(notif);
        }
      },
    );
  }

  get config(): WalletRunConfig {
    if (!this._config) {
      throw Error("config not initialized");
    }
    return this._config;
  }

  get http(): HttpRequestLibrary {
    if (!this._http) {
      throw Error("wallet not initialized");
    }
    return this._http;
  }

  constructor(
    public dbImplementation: WalletDatabaseImplementation,
    private httpFactory: HttpFactory,
    timer: TimerAPI,
    cryptoWorkerFactory: CryptoWorkerFactory,
  ) {
    this.cryptoDispatcher = new CryptoDispatcher(cryptoWorkerFactory);
    this.cryptoApi = this.cryptoDispatcher.cryptoApi;
    this.timerGroup = new TimerGroup(timer);
    // Migration record used for testing with sandcastle or local deployment.
    this.exchangeMigrationPlan.set("http://exchange.taler.localhost:4321/", {
      newExchangeBaseUrl: "http://exchange.taler2.localhost:4321/",
    });
    // Prod migration (BFH)
    this.exchangeMigrationPlan.set("https://exchange.chf.taler.net/", {
      newExchangeBaseUrl: "https://exchange.taler.ti.bfh.ch/",
    });
    // Prod migration (GLS)
    this.exchangeMigrationPlan.set("https://exchange.glstest.taler.net/", {
      newExchangeBaseUrl: "https://test.exchange.gls.de/",
    });
  }

  async ensureWalletDbOpen(): Promise<void> {
    if (this._indexedDbHandle) {
      return;
    }
    if (this.loadingDb) {
      while (this.loadingDb) {
        await this.loadingDbCond.wait();
      }
    }
    this.loadingDb = true;
    const myVersionChange = async (): Promise<void> => {
      logger.info("version change requested for Taler DB");
    };
    try {
      const myDb = await openTalerDatabase(this.idbFactory, myVersionChange);
      this._indexedDbHandle = myDb;
      const dbAccess = this.createDbAccessHandle(CancellationToken.CONTINUE);
      const count = await applyFixups(dbAccess);
      if (count > 0) {
        const oc = {
          observe(evt: any) {},
        };
        const wex = getNormalWalletExecutionContext(
          this,
          CancellationToken.CONTINUE,
          undefined,
          oc,
        );
        await dbAccess.runAllStoresReadWriteTx({}, async (tx) => {
          await rematerializeTransactions(wex, tx);
        });
      }
    } catch (e) {
      logger.error("error writing to database during initialization");
      throw TalerError.fromDetail(TalerErrorCode.WALLET_DB_UNAVAILABLE, {
        innerError: getErrorDetailFromException(e),
      });
    } finally {
      this.loadingDb = false;
      this.loadingDbCond.trigger();
    }
  }

  /**
   * Prepare database for import by closing it.
   */
  async suspendDatabase(): Promise<void> {
    if (this.loadingDb) {
      while (this.loadingDb) {
        await this.loadingDbCond.wait();
      }
    }
    this.loadingDb = true;
    const dbh = this._indexedDbHandle;
    if (!dbh) {
      return;
    }
    this._indexedDbHandle = undefined;
    return new Promise((resolve, reject) => {
      dbh.addEventListener("close", () => {
        resolve();
      });
      dbh.close();
    });
  }

  /**
   * Resume database by re-opening it.
   */
  async resumeDatabase(): Promise<void> {
    this.loadingDb = false;
    this.loadingDbCond.trigger();
    await this.ensureWalletDbOpen();
  }

  notify(n: WalletNotification): void {
    logger.trace(`Notification: ${j2s(n)}`);
    for (const l of this.listeners) {
      const nc = JSON.parse(JSON.stringify(n));
      setTimeout(() => {
        l(nc);
      }, 0);
    }
  }

  addNotificationListener(f: (n: WalletNotification) => void): CancelFn {
    this.listeners.push(f);
    return () => {
      const idx = this.listeners.indexOf(f);
      if (idx >= 0) {
        this.listeners.splice(idx, 1);
      }
    };
  }

  /**
   * Stop ongoing processing.
   */
  stop(): void {
    logger.trace("stopping (at internal wallet state)");
    this.stopped = true;
    this.timerGroup.stopCurrentAndFutureTimers();
    this.cryptoDispatcher.stop();
    this.taskScheduler.shutdown().catch((e) => {
      logger.warn(`shutdown failed: ${safeStringifyException(e)}`);
    });
  }

  /**
   * Run an async function after acquiring a list of locks, identified
   * by string tokens.
   */
  async runSequentialized<T>(
    tokens: string[],
    f: () => Promise<T>,
  ): Promise<T> {
    // Make sure locks are always acquired in the same order
    tokens = [...tokens].sort();

    for (const token of tokens) {
      if (this.resourceLocks.has(token)) {
        const p = openPromise<void>();
        let waitList = this.resourceWaiters[token];
        if (!waitList) {
          waitList = this.resourceWaiters[token] = [];
        }
        waitList.push(p);
        await p.promise;
      }
      this.resourceLocks.add(token);
    }

    try {
      logger.trace(`begin exclusive execution on ${JSON.stringify(tokens)}`);
      const result = await f();
      logger.trace(`end exclusive execution on ${JSON.stringify(tokens)}`);
      return result;
    } finally {
      for (const token of tokens) {
        this.resourceLocks.delete(token);
        const waiter = (this.resourceWaiters[token] ?? []).shift();
        if (waiter) {
          waiter.resolve();
        }
      }
    }
  }
}
