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

/**
 * Imports.
 */
import {
  Event,
  IDBDatabase,
  IDBFactory,
  IDBObjectStore,
  IDBRequest,
  IDBTransaction,
  structuredEncapsulate,
  structuredRevive,
} from "@gnu-taler/idb-bridge";
import {
  AbsoluteTime,
  AccountLimit,
  AgeCommitmentProof,
  AmountString,
  Amounts,
  AttentionInfo,
  BackupProviderTerms,
  BlindedUniqueDonationIdentifier,
  CancellationToken,
  Codec,
  CoinEnvelope,
  CoinPublicKeyString,
  CoinRefreshRequest,
  CoinStatus,
  CurrencySpecification,
  DenomLossEventType,
  DenomSelectionState,
  DenominationInfo,
  DenominationPubKey,
  DonationReceiptSignature,
  EddsaPublicKeyString,
  EddsaSignatureString,
  ExchangeAuditor,
  ExchangeGlobalFees,
  ExchangeRefundRequest,
  HashCodeString,
  Logger,
  MailboxConfiguration,
  MerchantContractTokenDetails,
  MerchantContractTokenKind,
  RefreshReason,
  ScopeInfo,
  SignedTokenEnvelope,
  TalerErrorDetail,
  TalerPreciseTimestamp,
  TalerProtocolDuration,
  TalerProtocolTimestamp,
  TokenEnvelope,
  TokenIssuePublicKey,
  TokenUseSig,
  TransactionIdStr,
  UnblindedDenominationSignature,
  WireInfo,
  WithdrawalExchangeAccountDetails,
  ZeroLimitedOperation,
  canonicalJson,
  codecForAny,
  encodeCrock,
  hash,
  j2s,
  stringToBytes,
  stringifyScopeInfo,
  MailboxMessageRecord,
} from "@gnu-taler/taler-util";
import { DbRetryInfo, TaskIdentifiers } from "./common.js";
import {
  DbAccess,
  DbAccessImpl,
  DbReadOnlyTransaction,
  DbReadWriteTransaction,
  IndexDescriptor,
  StoreDescriptor,
  StoreNames,
  StoreWithIndexes,
  describeContents,
  describeIndex,
  describeStore,
  describeStoreV2,
  openDatabase,
} from "./query.js";

/**
 * This file contains the database schema of the Taler wallet together
 * with some helper functions.
 *
 * Some design considerations:
 * - By convention, each object store must have a corresponding "<Name>Record"
 *   interface defined for it.
 * - For records that represent operations, there should be exactly
 *   one top-level enum field that indicates the status of the operation.
 *   This field should be present even if redundant, because the field
 *   will have an index.
 * - Amounts are stored as strings, except when they are needed for
 *   indexing.
 * - Every record that has a corresponding transaction item must have
 *   an index for a mandatory timestamp field.
 * - Optional fields should be avoided, use "T | undefined" instead.
 * - Do all records have some obvious, indexed field that can
 *   be used for range queries?
 *
 * @author Florian Dold <dold@taler.net>
 */

/**
 FIXMEs:
 - Contract terms can be quite large.  We currently tend to read the
   full contract terms from the DB quite often.
   Instead, we should probably extract what we need into a separate object
   store.
 - More object stores should have an "id" primary key,
   as this makes referencing less expensive.
 - Coin selections should probably go into a separate object store.
 - Some records should be split up into an extra "details" record
   that we don't always need to iterate over.
 */

/**
 * Name of the Taler database.  This is effectively the major
 * version of the DB schema. Whenever it changes, custom import logic
 * for all previous versions must be written, which should be
 * avoided.
 */
export const TALER_WALLET_MAIN_DB_NAME = "taler-wallet-main-v10";

/**
 * Name of the metadata database.  This database is used
 * to track major migrations of the main Taler database.
 *
 * (Minor migrations are handled via upgrade transactions.)
 */
export const TALER_WALLET_META_DB_NAME = "taler-wallet-meta";

/**
 * Name of the "stored backups" database.
 * Stored backups are created before manually importing a backup.
 * We use IndexedDB for this purpose, since we don't have file system
 * access on some platforms.
 */
export const TALER_WALLET_STORED_BACKUPS_DB_NAME =
  "taler-wallet-stored-backups";

/**
 * Name of the "meta config" database.
 */
export const CURRENT_DB_CONFIG_KEY = "currentMainDbName";

/**
 * Current database minor version, should be incremented
 * each time we do minor schema changes on the database.
 * A change is considered minor when fields are added in a
 * backwards-compatible way or object stores and indices
 * are added.
 */
export const WALLET_DB_MINOR_VERSION = 24;

declare const symDbProtocolTimestamp: unique symbol;

declare const symDbPreciseTimestamp: unique symbol;

/**
 * Timestamp, stored as microseconds.
 *
 * Always rounded to a full second.
 */
export type DbProtocolTimestamp = number & { [symDbProtocolTimestamp]: true };

/**
 * Timestamp, stored as microseconds.
 */
export type DbPreciseTimestamp = number & { [symDbPreciseTimestamp]: true };

const DB_TIMESTAMP_FOREVER = Number.MAX_SAFE_INTEGER;

export function timestampPreciseFromDb(
  dbTs: DbPreciseTimestamp,
): TalerPreciseTimestamp {
  return TalerPreciseTimestamp.fromMilliseconds(Math.floor(dbTs / 1000));
}

export function timestampOptionalPreciseFromDb(
  dbTs: DbPreciseTimestamp | undefined,
): TalerPreciseTimestamp | undefined {
  if (!dbTs) {
    return undefined;
  }
  return TalerPreciseTimestamp.fromMilliseconds(Math.floor(dbTs / 1000));
}

export function timestampPreciseToDb(
  stamp: TalerPreciseTimestamp,
): DbPreciseTimestamp {
  if (stamp.t_s === "never") {
    return DB_TIMESTAMP_FOREVER as DbPreciseTimestamp;
  } else {
    let tUs = stamp.t_s * 1000000;
    if (stamp.off_us) {
      tUs += stamp.off_us;
    }
    return tUs as DbPreciseTimestamp;
  }
}

export function timestampProtocolToDb(
  stamp: TalerProtocolTimestamp,
): DbProtocolTimestamp {
  if (stamp.t_s === "never") {
    return DB_TIMESTAMP_FOREVER as DbProtocolTimestamp;
  } else {
    let tUs = stamp.t_s * 1000000;
    return tUs as DbProtocolTimestamp;
  }
}

export function timestampProtocolFromDb(
  stamp: DbProtocolTimestamp,
): TalerProtocolTimestamp {
  return TalerProtocolTimestamp.fromSeconds(Math.floor(stamp / 1000000));
}

export function timestampAbsoluteFromDb(
  stamp: DbProtocolTimestamp | DbPreciseTimestamp,
): AbsoluteTime {
  if (stamp >= DB_TIMESTAMP_FOREVER) {
    return AbsoluteTime.never();
  }
  return AbsoluteTime.fromMilliseconds(Math.floor(stamp / 1000));
}

export function timestampOptionalAbsoluteFromDb(
  stamp: DbProtocolTimestamp | DbPreciseTimestamp | undefined,
): AbsoluteTime | undefined {
  if (stamp == null) {
    return undefined;
  }
  if (stamp >= DB_TIMESTAMP_FOREVER) {
    return AbsoluteTime.never();
  }
  return AbsoluteTime.fromMilliseconds(Math.floor(stamp / 1000));
}

/**
 * Format of the operation status code: 0x0abc_nnnn

 * a=1: active
 * 0x0100_nnnn: pending
 * 0x0101_nnnn: dialog
 * 0x0102_nnnn: (reserved)
 * 0x0103_nnnn: aborting
 * 0x0110_nnnn: suspended
 * 0x0113_nnnn: suspended-aborting
 * a=2: finalizing
 * 0x0200_nnnn: finalizing
 * 0x0210_nnnn: suspended-finalizing
 * a=5: final
 * 0x0500_nnnn: done
 * 0x0501_nnnn: failed
 * 0x0502_nnnn: expired
 * 0x0503_nnnn: aborted
 * 
 * nnnn=0000 should always be the most generic minor state for the major state
 */

/**
 * First possible operation status in the active range (inclusive).
 */
export const OPERATION_STATUS_NONFINAL_FIRST = 0x0100_0000;

/**
 * LAST possible operation status in the active range (inclusive).
 */
export const OPERATION_STATUS_NONFINAL_LAST = 0x0210_ffff;

export const OPERATION_STATUS_DONE_FIRST = 0x0500_0000;
export const OPERATION_STATUS_DONE_LAST = 0x0500_ffff;

/**
 * Status of a withdrawal.
 */
export enum WithdrawalGroupStatus {
  /**
   * Reserve must be registered with the bank.
   */
  PendingRegisteringBank = 0x0100_0001,
  SuspendedRegisteringBank = 0x0110_0001,

  /**
   * We've registered reserve's information with the bank
   * and are now waiting for the user to confirm the withdraw
   * with the bank (typically 2nd factor auth).
   */
  PendingWaitConfirmBank = 0x0100_0002,
  SuspendedWaitConfirmBank = 0x0110_0002,

  /**
   * Querying reserve status with the exchange.
   */
  PendingQueryingStatus = 0x0100_0003,
  SuspendedQueryingStatus = 0x0110_0003,

  /**
   * Ready for withdrawal.
   */
  PendingReady = 0x0100_0004,
  SuspendedReady = 0x0110_0004,

  /**
   * Redenominate the withdrawal
   * after the exchange entry is ready again.
   */
  PendingRedenominate = 0x0100_0008,
  SuspendedRedenominate = 0x0110_0008,

  /**
   * Exchange wants KYC info from the user.
   */
  PendingKyc = 0x0100_0005,
  SuspendedKyc = 0x0110_005,

  /**
   * Exchange wants KYC info from the user.
   * KYC link is ready.
   */
  PendingBalanceKyc = 0x0100_0006,
  SuspendedBalanceKyc = 0x0110_006,

  /**
   * Exchange wants KYC info from the user.
   *
   * KYC link is not ready yet, the KYC process is still initializing.
   */
  PendingBalanceKycInit = 0x0100_0007,
  SuspendedBalanceKycInit = 0x0110_007,

  /**
   * Proposed to the user, has can choose to accept/refuse.
   */
  DialogProposed = 0x0101_0000,

  /**
   * We are telling the bank that we don't want to complete
   * the withdrawal!
   */
  AbortingBank = 0x0103_0001,
  SuspendedAbortingBank = 0x0113_0001,

  /**
   * The corresponding withdraw record has been created.
   * No further processing is done, unless explicitly requested
   * by the user.
   */
  Done = 0x0500_0000,

  /**
   * The bank aborted the withdrawal.
   */
  FailedBankAborted = 0x0501_0001,

  FailedAbortingBank = 0x0501_0002,

  /**
   * Aborted in a state where we were supposed to
   * talk to the exchange.  Money might have been
   * wired or not.
   */
  AbortedExchange = 0x0503_0001,

  AbortedBank = 0x0503_0002,

  /**
   * User didn't refused the withdrawal.
   */
  AbortedUserRefused = 0x0503_0003,

  /**
   * Another wallet confirmed the withdrawal
   * (by POSTing the reserve pub to the bank)
   * before we had the chance.
   *
   * In this situation, we'll let the other wallet continue
   * and give up ourselves.
   */
  AbortedOtherWallet = 0x0503_0004,
}

/**
 * Extra info about a withdrawal that is used
 * with a bank-integrated withdrawal.
 */
export interface ReserveBankInfo {
  talerWithdrawUri: string;

  /**
   * URL that the user can be redirected to, and allows
   * them to confirm (or abort) the bank-integrated withdrawal.
   */
  confirmUrl: string | undefined;

  /**
   * Exchange payto URI that the bank will use to fund the reserve.
   */
  exchangePaytoUri?: string;

  /**
   * Time when the information about this reserve was posted to the bank.
   *
   * Only applies if bankWithdrawStatusUrl is defined.
   *
   * Set to undefined if that hasn't happened yet.
   */
  timestampReserveInfoPosted: DbPreciseTimestamp | undefined;

  /**
   * Time when the reserve was confirmed by the bank.
   *
   * Set to undefined if not confirmed yet.
   */
  timestampBankConfirmed: DbPreciseTimestamp | undefined;

  wireTypes: string[] | undefined;

  currency: string | undefined;

  externalConfirmation?: boolean;

  senderWire?: string;
}

/**
 * Status of a denomination.
 */
export enum DenominationVerificationStatus {
  /**
   * Verification was delayed (pending).
   */
  Unverified = 0x0100_0000,

  /**
   * Verified as valid.
   */
  VerifiedGood = 0x0500_0000,

  /**
   * Verified as invalid.
   */
  VerifiedBad = 0x0501_0000,
}

export interface DenomFees {
  /**
   * Fee for withdrawing.
   */
  feeWithdraw: AmountString;

  /**
   * Fee for depositing.
   */
  feeDeposit: AmountString;

  /**
   * Fee for refreshing.
   */
  feeRefresh: AmountString;

  /**
   * Fee for refunding.
   */
  feeRefund: AmountString;
}

/**
 * Denomination record as stored in the wallet's database.
 */
export interface DenominationRecord {
  /**
   * Currency of the denomination.
   *
   * Stored separately as we have an index on it.
   */
  currency: string;

  value: AmountString;

  /**
   * The denomination public key.
   */
  denomPub: DenominationPubKey;

  /**
   * Hash of the denomination public key.
   * Stored in the database for faster lookups.
   */
  denomPubHash: string;

  fees: DenomFees;

  /**
   * Validity start date of the denomination.
   */
  stampStart: DbProtocolTimestamp;

  /**
   * Date after which the currency can't be withdrawn anymore.
   */
  stampExpireWithdraw: DbProtocolTimestamp;

  /**
   * Date after the denomination officially doesn't exist anymore.
   */
  stampExpireLegal: DbProtocolTimestamp;

  /**
   * Data after which coins of this denomination can't be deposited anymore.
   */
  stampExpireDeposit: DbProtocolTimestamp;

  /**
   * Signature by the exchange's master key over the denomination
   * information.
   */
  masterSig: string;

  /**
   * Did we verify the signature on the denomination?
   */
  verificationStatus: DenominationVerificationStatus;

  /**
   * Was this denomination still offered by the exchange the last time
   * we checked?
   * Only false when the exchange redacts a previously published denomination.
   */
  isOffered: boolean;

  /**
   * Did the exchange revoke the denomination?
   * When this field is set to true in the database, the same transaction
   * should also mark all affected coins as revoked.
   */
  isRevoked: boolean;

  /**
   * If set to true, the exchange announced that the private key for this
   * denomination is lost.  Thus it can't be used to sign new coins
   * during withdrawal/refresh/..., but the coins can still be spent.
   */
  isLost?: boolean;

  /**
   * Base URL of the exchange.
   */
  exchangeBaseUrl: string;

  /**
   * Master public key of the exchange that made the signature
   * on the denomination.
   */
  exchangeMasterPub: string;
}

export namespace DenominationRecord {
  export function toDenomInfo(d: DenominationRecord): DenominationInfo {
    return {
      denomPub: d.denomPub,
      denomPubHash: d.denomPubHash,
      feeDeposit: Amounts.stringify(d.fees.feeDeposit),
      feeRefresh: Amounts.stringify(d.fees.feeRefresh),
      feeRefund: Amounts.stringify(d.fees.feeRefund),
      feeWithdraw: Amounts.stringify(d.fees.feeWithdraw),
      stampExpireDeposit: timestampProtocolFromDb(d.stampExpireDeposit),
      stampExpireLegal: timestampProtocolFromDb(d.stampExpireLegal),
      stampExpireWithdraw: timestampProtocolFromDb(d.stampExpireWithdraw),
      stampStart: timestampProtocolFromDb(d.stampStart),
      value: Amounts.stringify(d.value),
      exchangeBaseUrl: d.exchangeBaseUrl,
    };
  }
}

export interface ExchangeSignkeysRecord {
  stampStart: DbProtocolTimestamp;
  stampExpire: DbProtocolTimestamp;
  stampEnd: DbProtocolTimestamp;
  signkeyPub: EddsaPublicKeyString;
  masterSig: EddsaSignatureString;

  /**
   * Exchange details that thiis signkeys record belongs to.
   */
  exchangeDetailsRowId: number;
}

/**
 * Exchange details for a particular
 * (exchangeBaseUrl, masterPublicKey, currency) tuple.
 */
export interface ExchangeDetailsRecord {
  rowId?: number;

  /**
   * Master public key of the exchange.
   */
  masterPublicKey: string;

  exchangeBaseUrl: string;

  /**
   * Currency that the exchange offers.
   */
  currency: string;

  /**
   * Auditors (partially) auditing the exchange.
   */
  auditors: ExchangeAuditor[];

  /**
   * Last observed protocol version.
   */
  protocolVersionRange: string;

  reserveClosingDelay: TalerProtocolDuration;

  /**
   * Fees for exchange services
   */
  globalFees: ExchangeGlobalFees[];

  wireInfo: WireInfo;

  /**
   * Age restrictions supported by the exchange (bitmask).
   */
  ageMask?: number;

  walletBalanceLimits?: AmountString[];

  hardLimits?: AccountLimit[];

  zeroLimits?: ZeroLimitedOperation[];

  /**
   * Instructs wallets to use certain bank-specific
   * language (for buttons) and/or other UI/UX customization
   * for compliance with the rules of that bank.
   */
  bankComplianceLanguage: string | undefined;
}

export interface ExchangeDetailsPointer {
  masterPublicKey: string;

  currency: string;

  /**
   * Timestamp when the (masterPublicKey, currency) pointer
   * has been updated.
   */
  updateClock: DbPreciseTimestamp;
}

export enum ExchangeEntryDbRecordStatus {
  Preset = 1,
  Ephemeral = 2,
  Used = 3,
}

// FIXME: Use status ranges for this as well?
export enum ExchangeEntryDbUpdateStatus {
  Initial = 1,
  InitialUpdate = 2,
  Suspended = 3,
  UnavailableUpdate = 4,
  // Reserved 5 for backwards compatibility.
  Ready = 6,
  ReadyUpdate = 7,
  OutdatedUpdate = 8,
}

/**
 * Exchange record as stored in the wallet's database.
 */
export interface ExchangeEntryRecord {
  /**
   * Base url of the exchange.
   */
  baseUrl: string;

  /**
   * Currency hint for a preset exchange, relevant
   * when we didn't contact a preset exchange yet.
   */
  presetCurrencyHint?: string;

  /**
   * When did we confirm the last withdrawal from this exchange?
   *
   * Used mostly in the UI to suggest exchanges.
   */
  lastWithdrawal?: DbPreciseTimestamp;

  /**
   * Pointer to the current exchange details.
   *
   * Should usually not change.  Only changes when the
   * exchange advertises a different master public key and/or
   * currency.
   *
   * We could use a rowID here, but having the currency in the
   * details pointer lets us do fewer DB queries
   */
  detailsPointer: ExchangeDetailsPointer | undefined;

  entryStatus: ExchangeEntryDbRecordStatus;

  updateStatus: ExchangeEntryDbUpdateStatus;

  unavailableReason?: TalerErrorDetail;

  /**
   * If set to true, the next update to the exchange
   * status will request /keys with no-cache headers set.
   */
  cachebreakNextUpdate?: boolean;

  /**
   * Etag of the current ToS of the exchange.
   */
  tosCurrentEtag: string | undefined;

  tosAcceptedEtag: string | undefined;

  tosAcceptedTimestamp: DbPreciseTimestamp | undefined;

  /**
   * Last time when the exchange /keys info was updated.
   */
  lastUpdate: DbPreciseTimestamp | undefined;

  /**
   * Next scheduled update for the exchange.
   */
  nextUpdateStamp: DbPreciseTimestamp;

  /**
   * The number of times we tried to contact the exchange,
   * the exchange returned a result, but it is conflicting with the
   * existing exchange entry.
   *
   * We keep the retry counter here instead of using the task retries,
   * as the task succeeded, the exchange is just not usable.
   */
  updateRetryCounter?: number;

  lastKeysEtag: string | undefined;

  /**
   * Next time that we should check if coins need to be refreshed.
   *
   * Updated whenever the exchange's denominations are updated or when
   * the refresh check has been done.
   */
  nextRefreshCheckStamp: DbPreciseTimestamp;

  /**
   * Public key of the reserve that we're currently using for
   * receiving P2P payments.
   */
  currentMergeReserveRowId?: number;

  /**
   * Defaults to false.
   */
  peerPaymentsDisabled?: boolean;

  /**
   * Defaults to false.
   */
  noFees?: boolean;
}

export enum PlanchetStatus {
  Pending = 0x0100_0000,
  KycRequired = 0x0100_0001,
  WithdrawalDone = 0x0500_000,
  AbortedReplaced = 0x0503_0001,
}

/**
 * A coin that isn't yet signed by an exchange.
 */
export interface PlanchetRecord {
  /**
   * Public key of the coin.
   */
  coinPub: string;

  /**
   * Private key of the coin.
   */
  coinPriv: string;

  /**
   * Withdrawal group that this planchet belongs to
   * (or the empty string).
   */
  withdrawalGroupId: string;

  /**
   * Index within the withdrawal group (or -1).
   */
  coinIdx: number;

  planchetStatus: PlanchetStatus;

  lastError: TalerErrorDetail | undefined;

  denomPubHash: string;

  blindingKey: string;

  withdrawSig: string;

  coinEv: CoinEnvelope;

  coinEvHash: string;

  ageCommitmentProof?: AgeCommitmentProof;
}

export enum CoinSourceType {
  Withdraw = "withdraw",
  Refresh = "refresh",
  Reward = "reward",
}

export interface WithdrawCoinSource {
  type: CoinSourceType.Withdraw;

  /**
   * Can be the empty string for orphaned coins.
   */
  withdrawalGroupId: string;

  /**
   * Index of the coin in the withdrawal session.
   */
  coinIndex: number;

  /**
   * Reserve public key for the reserve we got this coin from.
   */
  reservePub: string;
}

export interface RefreshCoinSource {
  type: CoinSourceType.Refresh;
  refreshGroupId: string;
  oldCoinPub: string;
}

export interface RewardCoinSource {
  type: CoinSourceType.Reward;
  walletRewardId: string;
  coinIndex: number;
}

export type CoinSource =
  | WithdrawCoinSource
  | RefreshCoinSource
  | RewardCoinSource;

/**
 * CoinRecord as stored in the "coins" data store
 * of the wallet database.
 */
export interface CoinRecord {
  /**
   * Where did the coin come from?  Used for recouping coins.
   */
  coinSource: CoinSource;

  /**
   * Source transaction ID of the coin.
   *
   * Used to make the coin visible after the transaction
   * has entered a final state.
   */
  sourceTransactionId?: string;

  /**
   * Public key of the coin.
   */
  coinPub: string;

  /**
   * Private key to authorize operations on the coin.
   */
  coinPriv: string;

  /**
   * Hash of the public key that signs the coin.
   */
  denomPubHash: string;

  /**
   * Unblinded signature by the exchange.
   */
  denomSig: UnblindedDenominationSignature;

  /**
   * Base URL that identifies the exchange from which we got the
   * coin.
   */
  exchangeBaseUrl: string;

  /**
   * Blinding key used when withdrawing the coin.
   * Potentionally used again during payback.
   */
  blindingKey: string;

  /**
   * Hash of the coin envelope.
   *
   * Stored here for indexing purposes, so that when looking at a
   * reserve history, we can quickly find the coin for a withdrawal transaction.
   */
  coinEvHash: string;

  /**
   * Status of the coin.
   */
  status: CoinStatus;

  /**
   * Non-zero for visible.
   *
   * A coin is visible when it is fresh and the
   * source transaction is in a final state.
   */
  visible?: number;

  /**
   * Maximum age of purchases that can be made with this coin.
   *
   * (Used for indexing, redundant with {@link ageCommitmentProof}).
   */
  maxAge: number;

  ageCommitmentProof: AgeCommitmentProof | undefined;
}

/**
 * Object to be hashed for use as a grouping key for token listings, such that
 * any change in token family details results in a separate list item.
 */
export interface TokenFamilyInfo {
  /**
   * Identifier for the token family consisting of
   * unreserved characters according to RFC 3986.
   */
  slug: string;

  /**
   * Human-readable name for the token family.
   */
  name: string;

  /**
   * Human-readable description for the token family.
   */
  description: string;

  /**
   * Optional map from IETF BCP 47 language tags to localized descriptions.
   */
  descriptionI18n: any | undefined;

  /**
   * Additional meta data, such as the trusted_domains
   * or expected_domains. Depends on the kind.
   */
  extraData: MerchantContractTokenDetails;

  /**
   * Token issue public key used by merchant to verify tokens.
   */
  tokenIssuePub: TokenIssuePublicKey;
}

/**
 * TokenFamilyRecord as stored in the "tokenFamilies"
 * data store of the wallet database.
 */
export interface TokenRecord extends TokenFamilyInfo {
  /**
   * Source purchase of the token.
   */
  purchaseId: string;

  /**
   *  Transaction where token is being used.
   */
  transactionId?: string;

  /**
   * Index of token in choices array.
   */
  choiceIndex?: number;

  /**
   * Index of token in outputs array.
   */
  outputIndex?: number;

  /**
   * URL of the merchant issuing the token.
   */
  merchantBaseUrl: string;

  /**
   * Kind of the token.
   */
  kind: MerchantContractTokenKind;

  /**
   * Hash of token issue public key.
   */
  tokenIssuePubHash: string;

  /**
   * Hash of {@link TokenFamilyInfo} object.
   */
  tokenFamilyHash?: string;

  /**
   * Start time of the token family's validity period.
   */
  validAfter: DbProtocolTimestamp;

  /**
   * End time of the token family's validity period.
   */
  validBefore: DbProtocolTimestamp;

  /**
   * Unblinded token issue signature made by the merchant.
   */
  tokenIssueSig: UnblindedDenominationSignature;

  /**
   * Token use public key used to confirm usage of tokens.
   */
  tokenUsePub: string;

  /**
   * Token use private key used to verify usage of tokens.
   */
  tokenUsePriv: string;

  /**
   * Signature on token use request.
   */
  tokenUseSig?: TokenUseSig;

  /**
   * Envelope of the token.
   */
  tokenEv: TokenEnvelope;

  /**
   * Hash of the envelope.
   */
  tokenEvHash: string;

  /**
   * Blinding secret for token.
   */
  blindingKey: string;
}



/**
 * Slate, a blank slice of rock cut for use as a writing surface,
 * also the database representation of a token before being
 * signed by the merchant, as stored in the `slates' data store.
 */
export type SlateRecord = Omit<TokenRecord, "tokenIssueSig">;

export namespace TokenRecord {
  export function hashInfo(r: TokenRecord | SlateRecord): string {
    const info: TokenFamilyInfo = {
      slug: r.slug,
      name: r.name,
      description: r.description,
      descriptionI18n: r.descriptionI18n,
      extraData: r.extraData,
      tokenIssuePub: r.tokenIssuePub,
    };
    return encodeCrock(hash(stringToBytes(canonicalJson(info) + "\0")));
  }
}

/**
 * History item for a coin.
 *
 * DB-specific format,
 */
export type DbWalletCoinHistoryItem =
  | {
      type: "withdraw";
      transactionId: TransactionIdStr;
    }
  | {
      type: "spend";
      transactionId: TransactionIdStr;
      amount: AmountString;
    }
  | {
      type: "refresh";
      transactionId: TransactionIdStr;
      amount: AmountString;
    }
  | {
      type: "recoup";
      transactionId: TransactionIdStr;
      amount: AmountString;
    }
  | {
      type: "refund";
      transactionId: TransactionIdStr;
      amount: AmountString;
    };

/**
 * History event for a coin from the wallet's perspective.
 *
 * The history might reference transactions that were already deleted from the wallet.
 */
export interface CoinHistoryRecord {
  coinPub: string;
  /**
   * History items for the coin.
   *
   * We store this as an array in the object store, as the coin history
   * is pretty much always very small.
   */
  history: DbWalletCoinHistoryItem[];
}

export enum RefreshCoinStatus {
  Pending = 0x0100_0000,
  Finished = 0x0500_0000,

  /**
   * The refresh for this coin has been frozen, because of a permanent error.
   * More info in lastErrorPerCoin.
   */
  Failed = 0x0501_000,
}

export enum RefreshOperationStatus {
  Pending = 0x0100_0000,
  Suspended = 0x0110_0000,

  Finished = 0x0500_000,
  Failed = 0x0501_000,
}

/**
 * Status of a single element of a deposit group.
 */
export enum DepositElementStatus {
  DepositPending = 0x0100_0000,
  /**
   * Accepted, but tracking.
   */
  Tracking = 0x0100_0001,
  KycRequired = 0x0100_0002,
  Wired = 0x0500_0000,
  RefundSuccess = 0x0503_0000,
  RefundFailed = 0x0501_0000,
  RefundNotFound = 0x0501_0001,
}

export interface RefreshGroupPerExchangeInfo {
  /**
   * (Expected) output once the refresh group succeeded.
   */
  outputEffective: AmountString;
}

/**
 * Group of refresh operations.  The refreshed coins do not
 * have to belong to the same exchange, but must have the same
 * currency.
 */
export interface RefreshGroupRecord {
  operationStatus: RefreshOperationStatus;

  /**
   * Unique, randomly generated identifier for this group of
   * refresh operations.
   */
  refreshGroupId: string;

  /**
   * Currency of this refresh group.
   */
  currency: string;

  /**
   * Reason why this refresh group has been created.
   */
  reason: RefreshReason;

  originatingTransactionId?: string;

  oldCoinPubs: string[];

  inputPerCoin: AmountString[];

  expectedOutputPerCoin: AmountString[];

  infoPerExchange?: Record<string, RefreshGroupPerExchangeInfo>;

  /**
   * Flag for each coin whether refreshing finished.
   * If a coin can't be refreshed (remaining value too small),
   * it will be marked as finished, but no refresh session will
   * be created.
   */
  statusPerCoin: RefreshCoinStatus[];

  /**
   * Refund requests that might still be necessary
   * before the refresh can work.
   */
  refundRequests: { [n: number]: ExchangeRefundRequest };

  timestampCreated: DbPreciseTimestamp;

  failReason?: TalerErrorDetail;

  /**
   * Timestamp when the refresh session finished.
   */
  timestampFinished: DbPreciseTimestamp | undefined;
}

/**
 * Ongoing refresh
 */
export interface RefreshSessionRecord {
  refreshGroupId: string;

  /**
   * Index of the coin in the refresh group.
   */
  coinIndex: number;

  /**
   * 512-bit secret that can be used to derive
   * the other cryptographic material for the refresh session.
   *
   * If this field is set, it's a legacy V1 refresh session.
   */
  sessionSecretSeed?: string;

  /**
   * If this field is set, it's a V2 refresh session.
   */
  sessionPublicSeed?: string;

  /**
   * Sum of the value of denominations we want
   * to withdraw in this session, without fees.
   */
  amountRefreshOutput: AmountString;

  /**
   * Hashed denominations of the newly requested coins.
   */
  newDenoms: {
    denomPubHash: string;
    count: number;
  }[];

  /**
   * The no-reveal-index after we've done the melting.
   */
  norevealIndex?: number;

  lastError?: TalerErrorDetail;
}

export enum RefundReason {
  /**
   * Normal refund given by the merchant.
   */
  NormalRefund = "normal-refund",
  /**
   * Refund from an aborted payment.
   */
  AbortRefund = "abort-pay-refund",
}

export enum PurchaseStatus {
  /**
   * Not downloaded yet.
   */
  PendingDownloadingProposal = 0x0100_0000,
  SuspendedDownloadingProposal = 0x0110_0000,

  /**
   * The user has accepted the proposal.
   */
  PendingPaying = 0x0100_0001,
  SuspendedPaying = 0x0110_0001,

  /**
   * Currently in the process of aborting with a refund.
   */
  AbortingWithRefund = 0x0103_0000,
  SuspendedAbortingWithRefund = 0x0113_0000,

  /**
   * Paying a second time, likely with different session ID
   */
  PendingPayingReplay = 0x0100_0002,
  SuspendedPayingReplay = 0x0110_0002,

  /**
   * Query for refunds (until query succeeds).
   */
  PendingQueryingRefund = 0x0100_0003,
  SuspendedQueryingRefund = 0x0110_0003,

  /**
   * Query for refund (until auto-refund deadline is reached).
   *
   * Legacy state for compatibility.
   */
  PendingQueryingAutoRefund = 0x0100_0004,
  SuspendedQueryingAutoRefund = 0x0110_0004,

  FinalizingQueryingAutoRefund = 0x0200_0001,
  SuspendedFinalizingQueryingAutoRefund = 0x0210_0001,

  PendingAcceptRefund = 0x0100_0005,
  SuspendedPendingAcceptRefund = 0x0110_0005,

  /**
   * Proposal downloaded, but the user needs to accept/reject it.
   */
  DialogProposed = 0x0101_0000,

  /**
   * Proposal shared to other wallet or read from other wallet
   * the user needs to accept/reject it.
   */
  DialogShared = 0x0101_0001,

  /**
   * Downloading or processing the proposal has failed permanently.
   */
  FailedClaim = 0x0501_0000,

  /**
   * Tried to abort, but aborting failed or was cancelled.
   */
  FailedAbort = 0x0501_0001,

  FailedPaidByOther = 0x0501_0002,

  /**
   * Payment was successful.
   */
  Done = 0x0500_0000,

  /**
   * Downloaded proposal was detected as a re-purchase.
   */
  DoneRepurchaseDetected = 0x0500_0001,

  /**
   * The user has rejected the proposal.
   */
  AbortedProposalRefused = 0x0503_0000,

  AbortedRefunded = 0x0503_0001,

  AbortedOrderDeleted = 0x0503_0002,

  /**
   * The payment has been aborted.
   */
  AbortedIncompletePayment = 0x0503_0003,
}

/**
 * Partial information about the downloaded proposal.
 * Only contains data that is relevant for indexing on the
 * "purchases" object stores.
 */
export interface ProposalDownloadInfo {
  contractTermsHash: string;
  fulfillmentUrl?: string;
  currency: string;
  contractTermsMerchantSig: string;
}

export interface DbCoinSelection {
  coinPubs: string[];
  coinContributions: AmountString[];
}

export interface DbTokenSelection {
  tokenPubs: string[];
}

export interface PurchasePayInfo {
  /**
   * Undefined if payment is blocked by a pending refund.
   */
  payCoinSelection?: DbCoinSelection;
  /**
   * Undefined if payment is blocked by a pending refund.
   */
  payCoinSelectionUid?: string;

  payTokenSelection?: DbTokenSelection;

  /**
   * Token signatures from merchant.
   */
  slateTokenSigs?: SignedTokenEnvelope[];

  /**
   * Whether token selection should be forced
   * e.g. when merchant URL is not in `expected_domains'
   */
  payTokenForcedSel?: boolean;

  totalPayCost: AmountString;
}

/**
 * Record that stores status information about one purchase, starting from when
 * the customer accepts a proposal.  Includes refund status if applicable.
 *
 * Key: {@link proposalId}
 * Operation status: {@link purchaseStatus}
 */
export interface PurchaseRecord {
  /**
   * Proposal ID for this purchase.  Uniquely identifies the
   * purchase and the proposal.
   * Assigned by the wallet.
   */
  proposalId: string;

  /**
   * Order ID, assigned by the merchant.
   */
  orderId: string;

  merchantBaseUrl: string;

  /**
   * Claim token used when downloading the contract terms.
   */
  claimToken: string | undefined;

  /**
   * Session ID we got when downloading the contract.
   */
  downloadSessionId: string | undefined;

  /**
   * If this purchase is a repurchase, this field identifies the original purchase.
   */
  repurchaseProposalId: string | undefined;

  purchaseStatus: PurchaseStatus;

  abortReason?: TalerErrorDetail;
  failReason?: TalerErrorDetail;

  /**
   * Private key for the nonce.
   */
  noncePriv: string;

  /**
   * Public key for the nonce.
   */
  noncePub: string;

  /**
   * Index of selected choice in the choices array.
   */
  choiceIndex?: number | undefined;

  /**
   * Secret seed used to derive slates.
   * Stored since slates are created lazily.
   */
  secretSeed: string | undefined;

  /**
   * Downloaded and parsed proposal data.
   */
  download: ProposalDownloadInfo | undefined;

  payInfo: PurchasePayInfo | undefined;

  /**
   * Exchanges involved in this purchase.
   * Used as a multiEntry index to find all purchases for
   * an exchange.
   */
  exchanges?: string[];

  /**
   * Pending removals from pay coin selection.
   *
   * Used when a the pay coin selection needs to be changed
   * because a coin became known as double-spent or invalid,
   * but a new coin selection can't immediately be done, as
   * there is not enough balance (e.g. when waiting for a refresh).
   */
  pendingRemovedCoinPubs?: string[];

  /**
   * Timestamp of the first time that sending a payment to the merchant
   * for this purchase was successful.
   */
  timestampFirstSuccessfulPay: DbPreciseTimestamp | undefined;

  merchantPaySig: string | undefined;

  posConfirmation: string | undefined;

  donauOutputIndex?: number;
  donauBaseUrl?: string;
  donauAmount?: AmountString;
  donauTaxIdHash?: string;
  donauTaxIdSalt?: string;
  donauTaxId?: string;
  donauYear?: number;

  /**
   * This purchase was shared with another wallet
   * that is now supposed to finish the payment.
   */
  shared: boolean;

  /**
   * This purchase was created by reading
   * a payment share or the wallet
   * the nonce public by a payment share
   *
   * Defaults to false.
   */
  createdFromShared?: boolean;

  /**
   * When was the purchase record created?
   */
  timestamp: DbPreciseTimestamp;

  /**
   * When was the purchase made?
   * Refers to the time that the user accepted.
   */
  timestampAccept: DbPreciseTimestamp | undefined;

  /**
   * When was the last refund made?
   * Set to 0 if no refund was made on the purchase.
   */
  timestampLastRefundStatus: DbPreciseTimestamp | undefined;

  /**
   * Last session signature that we submitted to /pay (if any).
   */
  lastSessionId: string | undefined;

  /**
   * Continue querying the refund status until this deadline has expired.
   */
  autoRefundDeadline: DbProtocolTimestamp | undefined;

  /**
   * How much merchant has refund to be taken but the wallet
   * did not picked up yet
   */
  refundAmountAwaiting: AmountString | undefined;
}

export enum ConfigRecordKey {
  WalletBackupState = "walletBackupState",
  CurrencyDefaultsApplied = "currencyDefaultsApplied",
  // Only for testing, do not use!
  TestLoopTx = "testTxLoop",
  LastInitInfo = "lastInitInfo",
  MaterializedTransactionsVersion = "materializedTransactionsVersion",
  DonauConfig = "donauConfig",
}

export interface DonauConfig {
  donauBaseUrl: string;
  donauTaxId: string;
  /** Tax ID hash, salted with donauSalt */
  donauTaxIdHash: string;
  /** 32 byte salt, base32crockford encoded */
  donauSalt: string;
}

/**
 * Configuration key/value entries to configure
 * the wallet.
 */
export type ConfigRecord =
  | {
      key: ConfigRecordKey.WalletBackupState;
      value: WalletBackupConfState;
    }
  | { key: ConfigRecordKey.CurrencyDefaultsApplied; value: boolean }
  | { key: ConfigRecordKey.TestLoopTx; value: number }
  | { key: ConfigRecordKey.LastInitInfo; value: DbProtocolTimestamp }
  | { key: ConfigRecordKey.MaterializedTransactionsVersion; value: number }
  | { key: ConfigRecordKey.DonauConfig; value: DonauConfig }

export interface WalletBackupConfState {
  deviceId: string;
  walletRootPub: string;
  walletRootPriv: string;

  /**
   * Last hash of the canonicalized plain-text backup.
   */
  lastBackupPlainHash?: string;

  /**
   * Timestamp stored in the last backup.
   */
  lastBackupTimestamp?: DbPreciseTimestamp;

  /**
   * Last time we tried to do a backup.
   */
  lastBackupCheckTimestamp?: DbPreciseTimestamp;
  lastBackupNonce?: string;
}

// FIXME: Should these be numeric codes?
export const enum WithdrawalRecordType {
  BankManual = "bank-manual",
  BankIntegrated = "bank-integrated",
  PeerPullCredit = "peer-pull-credit",
  PeerPushCredit = "peer-push-credit",
  Recoup = "recoup",
}

export interface WgInfoBankIntegrated {
  withdrawalType: WithdrawalRecordType.BankIntegrated;

  /**
   * Extra state for when this is a withdrawal involving
   * a Taler-integrated bank.
   */
  bankInfo: ReserveBankInfo;

  /**
   * Info about withdrawal accounts, possibly including currency conversion.
   */
  exchangeCreditAccounts?: WithdrawalExchangeAccountDetails[];
}

export interface WgInfoBankManual {
  withdrawalType: WithdrawalRecordType.BankManual;

  /**
   * Info about withdrawal accounts, possibly including currency conversion.
   */
  exchangeCreditAccounts?: WithdrawalExchangeAccountDetails[];
}

export interface WgInfoBankPeerPull {
  withdrawalType: WithdrawalRecordType.PeerPullCredit;

  // FIXME: include a transaction ID here?

  /**
   * Needed to quickly construct the taler:// URI for the counterparty
   * without a join.
   */
  contractPriv: string;
}

export interface WgInfoBankPeerPush {
  withdrawalType: WithdrawalRecordType.PeerPushCredit;

  // FIXME: include a transaction ID here?
}

export interface WgInfoBankRecoup {
  withdrawalType: WithdrawalRecordType.Recoup;
}

export type WgInfo =
  | WgInfoBankIntegrated
  | WgInfoBankManual
  | WgInfoBankPeerPull
  | WgInfoBankPeerPush
  | WgInfoBankRecoup;

export type KycUserType = "individual" | "business";

/**
 * Group of withdrawal operations that need to be executed.
 * (Either for a normal withdrawal or from a reward.)
 *
 * The withdrawal group record is only created after we know
 * the coin selection we want to withdraw.
 */
export interface WithdrawalGroupRecord {
  /**
   * Unique identifier for the withdrawal group.
   */
  withdrawalGroupId: string;

  wgInfo: WgInfo;

  /**
   * If set to true, the account used during withdrawal is treated as an
   * account that does not belong to the user. It won't be shown in
   * the list of know bank accounts.
   *
   * Defaults to false.
   */
  isForeignAccount?: boolean;

  kycPaytoHash?: string;

  kycAccessToken?: string;

  kycLastCheckStatus?: number | undefined;
  kycLastCheckCode?: number | undefined;
  kycLastRuleGen?: number | undefined;
  kycLastAmlReview?: boolean | undefined;
  kycLastDeny?: DbPreciseTimestamp | undefined;

  /**
   * Delay to wait until the next withdrawal attempt.
   *
   * @deprecated by https://bugs.gnunet.org/view.php?id=9694
   */
  kycWithdrawalDelay?: TalerProtocolDuration;

  /**
   * Secret seed used to derive planchets.
   * Stored since planchets are created lazily.
   */
  secretSeed: string;

  /**
   * Public key of the reserve that we're withdrawing from.
   */
  reservePub: string;

  /**
   * The reserve private key.
   *
   * FIXME: Already in the reserves object store, redundant!
   */
  reservePriv: string;

  /**
   * The exchange base URL that we're withdrawing from.
   * (Redundantly stored, as the reserve record also has this info.)
   */
  exchangeBaseUrl?: string;

  /**
   * When was the withdrawal operation started started?
   * Timestamp in milliseconds.
   */
  timestampStart: DbPreciseTimestamp;

  /**
   * When was the withdrawal operation completed?
   */
  timestampFinish?: DbPreciseTimestamp;

  /**
   * Current status of the reserve.
   */
  status: WithdrawalGroupStatus;

  /**
   * Restrict withdrawals from this reserve to this age.
   */
  restrictAge?: number;

  /**
   * Amount that was sent by the user to fund the reserve.
   */
  instructedAmount?: AmountString;

  /**
   * Amount that was observed when querying the reserve that
   * we are withdrawing from.
   *
   * Useful for diagnostics.
   */
  reserveBalanceAmount?: AmountString;

  /**
   * Amount including fees (i.e. the amount subtracted from the
   * reserve to withdraw all coins in this withdrawal session).
   *
   * (Initial amount confirmed by the user, might differ with denomSel
   * on reselection.)
   */
  rawWithdrawalAmount?: AmountString;

  /**
   * Amount that will be added to the balance when the withdrawal succeeds.
   *
   * (Initial amount confirmed by the user, might differ with denomSel
   * on reselection.)
   */
  effectiveWithdrawalAmount?: AmountString;

  /**
   * Denominations selected for withdrawal.
   */
  denomsSel?: DenomSelectionState;

  abortReason?: TalerErrorDetail;
  failReason?: TalerErrorDetail;
}

export interface BankWithdrawUriRecord {
  /**
   * The withdraw URI we got from the bank.
   */
  talerWithdrawUri: string;

  /**
   * Reserve that was created for the withdraw URI.
   */
  reservePub: string;
}

export enum RecoupOperationStatus {
  Pending = 0x0100_0000,
  Suspended = 0x0110_0000,

  Finished = 0x0500_000,
  Failed = 0x0501_000,
}

/**
 * Status of recoup operations that were grouped together.
 *
 * The remaining amount of involved coins should be set to zero
 * in the same transaction that inserts the RecoupGroupRecord.
 */
export interface RecoupGroupRecord {
  /**
   * Unique identifier for the recoup group record.
   */
  recoupGroupId: string;

  exchangeBaseUrl: string;

  operationStatus: RecoupOperationStatus;

  timestampStarted: DbPreciseTimestamp;

  timestampFinished: DbPreciseTimestamp | undefined;

  /**
   * Public keys that identify the coins being recouped
   * as part of this session.
   *
   * (Structured like this to enable multiEntry indexing in IndexedDB.)
   */
  coinPubs: string[];

  /**
   * Array of flags to indicate whether the recoup finished on each individual coin.
   */
  recoupFinishedPerCoin: boolean[];

  /**
   * Public keys of coins that should be scheduled for refreshing
   * after all individual recoups are done.
   */
  scheduleRefreshCoins: CoinRefreshRequest[];
}

export enum BackupProviderStateTag {
  Provisional = "provisional",
  Ready = "ready",
  Retrying = "retrying",
}

export type BackupProviderState =
  | {
      tag: BackupProviderStateTag.Provisional;
    }
  | {
      tag: BackupProviderStateTag.Ready;
      nextBackupTimestamp: DbPreciseTimestamp;
    }
  | {
      tag: BackupProviderStateTag.Retrying;
    };

export interface BackupProviderRecord {
  /**
   * Base URL of the provider.
   *
   * Primary key for the record.
   */
  baseUrl: string;

  /**
   * Name of the provider
   */
  name: string;

  /**
   * Terms of service of the provider.
   * Might be unavailable in the DB in certain situations
   * (such as loading a recovery document).
   */
  terms?: BackupProviderTerms;

  /**
   * Hash of the last encrypted backup that we already merged
   * or successfully uploaded ourselves.
   */
  lastBackupHash?: string;

  /**
   * Last time that we successfully uploaded a backup (or
   * the uploaded backup was already current).
   *
   * Does NOT correspond to the timestamp of the backup,
   * which only changes when the backup content changes.
   */
  lastBackupCycleTimestamp?: DbPreciseTimestamp;

  /**
   * Proposal that we're currently trying to pay for.
   *
   * (Also included in paymentProposalIds.)
   *
   * FIXME:  Make this part of a proper BackupProviderState?
   */
  currentPaymentTransactionId?: string;

  shouldRetryFreshProposal: boolean;

  /**
   * Proposals that were used to pay (or attempt to pay) the provider.
   *
   * Stored to display a history of payments to the provider, and
   * to make sure that the wallet isn't overpaying.
   */
  paymentProposalIds: string[];

  state: BackupProviderState;

  /**
   * UIDs for the operation that added the backup provider.
   */
  uids: string[];
}

export enum DepositOperationStatus {
  PendingDeposit = 0x0100_0000,
  SuspendedDeposit = 0x0110_0000,

  // Legacy states, we we now show
  // the tracking state as a finalizing state.
  LegacyPendingTrack = 0x0100_0001,
  LegacySuspendedTrack = 0x0110_0001,

  PendingAggregateKyc = 0x0100_0002,
  SuspendedAggregateKyc = 0x0110_0002,

  PendingDepositKyc = 0x0100_0003,
  SuspendedDepositKyc = 0x0110_0003,

  PendingDepositKycAuth = 0x0100_0005,
  SuspendedDepositKycAuth = 0x0110_0005,

  Aborting = 0x0103_0000,
  SuspendedAborting = 0x0113_0000,

  FinalizingTrack = 0x0200_0001,
  SuspendedFinalizingTrack = 0x0210_0001,

  Finished = 0x0500_0000,

  FailedDeposit = 0x0501_0000,

  FailedTrack = 0x0501_0001,

  AbortedDeposit = 0x0503_0000,
}

export interface DepositTrackingInfo {
  // Raw wire transfer identifier of the deposit.
  wireTransferId: string;
  // When was the wire transfer given to the bank.
  timestampExecuted: DbProtocolTimestamp;
  // Total amount transfer for this wtid (including fees)
  amountRaw: AmountString;
  // Wire fee amount for this exchange
  wireFee: AmountString;

  exchangePub: string;
}

export interface DepositInfoPerExchange {
  /**
   * Expected effective amount that will be deposited
   * from coins of this exchange.
   */
  amountEffective: AmountString;
}

/**
 * Group of deposits made by the wallet.
 */
export interface DepositGroupRecord {
  depositGroupId: string;

  currency: string;

  /**
   * Instructed amount.
   */
  amount: AmountString;

  wireTransferDeadline: DbProtocolTimestamp;

  merchantPub: string;
  merchantPriv: string;

  noncePriv: string;
  noncePub: string;

  /**
   * Wire information used by all deposits in this
   * deposit group.
   */
  wire: {
    payto_uri: string;
    salt: string;
  };

  contractTermsHash: string;

  payCoinSelection?: DbCoinSelection;

  payCoinSelectionUid?: string;

  totalPayCost: AmountString;

  /**
   * The counterparty effective deposit amount.
   */
  counterpartyEffectiveDepositAmount: AmountString;

  timestampCreated: DbPreciseTimestamp;

  timestampFinished: DbPreciseTimestamp | undefined;

  /**
   * When did the wallet last try a deposit request?
   */
  timestampLastDepositAttempt: DbPreciseTimestamp | undefined;

  operationStatus: DepositOperationStatus;

  statusPerCoin?: DepositElementStatus[];

  infoPerExchange?: Record<string, DepositInfoPerExchange>;

  /**
   * When the deposit transaction was aborted and
   * refreshes were tried, we create a refresh
   * group and store the ID here.
   */
  abortRefreshGroupId?: string;

  abortReason?: TalerErrorDetail;
  failReason?: TalerErrorDetail;

  kycInfo?: DepositKycInfo;

  // FIXME: Do we need this and should it be in this object store?
  trackingState?: {
    [signature: string]: DepositTrackingInfo;
  };
}

export interface DepositKycInfo {
  accessToken?: string;
  paytoHash: string;
  exchangeBaseUrl: string;
  lastCheckStatus?: number | undefined;
  lastCheckCode?: number | undefined;
  lastRuleGen?: number | undefined;
  lastAmlReview?: boolean | undefined;
  lastDeny?: DbPreciseTimestamp | undefined;
  lastBadKycAuth?: boolean;
}

export interface TombstoneRecord {
  /**
   * Tombstone ID, with the syntax "tmb:<type>:<key>".
   */
  id: string;
}

export enum PeerPushDebitStatus {
  /**
   * Initiated, but no purse created yet.
   */
  PendingCreatePurse = 0x0100_0000 /* ACTIVE_START */,
  PendingReady = 0x0100_0001,
  AbortingDeletePurse = 0x0103_0000,

  SuspendedCreatePurse = 0x0110_0000,
  SuspendedReady = 0x0110_0001,
  SuspendedAbortingDeletePurse = 0x0113_0000,

  Done = 0x0500_0000,
  Aborted = 0x0503_0000,
  Failed = 0x0501_0000,
  Expired = 0x0502_0000,

  // Legacy / reserved:
  // SuspendedAbortingRefreshDeleted = 0x0113_0001,
  // SuspendedAbortingRefreshExpired = 0x0113_0002,
  // AbortingRefreshDeleted = 0x0103_0001,
  // AbortingRefreshExpired = 0x0103_0002,
}

export interface DbPeerPushPaymentCoinSelection {
  contributions: AmountString[];
  coinPubs: CoinPublicKeyString[];
}

/**
 * Record for a push P2P payment that this wallet initiated.
 */
export interface PeerPushDebitRecord {
  /**
   * What exchange are funds coming from?
   */
  exchangeBaseUrl: string;

  /**
   * Restricted scope for this transaction.
   *
   * Relevant for coin reselection.
   */
  restrictScope?: ScopeInfo;

  /**
   * Instructed amount.
   */
  amount: AmountString;

  /**
   * Effective amount.
   *
   * (Called totalCost for historical reasons.)
   */
  totalCost: AmountString;

  coinSel?: DbPeerPushPaymentCoinSelection;

  contractTermsHash: HashCodeString;

  /**
   * Purse public key.  Used as the primary key to look
   * up this record.
   */
  pursePub: string;

  /**
   * Purse private key.
   */
  pursePriv: string;

  /**
   * Public key of the merge capability of the purse.
   */
  mergePub: string;

  /**
   * Private key of the merge capability of the purse.
   */
  mergePriv: string;

  contractPriv: string;
  contractPub: string;

  /**
   * 24 byte nonce.
   */
  contractEncNonce: string;

  purseExpiration: DbProtocolTimestamp;

  timestampCreated: DbPreciseTimestamp;

  abortRefreshGroupId?: string;

  abortReason?: TalerErrorDetail;
  failReason?: TalerErrorDetail;

  /**
   * Status of the peer push payment initiation.
   */
  status: PeerPushDebitStatus;
}

export enum PeerPullPaymentCreditStatus {
  /**
   * Typically the initial state of the peer-pull-credit transaction,
   * purse will be created.
   */
  PendingCreatePurse = 0x0100_0000,
  SuspendedCreatePurse = 0x0110_0000,

  /**
   * Purse created, waiting for the other party to accept the
   * invoice and deposit money into it.
   */
  PendingReady = 0x0100_0001,
  SuspendedReady = 0x0110_0001,

  PendingMergeKycRequired = 0x0100_0002,
  SuspendedMergeKycRequired = 0x0110_0002,

  PendingWithdrawing = 0x0100_0003,
  SuspendedWithdrawing = 0x0110_0003,

  PendingBalanceKycRequired = 0x0100_0004,
  SuspendedBalanceKycRequired = 0x0110_0004,

  PendingBalanceKycInit = 0x0100_0005,
  SuspendedBalanceKycInit = 0x0110_0005,

  AbortingDeletePurse = 0x0103_0000,
  SuspendedAbortingDeletePurse = 0x0113_0000,

  Done = 0x0500_0000,
  Failed = 0x0501_0000,
  Expired = 0x0502_0000,
  Aborted = 0x0503_0000,
}

export interface PeerPullCreditRecord {
  /**
   * What exchange are we using for the payment request?
   */
  exchangeBaseUrl: string;

  /**
   * Amount requested.
   * FIXME: What type of instructed amount is i?
   */
  amount: AmountString;

  estimatedAmountEffective: AmountString;

  /**
   * Purse public key.  Used as the primary key to look
   * up this record.
   */
  pursePub: string;

  /**
   * Purse private key.
   */
  pursePriv: string;

  /**
   * Hash of the contract terms.  Also
   * used to look up the contract terms in the DB.
   */
  contractTermsHash: string;

  mergePub: string;
  mergePriv: string;

  contractPub: string;
  contractPriv: string;

  contractEncNonce: string;

  mergeTimestamp: DbPreciseTimestamp;

  mergeReserveRowId: number;

  /**
   * Status of the peer pull payment initiation.
   */
  status: PeerPullPaymentCreditStatus;

  kycPaytoHash?: string;

  kycAccessToken?: string;

  kycLastCheckStatus?: number | undefined;
  kycLastCheckCode?: number | undefined;
  kycLastRuleGen?: number | undefined;
  kycLastAmlReview?: boolean | undefined;
  kycLastDeny?: DbPreciseTimestamp | undefined;

  abortReason?: TalerErrorDetail;
  failReason?: TalerErrorDetail;

  withdrawalGroupId: string | undefined;
}

export enum PeerPushCreditStatus {
  PendingMerge = 0x0100_0000,
  SuspendedMerge = 0x0110_0000,

  PendingMergeKycRequired = 0x0100_0001,
  SuspendedMergeKycRequired = 0x0110_0001,

  /**
   * Merge was successful and withdrawal group has been created, now
   * everything is in the hand of the withdrawal group.
   */
  PendingWithdrawing = 0x0100_0002,
  SuspendedWithdrawing = 0x0110_0002,

  PendingBalanceKycRequired = 0x0100_0003,
  SuspendedBalanceKycRequired = 0x0110_0003,

  PendingBalanceKycInit = 0x0100_0004,
  SuspendedBalanceKycInit = 0x0110_0004,

  DialogProposed = 0x0101_0000,

  Done = 0x0500_0000,
  Aborted = 0x0503_0000,
  Failed = 0x0501_0000,
}

/**
 * Record for a push P2P payment that this wallet was offered.
 *
 * Unique: (exchangeBaseUrl, pursePub)
 */
export interface PeerPushPaymentIncomingRecord {
  peerPushCreditId: string;

  exchangeBaseUrl: string;

  pursePub: string;

  mergePriv: string;

  contractPriv: string;

  timestamp: DbPreciseTimestamp;

  estimatedAmountEffective: AmountString;

  /**
   * Hash of the contract terms.  Also
   * used to look up the contract terms in the DB.
   */
  contractTermsHash: string;

  /**
   * Status of the peer push payment incoming initiation.
   */
  status: PeerPushCreditStatus;

  abortReason?: TalerErrorDetail;
  failReason?: TalerErrorDetail;

  /**
   * Associated withdrawal group.
   */
  withdrawalGroupId: string | undefined;

  /**
   * Currency of the peer push payment credit transaction.
   *
   * Mandatory in current schema version, optional for compatibility
   * with older (ver_minor<4) DB versions.
   */
  currency: string | undefined;

  kycPaytoHash?: string;

  kycAccessToken?: string;

  kycLastCheckStatus?: number | undefined;
  kycLastCheckCode?: number | undefined;
  kycLastRuleGen?: number | undefined;
  kycLastAmlReview?: boolean | undefined;
  kycLastDeny?: DbPreciseTimestamp | undefined;
}

export enum PeerPullDebitRecordStatus {
  PendingDeposit = 0x0100_0001,
  AbortingRefresh = 0x0103_0001,

  SuspendedDeposit = 0x0110_0001,
  SuspendedAbortingRefresh = 0x0113_0001,

  DialogProposed = 0x0101_0001,

  Done = 0x0500_0000,
  Aborted = 0x0503_0000,
  Failed = 0x0501_0000,
}

export interface PeerPullPaymentCoinSelection {
  contributions: AmountString[];
  coinPubs: CoinPublicKeyString[];

  /**
   * Total cost based on the coin selection.
   * Non undefined after status === "Accepted"
   */
  totalCost: AmountString | undefined;
}

/**
 * AKA PeerPullDebit.
 */
export interface PeerPullPaymentIncomingRecord {
  peerPullDebitId: string;

  pursePub: string;

  exchangeBaseUrl: string;

  amount: AmountString;

  contractTermsHash: string;

  timestampCreated: DbPreciseTimestamp;

  /**
   * Contract priv that we got from the other party.
   */
  contractPriv: string;

  /**
   * Status of the peer push payment incoming initiation.
   */
  status: PeerPullDebitRecordStatus;

  /**
   * Estimated total cost when the record was created.
   */
  totalCostEstimated: AmountString;

  abortRefreshGroupId?: string;

  abortReason?: TalerErrorDetail;
  failReason?: TalerErrorDetail;

  coinSel?: PeerPullPaymentCoinSelection;
}

export enum ReserveRecordStatus {
  // Need to call the "/kyc-wallet" endpoint
  PendingLegiInit = 0x0100_0001,
  SuspendedLegiInit = 0x0110_0001,
  // Need to wait for user to pass legitimization
  PendingLegi = 0x0100_0002,
  SuspendedLegi = 0x0110_0002,

  /**
   * Done with KYC.
   */
  Done = 0x0500_0000,
}

/**
 * Store for extra information about a reserve.
 *
 * Mostly used to store the private key for a reserve and to allow
 * other records to reference the reserve key pair via a small row ID.
 *
 * In the future, we might also store KYC info about a reserve here.
 *
 * FIXME: Should reference exchange.
 */
export interface ReserveRecord {
  rowId?: number;

  reservePub: string;

  reservePriv: string;

  status?: ReserveRecordStatus;

  requirementRow?: number;

  /**
   * Balance threshold that we're currently requesting KYC for.
   */
  thresholdRequested?: AmountString;

  /**
   * Balance threshold that we already have passed KYC for.
   */
  thresholdGranted?: AmountString;

  /**
   * Threshold that will trigger the next KYC.
   */
  thresholdNext?: AmountString;

  kycAccessToken?: string;

  amlReview?: boolean;
}

export interface OperationRetryRecord {
  /**
   * Unique identifier for the operation.  Typically of
   * the format `${opType}-${opUniqueKey}`
   *
   * @see {@link TaskIdentifiers}
   */
  id: string;

  lastError?: TalerErrorDetail;

  retryInfo: DbRetryInfo;
}

/**
 * Availability of coins of a given denomination (and age restriction!).
 *
 * We can't store this information with the denomination record, as one denomination
 * can be withdrawn with multiple age restrictions.
 */
export interface CoinAvailabilityRecord {
  currency: string;
  value: AmountString;
  denomPubHash: string;
  exchangeBaseUrl: string;

  /**
   * Age restriction on the coin, or 0 for no age restriction (or
   * denomination without age restriction support).
   */
  maxAge: number;

  /**
   * Number of fresh coins of this denomination that are available.
   */
  freshCoinCount: number;

  /**
   * Number of fresh coins that are available
   * and visible, i.e. the source transaction is in
   * a final state.
   */
  visibleCoinCount: number;

  /**
   * Number of coins that we expect to obtain via a pending refresh.
   */
  pendingRefreshOutputCount?: number;
}

export interface ContractTermsRecord {
  /**
   * Contract terms hash.
   */
  h: string;

  /**
   * Contract terms JSON.
   */
  contractTermsRaw: any;
}

export interface UserAttentionRecord {
  info: AttentionInfo;

  entityId: string;

  /**
   * When the notification was created.
   */
  created: DbPreciseTimestamp;

  /**
   * When the user mark this notification as read.
   */
  read: DbPreciseTimestamp | undefined;
}

export interface DbExchangeHandle {
  url: string;
  exchangeMasterPub: string;
}

export interface DbAuditorHandle {
  url: string;
  auditorPub: string;
}

export enum RefundGroupStatus {
  Pending = 0x0100_0000,
  Done = 0x0500_0000,
  Failed = 0x0501_0000,
  Aborted = 0x0503_0000,
  Expired = 0x0502_0000,
}

/**
 * Metadata about a group of refunds with the merchant.
 */
export interface RefundGroupRecord {
  status: RefundGroupStatus;

  /**
   * Timestamp when the refund group was created.
   */
  timestampCreated: DbPreciseTimestamp;

  proposalId: string;

  refundGroupId: string;

  refreshGroupId?: string;

  amountRaw: AmountString;

  /**
   * Estimated effective amount, based on
   * refund fees and refresh costs.
   */
  amountEffective: AmountString;
}

export enum RefundItemStatus {
  /**
   * Intermittent error that the merchant is
   * reporting from the exchange.
   *
   * We'll try again!
   */
  Pending = 0x0100_0000,
  /**
   * Refund was obtained successfully.
   */
  Done = 0x0500_0000,
  /**
   * Permanent error reported by the exchange
   * for the refund.
   */
  Failed = 0x0501_0000,
}

/**
 * Refund for a single coin in a payment with a merchant.
 */
export interface RefundItemRecord {
  /**
   * Auto-increment DB record ID.
   */
  id?: number;

  status: RefundItemStatus;

  /**
   * Mandatory since DB minor version 15.
   */
  proposalId?: string;

  refundGroupId: string;

  /**
   * Execution time as claimed by the merchant
   */
  executionTime: DbProtocolTimestamp;

  /**
   * Time when the wallet became aware of the refund.
   */
  obtainedTime: DbPreciseTimestamp;

  refundAmount: AmountString;

  coinPub: string;

  rtxid: number;
}

export function passthroughCodec<T>(): Codec<T> {
  return codecForAny();
}

export interface GlobalCurrencyAuditorRecord {
  id?: number;
  currency: string;
  auditorBaseUrl: string;
  auditorPub: string;
}

export interface GlobalCurrencyExchangeRecord {
  id?: number;
  currency: string;
  exchangeBaseUrl: string;
  exchangeMasterPub: string;
}

/**
 * Metadata for a transaction.
 * This object store is effectively a materialzed view of transactions gathered
 * from various other object stores.
 *
 * Primary key: transactionId
 */
export interface TransactionMetaRecord {
  /**
   * Transaction identifier.
   * Also determines the type of the transaction.
   */
  transactionId: string;

  timestamp: DbPreciseTimestamp;

  /**
   * Status of the transaction, matches the status enum of the
   * transaction of the type determined by the transaction ID.
   */
  status: number;

  /**
   * Exchanges involved in the transaction.
   */
  exchanges: string[];

  currency: string;
}

export enum DenomLossStatus {
  /**
   * Done indicates that the loss happened.
   */
  Done = 0x0500_0000,

  /**
   * Aborted in the sense that the loss was reversed.
   */
  Aborted = 0x0503_0001,
}

export interface DenomLossEventRecord {
  denomLossEventId: string;
  currency: string;
  denomPubHashes: string[];
  status: DenomLossStatus;
  timestampCreated: DbPreciseTimestamp;
  amount: string;
  eventType: DenomLossEventType;
  exchangeBaseUrl: string;
}

export interface CurrencyInfoRecord {
  /**
   * Stringified scope info.
   */
  scopeInfoStr: string;

  /**
   * Currency specification.
   */
  currencySpec: CurrencySpecification;

  /**
   * How did the currency info get set?
   */
  source: "exchange" | "user" | "preset";
}

export enum ExchangeMigrationReason {
  MismatchedBaseUrl = "mismatched-base-url",
  UnavailableOldUrl = "unavailable-old-url",
}

export interface ExchangeMigrationLogRecord {
  oldExchangeBaseUrl: string;
  newExchangeBaseUrl: string;
  timestamp: DbPreciseTimestamp;
  /**
   * Reason that triggered the exchange base URL migration.
   */
  reason: ExchangeMigrationReason;
}

export interface ExchangeBaseUrlFixupRecord {
  exchangeBaseUrl: string;
  replacement: string;
}

export enum DonationReceiptStatus {
  /**
   * Done indicates that the receipt
   * has been successfully submitted.
   */
  DoneSubmitted = 0x0500_0000,

  /**
   * Pending indicates that the
   * receipt still needs to be submitted.
   */
  Pending = 0x0100_0000,
}

/**
 * Record for donation planchets.
 */
export interface DonationPlanchetRecord {
  donauBaseUrl: string;
  udiNonce: HashCodeString;
  donorTaxIdHash: HashCodeString;
  donorHashSalt: string;
  donorTaxId: string;
  donationYear: number;
  proposalId: string;
  /** Index of this udi within the selected donation units for the purchase. */
  udiIndex: number;
  blindedUdi: BlindedUniqueDonationIdentifier;
  /** blinding key secret */
  bks: string;
  donationUnitPubHash: HashCodeString;
  value: AmountString;
}

/**
 * Record for donation receipts.
 */
export interface DonationReceiptRecord {
  status: DonationReceiptStatus;
  donauBaseUrl: string;
  udiNonce: HashCodeString;
  proposalId: string;
  donationYear: number;
  donationUnitPubHash: HashCodeString;
  donationUnitSig: DonationReceiptSignature;
  donorTaxIdHash: HashCodeString;
  donorHashSalt: string;
  donorTaxId: string;
  value: AmountString;
  /** Index of this udi within the selected donation units for the purchase. */
  udiIndex: number;
}

export interface DonationSummaryRecord {
  donauBaseUrl: string;
  legalDomain?: string;
  year: number;
  currency: string;
  amountReceiptsAvailable: AmountString;
  amountReceiptsSubmitted: AmountString;
}

/**
 * Record for contacts
 */
export interface ContactRecord {
  /**
   * The mailbox URI of this contact
   */
  mailboxBaseUri: string;

  /**
   * The mailbox identity
   */
  mailboxAddress: HashCodeString;

  /**
   * The alias of this contact
   */
  alias: string;
  /**
   * The type of the alias
   */
  aliasType: string;
  /**
   * The source of this alias
   */
  source: string;
}

/**
 * Schema definition for the IndexedDB
 * wallet database.
 */
export const WalletStoresV1 = {
  exchangeBaseUrlMigrationLog: describeStoreV2({
    recordCodec: passthroughCodec<ExchangeMigrationLogRecord>(),
    storeName: "exchangeBaseUrlMigrationLog",
    keyPath: ["oldExchangeBaseUrl", "newExchangeBaseUrl"],
    versionAdded: 18,
    indexes: {},
  }),
  exchangeBaseUrlFixups: describeStoreV2({
    recordCodec: passthroughCodec<ExchangeBaseUrlFixupRecord>(),
    storeName: "exchangeBaseUrlFixups",
    keyPath: "exchangeBaseUrl",
    versionAdded: 19,
    indexes: {},
  }),
  denomLossEvents: describeStoreV2({
    recordCodec: passthroughCodec<DenomLossEventRecord>(),
    storeName: "denomLossEvents",
    keyPath: "denomLossEventId",
    versionAdded: 9,
    indexes: {
      byCurrency: describeIndex("byCurrency", "currency", {
        versionAdded: 9,
      }),
      byStatus: describeIndex("byStatus", "status", {
        versionAdded: 10,
      }),
    },
  }),
  transactionsMeta: describeStoreV2({
    recordCodec: passthroughCodec<TransactionMetaRecord>(),
    storeName: "transactionsMeta",
    keyPath: "transactionId",
    versionAdded: 13,
    indexes: {
      byCurrency: describeIndex("byCurrency", "currency", {
        versionAdded: 13,
      }),
      byExchange: describeIndex("byExchange", "exchanges", {
        versionAdded: 13,
        multiEntry: true,
      }),
      byTimestamp: describeIndex("byTimestamp", "timestamp", {
        versionAdded: 13,
      }),
      byStatus: describeIndex("byStatus", "status", {
        versionAdded: 13,
      }),
    },
  }),
  currencyInfo: describeStoreV2({
    recordCodec: passthroughCodec<CurrencyInfoRecord>(),
    storeName: "currencyInfo",
    keyPath: "scopeInfoStr",
    versionAdded: 12,
  }),
  globalCurrencyAuditors: describeStoreV2({
    recordCodec: passthroughCodec<GlobalCurrencyAuditorRecord>(),
    storeName: "globalCurrencyAuditors",
    keyPath: "id",
    autoIncrement: true,
    versionAdded: 3,
    indexes: {
      byCurrencyAndUrlAndPub: describeIndex(
        "byCurrencyAndUrlAndPub",
        ["currency", "auditorBaseUrl", "auditorPub"],
        {
          unique: true,
          versionAdded: 4,
        },
      ),
    },
  }),
  globalCurrencyExchanges: describeStoreV2({
    recordCodec: passthroughCodec<GlobalCurrencyExchangeRecord>(),
    storeName: "globalCurrencyExchanges",
    keyPath: "id",
    autoIncrement: true,
    versionAdded: 3,
    indexes: {
      byCurrencyAndUrlAndPub: describeIndex(
        "byCurrencyAndUrlAndPub",
        ["currency", "exchangeBaseUrl", "exchangeMasterPub"],
        {
          unique: true,
          versionAdded: 4,
        },
      ),
    },
  }),
  coinAvailability: describeStore(
    "coinAvailability",
    describeContents<CoinAvailabilityRecord>({
      keyPath: ["exchangeBaseUrl", "denomPubHash", "maxAge"],
    }),
    {
      byExchangeAgeAvailability: describeIndex("byExchangeAgeAvailability", [
        "exchangeBaseUrl",
        "maxAge",
        "freshCoinCount",
      ]),
      byExchangeBaseUrl: describeIndex("byExchangeBaseUrl", "exchangeBaseUrl", {
        versionAdded: 8,
      }),
    },
  ),
  coinHistory: describeStoreV2({
    storeName: "coinHistory",
    recordCodec: passthroughCodec<CoinHistoryRecord>(),
    keyPath: "coinPub",
    versionAdded: 11,
  }),
  coins: describeStore(
    "coins",
    describeContents<CoinRecord>({
      keyPath: "coinPub",
    }),
    {
      byBaseUrl: describeIndex("byBaseUrl", "exchangeBaseUrl"),
      byDenomPubHash: describeIndex("byDenomPubHash", "denomPubHash"),
      byExchangeDenomPubHashAndAgeAndStatus: describeIndex(
        "byExchangeDenomPubHashAndAgeAndStatus",
        ["exchangeBaseUrl", "denomPubHash", "maxAge", "status"],
      ),
      byCoinEvHash: describeIndex("byCoinEvHash", "coinEvHash"),
      bySourceTransactionId: describeIndex(
        "bySourceTransactionId",
        "sourceTransactionId",
        {
          versionAdded: 9,
        },
      ),
    },
  ),
  tokens: describeStore(
    "tokens",
    describeContents<TokenRecord>({
      keyPath: "tokenUsePub",
      versionAdded: 16,
    }),
    {
      byTokenIssuePubHash: describeIndex(
        "byTokenIssuePubHash",
        "tokenIssuePubHash",
        {
          versionAdded: 17,
        },
      ),
      byPurchaseIdAndChoiceIndex: describeIndex(
        "byPurchaseIdAndChoiceIndex",
        ["purchaseId", "choiceIndex"],
        {
          versionAdded: 17,
        },
      ),
      byTokenFamilyHash: describeIndex("byTokenFamilyHash", "tokenFamilyHash", {
        versionAdded: 21,
      }),
    },
  ),
  slates: describeStore(
    "slates",
    describeContents<SlateRecord>({
      keyPath: "tokenUsePub",
      versionAdded: 16,
    }),
    {
      byPurchaseIdAndChoiceIndex: describeIndex(
        "byPurchaseIdAndChoiceIndex",
        ["purchaseId", "choiceIndex"],
        {
          versionAdded: 17,
        },
      ),
      byPurchaseIdAndChoiceIndexAndOutputIndex: describeIndex(
        "byPurchaseIdAndChoiceIndexAndOutputIndex",
        ["purchaseId", "choiceIndex", "outputIndex"],
        {
          versionAdded: 17,
        },
      ),
    },
  ),
  reserves: describeStore(
    "reserves",
    describeContents<ReserveRecord>({
      keyPath: "rowId",
      autoIncrement: true,
    }),
    {
      byReservePub: describeIndex("byReservePub", "reservePub", {}),
    },
  ),
  config: describeStore(
    "config",
    describeContents<ConfigRecord>({ keyPath: "key" }),
    {},
  ),
  denominations: describeStore(
    "denominations",
    describeContents<DenominationRecord>({
      keyPath: ["exchangeBaseUrl", "denomPubHash"],
    }),
    {
      byExchangeBaseUrl: describeIndex("byExchangeBaseUrl", "exchangeBaseUrl"),
    },
  ),
  exchanges: describeStore(
    "exchanges",
    describeContents<ExchangeEntryRecord>({
      keyPath: "baseUrl",
    }),
    {},
  ),
  exchangeDetails: describeStore(
    "exchangeDetails",
    describeContents<ExchangeDetailsRecord>({
      keyPath: "rowId",
      autoIncrement: true,
    }),
    {
      byExchangeBaseUrl: describeIndex("byExchangeBaseUrl", "exchangeBaseUrl", {
        versionAdded: 2,
      }),
      byPointer: describeIndex(
        "byDetailsPointer",
        ["exchangeBaseUrl", "currency", "masterPublicKey"],
        {
          unique: true,
        },
      ),
    },
  ),
  exchangeSignKeys: describeStore(
    "exchangeSignKeys",
    describeContents<ExchangeSignkeysRecord>({
      keyPath: ["exchangeDetailsRowId", "signkeyPub"],
    }),
    {
      byExchangeDetailsRowId: describeIndex("byExchangeDetailsRowId", [
        "exchangeDetailsRowId",
      ]),
    },
  ),
  contacts: describeStoreV2({
    recordCodec: passthroughCodec<ContactRecord>(),
    storeName: "contacts",
    keyPath: ["alias", "aliasType"],
    indexes: {},
    versionAdded: 24,
  }),
  mailboxMessages: describeStoreV2({
    recordCodec: passthroughCodec<MailboxMessageRecord>(),
    storeName: "mailboxMessages",
    keyPath: ["originMailboxBaseUrl", "talerUri"],
    indexes: {},
    versionAdded: 24,
  }),
  mailboxConfigurations: describeStoreV2({
    recordCodec: passthroughCodec<MailboxConfiguration>(),
    storeName: "mailboxConfigurations",
    keyPath: "mailboxBaseUrl",
    indexes: {},
    versionAdded: 24,
  }),
   refreshGroups: describeStore(
    "refreshGroups",
    describeContents<RefreshGroupRecord>({
      keyPath: "refreshGroupId",
    }),
    {
      byStatus: describeIndex("byStatus", "operationStatus"),
      byOriginatingTransactionId: describeIndex(
        "byOriginatingTransactionId",
        "originatingTransactionId",
        {
          versionAdded: 5,
        },
      ),
    },
  ),
  refreshSessions: describeStore(
    "refreshSessions",
    describeContents<RefreshSessionRecord>({
      keyPath: ["refreshGroupId", "coinIndex"],
    }),
    {
      byRefreshGroupId: describeIndex("byRefreshGroupId", "refreshGroupId", {
        versionAdded: 15,
      }),
    },
  ),
  recoupGroups: describeStore(
    "recoupGroups",
    describeContents<RecoupGroupRecord>({
      keyPath: "recoupGroupId",
    }),
    {
      byStatus: describeIndex("byStatus", "operationStatus", {
        versionAdded: 6,
      }),
      byExchangeBaseUrl: describeIndex("byExchangeBaseUrl", "exchangeBaseUrl", {
        versionAdded: 15,
      }),
    },
  ),
  purchases: describeStore(
    "purchases",
    describeContents<PurchaseRecord>({ keyPath: "proposalId" }),
    {
      byStatus: describeIndex("byStatus", "purchaseStatus"),
      byFulfillmentUrl: describeIndex(
        "byFulfillmentUrl",
        "download.fulfillmentUrl",
      ),
      byUrlAndOrderId: describeIndex("byUrlAndOrderId", [
        "merchantBaseUrl",
        "orderId",
      ]),
      byExchange: describeIndex("byExchange", "exchanges", {
        versionAdded: 15,
        multiEntry: true,
      }),
    },
  ),
  donationPlanchets: describeStoreV2({
    recordCodec: passthroughCodec<DonationPlanchetRecord>(),
    storeName: "donationPlanchets",
    keyPath: "udiNonce",
    versionAdded: 20,
    indexes: {
      byProposalId: describeIndex("byProposalId", "proposalId", {
        versionAdded: 20,
      }),
    },
  }),
  donationReceipts: describeStoreV2({
    recordCodec: passthroughCodec<DonationReceiptRecord>(),
    storeName: "donationReceipts",
    keyPath: "udiNonce",
    versionAdded: 20,
    indexes: {
      byStatus: describeIndex("byStatus", "status", {
        versionAdded: 20,
      }),
      byDonauBaseUrl: describeIndex("byDonauBaseUrl", "donauBaseUrl", {
        versionAdded: 23,
      }),
      byStatusAndDonauBaseUrl: describeIndex(
        "byStatusAndDonauBaseUrl",
        ["status", "donauBaseUrl"],
        {
          versionAdded: 23,
        },
      ),
    },
  }),
  donationSummaries: describeStoreV2({
    recordCodec: passthroughCodec<DonationSummaryRecord>(),
    storeName: "donationSummaries",
    keyPath: ["donauBaseUrl", "year", "currency"],
    versionAdded: 22,
    indexes: {},
  }),
  withdrawalGroups: describeStore(
    "withdrawalGroups",
    describeContents<WithdrawalGroupRecord>({
      keyPath: "withdrawalGroupId",
    }),
    {
      byStatus: describeIndex("byStatus", "status"),
      byExchangeBaseUrl: describeIndex("byExchangeBaseUrl", "exchangeBaseUrl", {
        versionAdded: 2,
      }),
      byTalerWithdrawUri: describeIndex(
        "byTalerWithdrawUri",
        "wgInfo.bankInfo.talerWithdrawUri",
      ),
    },
  ),
  planchets: describeStore(
    "planchets",
    describeContents<PlanchetRecord>({ keyPath: "coinPub" }),
    {
      byGroupAndIndex: describeIndex(
        "byGroupAndIndex",
        ["withdrawalGroupId", "coinIdx"],
        {
          unique: true,
        },
      ),
      byGroup: describeIndex("byGroup", "withdrawalGroupId"),
      byCoinEvHash: describeIndex("byCoinEv", "coinEvHash"),
    },
  ),
  bankWithdrawUris: describeStore(
    "bankWithdrawUris",
    describeContents<BankWithdrawUriRecord>({
      keyPath: "talerWithdrawUri",
    }),
    {
      byGroup: describeIndex("byGroup", "withdrawalGroupId"),
    },
  ),
  backupProviders: describeStore(
    "backupProviders",
    describeContents<BackupProviderRecord>({
      keyPath: "baseUrl",
    }),
    {
      byPaymentProposalId: describeIndex(
        "byPaymentProposalId",
        "paymentProposalIds",
        {
          multiEntry: true,
        },
      ),
    },
  ),
  depositGroups: describeStore(
    "depositGroups",
    describeContents<DepositGroupRecord>({
      keyPath: "depositGroupId",
    }),
    {
      byStatus: describeIndex("byStatus", "operationStatus"),
    },
  ),
  tombstones: describeStore(
    "tombstones",
    describeContents<TombstoneRecord>({ keyPath: "id" }),
    {},
  ),
  operationRetries: describeStore(
    "operationRetries",
    describeContents<OperationRetryRecord>({
      keyPath: "id",
    }),
    {},
  ),
  peerPushCredit: describeStore(
    "peerPushCredit",
    describeContents<PeerPushPaymentIncomingRecord>({
      keyPath: "peerPushCreditId",
    }),
    {
      byExchangeAndPurse: describeIndex("byExchangeAndPurse", [
        "exchangeBaseUrl",
        "pursePub",
      ]),
      byExchangeAndContractPriv: describeIndex(
        "byExchangeAndContractPriv",
        ["exchangeBaseUrl", "contractPriv"],
        {
          unique: true,
        },
      ),
      byWithdrawalGroupId: describeIndex(
        "byWithdrawalGroupId",
        "withdrawalGroupId",
        {},
      ),
      byStatus: describeIndex("byStatus", "status"),
    },
  ),
  peerPullDebit: describeStore(
    "peerPullDebit",
    describeContents<PeerPullPaymentIncomingRecord>({
      keyPath: "peerPullDebitId",
    }),
    {
      byExchangeAndPurse: describeIndex("byExchangeAndPurse", [
        "exchangeBaseUrl",
        "pursePub",
      ]),
      byExchangeAndContractPriv: describeIndex(
        "byExchangeAndContractPriv",
        ["exchangeBaseUrl", "contractPriv"],
        {
          unique: true,
        },
      ),
      byStatus: describeIndex("byStatus", "status"),
    },
  ),
  peerPullCredit: describeStore(
    "peerPullCredit",
    describeContents<PeerPullCreditRecord>({
      keyPath: "pursePub",
    }),
    {
      byStatus: describeIndex("byStatus", "status"),
      byWithdrawalGroupId: describeIndex(
        "byWithdrawalGroupId",
        "withdrawalGroupId",
        {},
      ),
    },
  ),
  peerPushDebit: describeStore(
    "peerPushDebit",
    describeContents<PeerPushDebitRecord>({
      keyPath: "pursePub",
    }),
    {
      byStatus: describeIndex("byStatus", "status"),
    },
  ),
  bankAccountsV2: describeStore(
    "bankAccountsV2",
    describeContents<BankAccountsRecord>({
      keyPath: "bankAccountId",
      versionAdded: 14,
    }),
    {
      byPaytoUri: describeIndex("byPaytoUri", "paytoUri", {
        versionAdded: 14,
      }),
    },
  ),
  contractTerms: describeStore(
    "contractTerms",
    describeContents<ContractTermsRecord>({
      keyPath: "h",
    }),
    {},
  ),
  refundGroups: describeStore(
    "refundGroups",
    describeContents<RefundGroupRecord>({
      keyPath: "refundGroupId",
    }),
    {
      byProposalId: describeIndex("byProposalId", "proposalId"),
      byStatus: describeIndex("byStatus", "status", {}),
    },
  ),
  refundItems: describeStore(
    "refundItems",
    describeContents<RefundItemRecord>({
      keyPath: "id",
      autoIncrement: true,
    }),
    {
      byCoinPubAndRtxid: describeIndex("byCoinPubAndRtxid", [
        "coinPub",
        "rtxid",
      ]),
      // FIXME: Why is this a list of index keys? Confusing!
      byRefundGroupId: describeIndex("byRefundGroupId", ["refundGroupId"]),
    },
  ),
  fixups: describeStore(
    "fixups",
    describeContents<FixupRecord>({
      keyPath: "fixupName",
    }),
    {},
  ),
  //
  // Obsolete stores, not used anymore
  //
  _obsolete_transactions: describeStoreV2({
    recordCodec: passthroughCodec<unknown>(),
    storeName: "transactions",
    keyPath: "transactionItem.transactionId",
    versionAdded: 7,
    indexes: {
      byCurrency: describeIndex("byCurrency", "currency", {
        versionAdded: 7,
      }),
      byExchange: describeIndex("byExchange", "exchanges", {
        versionAdded: 7,
        multiEntry: true,
      }),
    },
  }),
  _obsolete_bankAccounts: describeStore(
    "bankAccounts",
    describeContents<any>({
      keyPath: "uri",
    }),
    {},
  ),
  _obsolete_rewards: describeStore(
    "rewards",
    describeContents<any>({ keyPath: "walletRewardId" }),
    {
      byMerchantTipIdAndBaseUrl: describeIndex("byMerchantRewardIdAndBaseUrl", [
        "merchantRewardId",
        "merchantBaseUrl",
      ]),
      byStatus: describeIndex("byStatus", "status", {
        versionAdded: 8,
      }),
    },
  ),
  userAttention: describeStore(
    "userAttention",
    describeContents<UserAttentionRecord>({
      keyPath: ["entityId", "info.type"],
    }),
    {},
  ),
};

export type WalletDbStoresName = StoreNames<typeof WalletStoresV1>;
export type WalletDbStoresArr = Array<WalletDbStoresName>;

export type WalletDbReadWriteTransaction<StoresArr extends WalletDbStoresArr> =
  DbReadWriteTransaction<typeof WalletStoresV1, StoresArr>;

export type WalletDbReadOnlyTransaction<StoresArr extends WalletDbStoresArr> =
  DbReadOnlyTransaction<typeof WalletStoresV1, StoresArr>;

export type WalletDbAllStoresReadOnlyTransaction = DbReadOnlyTransaction<
  typeof WalletStoresV1,
  WalletDbStoresArr
>;

export type WalletDbAllStoresReadWriteTransaction = DbReadWriteTransaction<
  typeof WalletStoresV1,
  WalletDbStoresArr
>;

/**
 * An applied migration.
 */
export interface FixupRecord {
  fixupName: string;
}

/**
 * User accounts
 */
export interface BankAccountsRecord {
  /**
   * Opaque identifier for the bank account.
   */
  bankAccountId: string;

  /**
   * Payto URI of the bank account.
   */
  paytoUri: string;

  /**
   * User-defined label for the account.
   */
  label: string | undefined;

  currencies: string[] | undefined;

  /**
   * FIXME: Provide more info here.
   */
  kycCompleted: boolean;
}

export interface MetaConfigRecord {
  key: string;
  value: any;
}

export const walletMetadataStore = {
  metaConfig: describeStore(
    "metaConfig",
    describeContents<MetaConfigRecord>({ keyPath: "key" }),
    {},
  ),
};

export interface StoredBackupMeta {
  name: string;
}

export const StoredBackupStores = {
  backupMeta: describeStore(
    "backupMeta",
    describeContents<StoredBackupMeta>({ keyPath: "name" }),
    {},
  ),
  backupData: describeStore("backupData", describeContents<any>({}), {}),
};

export interface DbDumpRecord {
  /**
   * Key, serialized with structuredEncapsulated.
   *
   * Only present for out-of-line keys (i.e. no key path).
   */
  key?: any;
  /**
   * Value, serialized with structuredEncapsulated.
   */
  value: any;
}

export interface DbIndexDump {
  keyPath: string | string[];
  multiEntry: boolean;
  unique: boolean;
}

export interface DbStoreDump {
  keyPath?: string | string[];
  autoIncrement: boolean;
  indexes: { [indexName: string]: DbIndexDump };
  records: DbDumpRecord[];
}

export interface DbDumpDatabase {
  version: number;
  stores: { [storeName: string]: DbStoreDump };
}

export interface DbDump {
  databases: {
    [name: string]: DbDumpDatabase;
  };
}

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

export async function exportSingleDb(
  idb: IDBFactory,
  dbName: string,
): Promise<DbDumpDatabase> {
  const myDb = await openDatabase(
    idb,
    dbName,
    undefined,
    () => {
      logger.info(`unexpected onversionchange in exportSingleDb of ${dbName}`);
    },
    () => {
      logger.info(`unexpected onupgradeneeded in exportSingleDb of ${dbName}`);
    },
  );

  const singleDbDump: DbDumpDatabase = {
    version: myDb.version,
    stores: {},
  };

  return new Promise((resolve, reject) => {
    const tx = myDb.transaction(Array.from(myDb.objectStoreNames));
    tx.addEventListener("complete", () => {
      //myDb.close();
      resolve(singleDbDump);
    });
    // tslint:disable-next-line:prefer-for-of
    for (let i = 0; i < myDb.objectStoreNames.length; i++) {
      const name = myDb.objectStoreNames[i];
      const store = tx.objectStore(name);
      const storeDump: DbStoreDump = {
        autoIncrement: store.autoIncrement,
        keyPath: store.keyPath,
        indexes: {},
        records: [],
      };
      const indexNames = store.indexNames;
      for (let j = 0; j < indexNames.length; j++) {
        const idxName = indexNames[j];
        const index = store.index(idxName);
        storeDump.indexes[idxName] = {
          keyPath: index.keyPath,
          multiEntry: index.multiEntry,
          unique: index.unique,
        };
      }
      singleDbDump.stores[name] = storeDump;
      store.openCursor().addEventListener("success", (e: Event) => {
        const cursor = (e.target as any).result;
        if (cursor) {
          const rec: DbDumpRecord = {
            value: structuredEncapsulate(cursor.value),
          };
          // Only store key if necessary, i.e. when
          // the key is not stored as part of the object via
          // a key path.
          if (store.keyPath == null) {
            rec.key = structuredEncapsulate(cursor.key);
          }
          storeDump.records.push(rec);
          cursor.continue();
        }
      });
    }
  });
}

export async function exportDb(idb: IDBFactory): Promise<DbDump> {
  const dbDump: DbDump = {
    databases: {},
  };

  dbDump.databases[TALER_WALLET_META_DB_NAME] = await exportSingleDb(
    idb,
    TALER_WALLET_META_DB_NAME,
  );
  dbDump.databases[TALER_WALLET_MAIN_DB_NAME] = await exportSingleDb(
    idb,
    TALER_WALLET_MAIN_DB_NAME,
  );

  return dbDump;
}

async function recoverFromDump(
  db: IDBDatabase,
  dbDump: DbDumpDatabase,
): Promise<void> {
  const tx = db.transaction(Array.from(db.objectStoreNames), "readwrite");
  const txProm = promiseFromTransaction(tx);
  const storeNames = db.objectStoreNames;
  for (let i = 0; i < storeNames.length; i++) {
    const name = db.objectStoreNames[i];
    const storeDump = dbDump.stores[name];
    if (!storeDump) continue;
    await promiseFromRequest(tx.objectStore(name).clear());
    logger.info(`importing ${storeDump.records.length} records into ${name}`);
    for (let rec of storeDump.records) {
      await promiseFromRequest(tx.objectStore(name).put(rec.value, rec.key));
      logger.trace("importing record done");
    }
  }
  tx.commit();
  return await txProm;
}

function checkDbDump(x: any): x is DbDump {
  return "databases" in x;
}

export async function importDb(db: IDBDatabase, dumpJson: any): Promise<void> {
  const d = structuredRevive(dumpJson);
  if (checkDbDump(d)) {
    const walletDb = d.databases[TALER_WALLET_MAIN_DB_NAME];
    if (!walletDb) {
      throw Error(
        `unable to import, main wallet database (${TALER_WALLET_MAIN_DB_NAME}) not found`,
      );
    }
    await recoverFromDump(db, walletDb);
  } else {
    throw Error("unable to import, doesn't look like a valid DB dump");
  }
}

export interface FixupDescription {
  name: string;
  fn(tx: WalletDbReadOnlyTransaction<WalletDbStoresArr>): Promise<void>;
}

/**
 * Manual migrations between minor versions of the DB schema.
 */
export const walletDbFixups: FixupDescription[] = [
  // Can be removed 2025-11-01.
  {
    fn: fixup20250916TopsBlunder,
    name: "fixup20250916TopsBlunder",
  },
  // Removing this would cause old transactions
  // to show up under multiple exchanges
  {
    fn: fixup20250915TransactionsScope,
    name: "fixup20250915TransactionsScope",
  },
  // Can be removed, since hash is generated
  // on the fly for old tokens when missing.
  {
    fn: fixupTokenFamilyHash,
    name: "fixupTokenFamilyHash",
  },
];

/**
 * Some old payment transactions didn't correctly
 * set the involved exchanges.
 *
 * This fixup sets the exchanges of a payment transaction
 * based on the coin selection.
 */
async function fixup20250915TransactionsScope(
  tx: WalletDbAllStoresReadWriteTransaction,
): Promise<void> {
  await tx.purchases.iter().forEachAsync(async (rec) => {
    if (
      (rec.exchanges?.length ?? 0) == 0 &&
      rec.payInfo?.payCoinSelection != null
    ) {
      const pcs = rec.payInfo.payCoinSelection.coinPubs;
      const exchSet: Set<string> = new Set();
      for (const pc in pcs) {
        const coin = await tx.coins.get(pc);
        if (!coin) {
          continue;
        }
        exchSet.add(coin.exchangeBaseUrl);
      }
      rec.exchanges = [...exchSet];
      rec.exchanges.sort();
      if (rec.exchanges.length == 0) {
        // For old SPURLOS transactions, set exchange manually
        // when we can't infer it.
        if (
          rec.timestamp <= 1736942400000_000 &&
          rec.download?.currency === "SPURLOS"
        ) {
          rec.exchanges = ["https://exchange.taler.datenspuren.de/"];
        }
        logger.warn(
          `unable to fix up pay transaction ${rec.proposalId}, could not reconstruct exchanges`,
        );
      }
      await tx.purchases.put(rec);
    }
  });
}

/**
 * TOPS accidentally revoked keys.
 * Make sure to re-request keys and re-do denom selection
 * for withdrawal groups with zero selected denominations.
 */
async function fixup20250916TopsBlunder(
  tx: WalletDbAllStoresReadWriteTransaction,
): Promise<void> {
  const exchangeUrls = [
    "https://exchange.taler-ops.ch/",
    "https://exchange.stage.taler-ops.ch/",
  ];

  for (const exch of exchangeUrls) {
    const exchRec = await tx.exchanges.get(exch);
    if (!exchRec) {
      continue;
    }
    logger.info(
      `have exchange ${exch} in update state ${exchRec.updateStatus}`,
    );
    exchRec.lastUpdate = undefined;
    exchRec.lastKeysEtag = undefined;
    switch (exchRec.updateStatus) {
      case ExchangeEntryDbUpdateStatus.ReadyUpdate:
      case ExchangeEntryDbUpdateStatus.Ready:
        break;
      default:
        continue;
    }
    logger.info(`fixup: forcing update of exchange ${exch}`);
    exchRec.lastKeysEtag = undefined;
    exchRec.lastUpdate = undefined;
    exchRec.updateRetryCounter = undefined;
    exchRec.updateStatus = ExchangeEntryDbUpdateStatus.UnavailableUpdate;
    exchRec.nextUpdateStamp = timestampPreciseToDb(TalerPreciseTimestamp.now());
    await tx.exchanges.put(exchRec);
  }
  for (const exch of exchangeUrls) {
    const wgs =
      await tx.withdrawalGroups.indexes.byExchangeBaseUrl.getAll(exch);
    logger.info(
      `have ${wgs.length} withdrawal transactions that might need fixup`,
    );
    for (const wg of wgs) {
      logger.info(`status ${wg.status}`);
      logger.info(`denom sel ${j2s(wg.denomsSel)}`);
      if (wg.status !== WithdrawalGroupStatus.Done) {
        continue;
      }
      let numActiveDenoms = 0;
      if (wg.denomsSel?.selectedDenoms) {
        for (const sd of wg.denomsSel.selectedDenoms) {
          numActiveDenoms += sd.count - (sd.skip ?? 0);
        }
      }
      if (numActiveDenoms > 0) {
        continue;
      }
      logger.info(`updating withdrawal group status`);
      wg.status = WithdrawalGroupStatus.PendingQueryingStatus;
      await tx.withdrawalGroups.put(wg);
    }
  }
}

async function fixupTokenFamilyHash(
  tx: WalletDbAllStoresReadWriteTransaction,
): Promise<void> {
  const tokens = await tx.tokens.getAll();
  for (const token of tokens) {
    if (!token.tokenFamilyHash) {
      logger.info(`hashing token family info for ${token.tokenIssuePubHash}`);
      token.tokenFamilyHash = TokenRecord.hashInfo(token);
      await tx.tokens.put(token);
    }
  }
}

export async function applyFixups(
  db: DbAccess<typeof WalletStoresV1>,
): Promise<number> {
  logger.trace("applying fixups");
  let count = 0;
  await db.runAllStoresReadWriteTx({}, async (tx) => {
    for (const fixupInstruction of walletDbFixups) {
      logger.trace(`checking fixup ${fixupInstruction.name}`);
      const fixupRecord = await tx.fixups.get(fixupInstruction.name);
      if (fixupRecord) {
        continue;
      }
      logger.info(`applying DB fixup ${fixupInstruction.name}`);
      await fixupInstruction.fn(tx);
      await tx.fixups.put({
        fixupName: fixupInstruction.name,
      });
      count++;
    }
  });
  return count;
}

/**
 * Upgrade an IndexedDB in an upgrade transaction.
 *
 * The upgrade is made based on a store map, i.e. the metadata
 * structure that describes all the object stores and indexes.
 */
function upgradeFromStoreMap(
  storeMap: any, // FIXME: nail down type
  db: IDBDatabase,
  oldVersion: number,
  newVersion: number,
  upgradeTransaction: IDBTransaction,
): void {
  if (oldVersion === 0) {
    for (const n in storeMap) {
      const swi: StoreWithIndexes<
        any,
        StoreDescriptor<unknown>,
        any
      > = storeMap[n];
      const storeDesc: StoreDescriptor<unknown> = swi.store;
      const s = db.createObjectStore(swi.storeName, {
        autoIncrement: storeDesc.autoIncrement,
        keyPath: storeDesc.keyPath,
      });
      for (const indexName in swi.indexMap as any) {
        const indexDesc: IndexDescriptor = swi.indexMap[indexName];
        s.createIndex(indexDesc.name, indexDesc.keyPath, {
          multiEntry: indexDesc.multiEntry,
          unique: indexDesc.unique,
        });
      }
    }
    return;
  }
  if (oldVersion === newVersion) {
    return;
  }
  logger.info(`upgrading database from ${oldVersion} to ${newVersion}`);
  for (const n in storeMap) {
    const swi: StoreWithIndexes<any, StoreDescriptor<unknown>, any> = storeMap[
      n
    ];
    const storeDesc: StoreDescriptor<unknown> = swi.store;
    const storeAddedVersion = storeDesc.versionAdded ?? 0;
    let s: IDBObjectStore;
    if (storeAddedVersion > oldVersion) {
      // Be tolerant if object store already exists.
      // Probably means somebody deployed without
      // adding the "addedInVersion" attribute.
      if (!upgradeTransaction.objectStoreNames.contains(swi.storeName)) {
        try {
          s = db.createObjectStore(swi.storeName, {
            autoIncrement: storeDesc.autoIncrement,
            keyPath: storeDesc.keyPath,
          });
        } catch (e) {
          const moreInfo = e instanceof Error ? ` Reason: ${e.message}` : "";
          throw new Error(
            `Migration failed. Could not create store ${swi.storeName}.${moreInfo}`,
            // @ts-expect-error no support for options.cause yet
            { cause: e },
          );
        }
      }
    }

    s = upgradeTransaction.objectStore(swi.storeName);

    for (const indexName in swi.indexMap as any) {
      const indexDesc: IndexDescriptor = swi.indexMap[indexName];
      const indexAddedVersion = indexDesc.versionAdded ?? 0;
      if (indexAddedVersion <= oldVersion) {
        continue;
      }
      // Be tolerant if index already exists.
      // Probably means somebody deployed without
      // adding the "addedInVersion" attribute.
      if (!s.indexNames.contains(indexDesc.name)) {
        try {
          s.createIndex(indexDesc.name, indexDesc.keyPath, {
            multiEntry: indexDesc.multiEntry,
            unique: indexDesc.unique,
          });
        } catch (e) {
          const moreInfo = e instanceof Error ? ` Reason: ${e.message}` : "";
          throw Error(
            `Migration failed. Could not create index ${indexDesc.name}/${indexDesc.keyPath}. ${moreInfo}`,
            // @ts-expect-error no support for options.cause yet
            { cause: e },
          );
        }
      }
    }
  }
}

function promiseFromTransaction(transaction: IDBTransaction): Promise<void> {
  return new Promise<void>((resolve, reject) => {
    transaction.oncomplete = () => {
      resolve();
    };
    transaction.onerror = () => {
      reject();
    };
  });
}

export function promiseFromRequest(request: IDBRequest): Promise<any> {
  return new Promise((resolve, reject) => {
    request.onsuccess = () => {
      resolve(request.result);
    };
    request.onerror = () => {
      reject(request.error);
    };
  });
}

/**
 * Purge all data in the given database.
 */
export function clearDatabase(db: IDBDatabase): Promise<void> {
  // db.objectStoreNames is a DOMStringList, so we need to convert
  let stores: string[] = [];
  for (let i = 0; i < db.objectStoreNames.length; i++) {
    stores.push(db.objectStoreNames[i]);
  }
  logger.info(`clearing object stores: ${j2s(stores)}`);
  const tx = db.transaction(stores, "readwrite");
  for (const store of stores) {
    tx.objectStore(store).clear();
  }
  return promiseFromTransaction(tx);
}

function onTalerDbUpgradeNeeded(
  db: IDBDatabase,
  oldVersion: number,
  newVersion: number,
  upgradeTransaction: IDBTransaction,
) {
  upgradeFromStoreMap(
    WalletStoresV1,
    db,
    oldVersion,
    newVersion,
    upgradeTransaction,
  );
}

function onMetaDbUpgradeNeeded(
  db: IDBDatabase,
  oldVersion: number,
  newVersion: number,
  upgradeTransaction: IDBTransaction,
) {
  upgradeFromStoreMap(
    walletMetadataStore,
    db,
    oldVersion,
    newVersion,
    upgradeTransaction,
  );
}

function onStoredBackupsDbUpgradeNeeded(
  db: IDBDatabase,
  oldVersion: number,
  newVersion: number,
  upgradeTransaction: IDBTransaction,
) {
  upgradeFromStoreMap(
    StoredBackupStores,
    db,
    oldVersion,
    newVersion,
    upgradeTransaction,
  );
}

export async function openStoredBackupsDatabase(
  idbFactory: IDBFactory,
): Promise<DbAccess<typeof StoredBackupStores>> {
  const backupsDbHandle = await openDatabase(
    idbFactory,
    TALER_WALLET_STORED_BACKUPS_DB_NAME,
    1,
    () => {},
    onStoredBackupsDbUpgradeNeeded,
  );

  const handle = new DbAccessImpl(
    backupsDbHandle,
    StoredBackupStores,
    {},
    CancellationToken.CONTINUE,
  );
  return handle;
}

/**
 * Return a promise that resolves
 * to the taler wallet db.
 *
 * @param onVersionChange Called when another client concurrenctly connects to the database
 * with a higher version.
 */
export async function openTalerDatabase(
  idbFactory: IDBFactory,
  onVersionChange: () => void,
): Promise<IDBDatabase> {
  const metaDbHandle = await openDatabase(
    idbFactory,
    TALER_WALLET_META_DB_NAME,
    1,
    () => {},
    onMetaDbUpgradeNeeded,
  );

  const metaDb = new DbAccessImpl(
    metaDbHandle,
    walletMetadataStore,
    {},
    CancellationToken.CONTINUE,
  );
  let currentMainVersion: string | undefined;
  await metaDb.runReadWriteTx({ storeNames: ["metaConfig"] }, async (tx) => {
    const dbVersionRecord = await tx.metaConfig.get(CURRENT_DB_CONFIG_KEY);
    if (!dbVersionRecord) {
      currentMainVersion = TALER_WALLET_MAIN_DB_NAME;
      await tx.metaConfig.put({
        key: CURRENT_DB_CONFIG_KEY,
        value: TALER_WALLET_MAIN_DB_NAME,
      });
    } else {
      currentMainVersion = dbVersionRecord.value;
    }
  });

  if (currentMainVersion !== TALER_WALLET_MAIN_DB_NAME) {
    switch (currentMainVersion) {
      case "taler-wallet-main-v2":
      case "taler-wallet-main-v3":
      case "taler-wallet-main-v4": // temporary, we might migrate v4 later
      case "taler-wallet-main-v5":
      case "taler-wallet-main-v6":
      case "taler-wallet-main-v7":
      case "taler-wallet-main-v8":
      case "taler-wallet-main-v9":
        // We consider this a pre-release
        // development version, no migration is done.
        await metaDb.runReadWriteTx(
          { storeNames: ["metaConfig"] },
          async (tx) => {
            await tx.metaConfig.put({
              key: CURRENT_DB_CONFIG_KEY,
              value: TALER_WALLET_MAIN_DB_NAME,
            });
          },
        );
        break;
      default:
        throw Error(
          `major migration from database major=${currentMainVersion} not supported`,
        );
    }
  }

  const mainDbHandle = await openDatabase(
    idbFactory,
    TALER_WALLET_MAIN_DB_NAME,
    WALLET_DB_MINOR_VERSION,
    onVersionChange,
    onTalerDbUpgradeNeeded,
  );

  return mainDbHandle;
}

export async function deleteTalerDatabase(
  idbFactory: IDBFactory,
): Promise<void> {
  return new Promise((resolve, reject) => {
    const req = idbFactory.deleteDatabase(TALER_WALLET_MAIN_DB_NAME);
    req.onerror = () => reject(req.error);
    req.onsuccess = () => resolve();
  });
}

/**
 * High-level helpers to access the database.
 * Eventually all access to the database should
 * go through helpers in this namespace.
 */
export namespace WalletDbHelpers {
  export interface GetCurrencyInfoDbResult {
    /**
     * Currency specification.
     */
    currencySpec: CurrencySpecification;

    /**
     * How did the currency info get set?
     */
    source: "exchange" | "user" | "preset";
  }

  export interface StoreCurrencyInfoDbRequest {
    scopeInfo: ScopeInfo;
    currencySpec: CurrencySpecification;
    source: "exchange" | "user" | "preset";
  }

  export async function getCurrencyInfo(
    tx: WalletDbReadOnlyTransaction<["currencyInfo"]>,
    scopeInfo: ScopeInfo,
  ): Promise<GetCurrencyInfoDbResult | undefined> {
    const s = stringifyScopeInfo(scopeInfo);
    const res = await tx.currencyInfo.get(s);
    if (!res) {
      return undefined;
    }
    return {
      currencySpec: res.currencySpec,
      source: res.source,
    };
  }

  /**
   * Store currency info for a scope.
   *
   * Overrides existing currency infos.
   */
  export async function upsertCurrencyInfo(
    tx: WalletDbReadWriteTransaction<["currencyInfo"]>,
    req: StoreCurrencyInfoDbRequest,
  ): Promise<void> {
    await tx.currencyInfo.put({
      scopeInfoStr: stringifyScopeInfo(req.scopeInfo),
      currencySpec: req.currencySpec,
      source: req.source,
    });
  }

  export async function insertCurrencyInfoUnlessExists(
    tx: WalletDbReadWriteTransaction<["currencyInfo"]>,
    req: StoreCurrencyInfoDbRequest,
  ): Promise<void> {
    const scopeInfoStr = stringifyScopeInfo(req.scopeInfo);
    const oldRec = await tx.currencyInfo.get(scopeInfoStr);
    if (oldRec) {
      return;
    }
    await tx.currencyInfo.put({
      scopeInfoStr: stringifyScopeInfo(req.scopeInfo),
      currencySpec: req.currencySpec,
      source: req.source,
    });
  }

  export async function getConfig<T extends ConfigRecord["key"]>(
    tx: WalletDbReadWriteTransaction<["config"]>,
    key: T,
  ): Promise<Extract<ConfigRecord, { key: T }> | undefined> {
    return (await tx.config.get(key)) as any;
  }
}
