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

/**
 * Implementation of the deposit transaction.
 */

/**
 * Imports.
 */
import {
  AbsoluteTime,
  AmountJson,
  AmountString,
  Amounts,
  BatchDepositRequestCoin,
  CheckDepositRequest,
  CheckDepositResponse,
  CoinDepositPermission,
  CoinRefreshRequest,
  CreateDepositGroupRequest,
  CreateDepositGroupResponse,
  DepositGroupFees,
  DepositTransactionTrackingState,
  Duration,
  Exchange,
  ExchangeBatchDepositRequest,
  ExchangeRefundRequest,
  HttpStatusCode,
  KycAuthTransferInfo,
  Logger,
  MerchantContractTerms,
  MerchantContractTermsV0,
  MerchantContractVersion,
  NotificationType,
  RefreshReason,
  ScopeInfo,
  SelectedProspectiveCoin,
  TalerError,
  TalerErrorCode,
  TalerErrorDetail,
  TalerPreciseTimestamp,
  TalerProtocolTimestamp,
  TrackTransaction,
  Transaction,
  TransactionAction,
  TransactionIdStr,
  TransactionMajorState,
  TransactionMinorState,
  TransactionState,
  TransactionType,
  URL,
  DownloadedContractData,
  WalletNotification,
  assertUnreachable,
  canonicalJson,
  checkDbInvariant,
  checkLogicInvariant,
  codecForBatchDepositSuccess,
  codecForLegitimizationNeededResponse,
  codecForMerchantContractTerms,
  codecForTackTransactionAccepted,
  codecForTackTransactionWired,
  encodeCrock,
  getRandomBytes,
  hashNormalizedPaytoUri,
  hashWire,
  j2s,
  parsePaytoUri,
} from "@gnu-taler/taler-util";
import {
  readResponseJsonOrThrow,
  readSuccessResponseJsonOrThrow,
  readTalerErrorResponse,
  throwUnexpectedRequestError,
} from "@gnu-taler/taler-util/http";
import { selectPayCoins, selectPayCoinsInTx } from "./coinSelection.js";
import {
  PendingTaskType,
  TaskIdStr,
  TaskRunResult,
  TransactionContext,
  cancelableFetch,
  cancelableLongPoll,
  constructTaskIdentifier,
  genericWaitForState,
  runWithClientCancellation,
  spendCoins,
} from "./common.js";
import {
  DepositElementStatus,
  DepositGroupRecord,
  DepositInfoPerExchange,
  DepositOperationStatus,
  DepositTrackingInfo,
  RefreshOperationStatus,
  WalletDbAllStoresReadOnlyTransaction,
  WalletDbReadWriteTransaction,
  WithdrawalGroupStatus,
  timestampAbsoluteFromDb,
  timestampPreciseFromDb,
  timestampPreciseToDb,
  timestampProtocolFromDb,
  timestampProtocolToDb,
} from "./db.js";
import {
  ReadyExchangeSummary,
  checkExchangeInScopeTx,
  fetchFreshExchange,
  getExchangeWireDetailsInTx,
  getExchangeWireFee,
  getScopeForAllExchanges,
} from "./exchanges.js";
import { EddsaKeyPairStrings, SignContractTermsHashResponse } from "./index.js";
import {
  GenericKycStatusReq,
  checkDepositHardLimitExceeded,
  getDepositLimitInfo,
  isKycOperationDue,
  runKycCheckAlgo,
} from "./kyc.js";
import {
  generateDepositPermissions,
  getTotalPaymentCost,
} from "./pay-merchant.js";
import {
  RefreshTransactionContext,
  createRefreshGroup,
  getTotalRefreshCost,
} from "./refresh.js";
import {
  BalanceEffect,
  applyNotifyTransition,
  constructTransactionIdentifier,
  isUnsuccessfulTransaction,
  parseTransactionIdentifier,
} from "./transactions.js";
import { WalletExecutionContext, getDenomInfo } from "./wallet.js";
import { augmentPaytoUrisForKycTransfer } from "./withdraw.js";

/**
 * Logger.
 */
const logger = new Logger("deposits.ts");

export class DepositTransactionContext implements TransactionContext {
  readonly transactionId: TransactionIdStr;
  readonly taskId: TaskIdStr;

  constructor(
    public wex: WalletExecutionContext,
    public depositGroupId: string,
  ) {
    this.transactionId = constructTransactionIdentifier({
      tag: TransactionType.Deposit,
      depositGroupId,
    });
    this.taskId = constructTaskIdentifier({
      tag: PendingTaskType.Deposit,
      depositGroupId,
    });
  }

  /**
   * Get the full transaction details for the transaction.
   *
   * Returns undefined if the transaction is in a state where we do not have a
   * transaction item (e.g. if it was deleted).
   */
  async lookupFullTransaction(
    tx: WalletDbAllStoresReadOnlyTransaction,
  ): Promise<Transaction | undefined> {
    const dg = await tx.depositGroups.get(this.depositGroupId);
    if (!dg) {
      return undefined;
    }
    const ort = await tx.operationRetries.get(this.taskId);

    let deposited = true;
    if (dg.statusPerCoin) {
      for (const d of dg.statusPerCoin) {
        if (d == DepositElementStatus.DepositPending) {
          deposited = false;
        }
      }
    } else {
      deposited = false;
    }

    const trackingState: DepositTransactionTrackingState[] = [];

    for (const ts of Object.values(dg.trackingState ?? {})) {
      trackingState.push({
        amountRaw: ts.amountRaw,
        timestampExecuted: timestampProtocolFromDb(ts.timestampExecuted),
        wireFee: ts.wireFee,
        wireTransferId: ts.wireTransferId,
      });
    }

    let wireTransferProgress = 0;
    if (dg.statusPerCoin) {
      wireTransferProgress =
        (100 *
          dg.statusPerCoin.reduce(
            (prev, cur) => prev + (cur === DepositElementStatus.Wired ? 1 : 0),
            0,
          )) /
        dg.statusPerCoin.length;
    }

    let kycAuthTransferInfo: KycAuthTransferInfo | undefined = undefined;
    let kycUrl: string | undefined;

    switch (dg.operationStatus) {
      case DepositOperationStatus.PendingAggregateKyc:
      case DepositOperationStatus.SuspendedAggregateKyc:
      case DepositOperationStatus.PendingDepositKyc:
      case DepositOperationStatus.SuspendedDepositKyc: {
        if (!dg.kycInfo) {
          break;
        }
        kycUrl = new URL(
          `kyc-spa/${dg.kycInfo.accessToken}`,
          dg.kycInfo.exchangeBaseUrl,
        ).href;
        break;
      }
      case DepositOperationStatus.PendingDepositKycAuth:
      case DepositOperationStatus.SuspendedDepositKycAuth: {
        if (!dg.kycInfo) {
          break;
        }
        const plainCreditPaytoUris: string[] = [];
        const exchangeWire = await getExchangeWireDetailsInTx(
          tx,
          dg.kycInfo.exchangeBaseUrl,
        );
        if (exchangeWire) {
          for (const acc of exchangeWire.wireInfo.accounts) {
            if (acc.conversion_url) {
              // Conversion accounts do not work for KYC auth!
              continue;
            }
            plainCreditPaytoUris.push(acc.payto_uri);
          }
        }
        // FIXME: Query tiny amount from exchange.
        const amount: AmountString = `${dg.currency}:0.01`;
        kycAuthTransferInfo = {
          debitPaytoUri: dg.wire.payto_uri,
          accountPub: dg.merchantPub,
          amount,
          creditPaytoUris: augmentPaytoUrisForKycTransfer(
            plainCreditPaytoUris,
            dg.merchantPub,
            amount,
          ),
        };
        break;
      }
    }

    const txState = computeDepositTransactionStatus(dg);
    return {
      type: TransactionType.Deposit,
      txState,
      stId: dg.operationStatus,
      scopes: await getScopeForAllExchanges(
        tx,
        !dg.infoPerExchange ? [] : Object.keys(dg.infoPerExchange),
      ),
      txActions: computeDepositTransactionActions(dg),
      amountRaw: Amounts.stringify(dg.counterpartyEffectiveDepositAmount),
      amountEffective: isUnsuccessfulTransaction(txState)
        ? Amounts.stringify(Amounts.zeroOfAmount(dg.totalPayCost))
        : Amounts.stringify(dg.totalPayCost),
      timestamp: timestampPreciseFromDb(dg.timestampCreated),
      targetPaytoUri: dg.wire.payto_uri,
      wireTransferDeadline: timestampProtocolFromDb(dg.wireTransferDeadline),
      transactionId: constructTransactionIdentifier({
        tag: TransactionType.Deposit,
        depositGroupId: dg.depositGroupId,
      }),
      accountPub: dg.merchantPub,
      wireTransferProgress,
      depositGroupId: dg.depositGroupId,
      trackingState,
      deposited,
      abortReason: dg.abortReason,
      failReason: dg.failReason,
      kycAuthTransferInfo,
      kycPaytoHash: dg.kycInfo?.paytoHash,
      kycAccessToken: dg.kycInfo?.accessToken,
      kycUrl,
      ...(ort?.lastError ? { error: ort.lastError } : {}),
    };
  }

  /**
   * Update the metadata of the transaction in the database.
   */
  async updateTransactionMeta(
    tx: WalletDbReadWriteTransaction<["depositGroups", "transactionsMeta"]>,
  ): Promise<void> {
    const depositRec = await tx.depositGroups.get(this.depositGroupId);
    if (!depositRec) {
      await tx.transactionsMeta.delete(this.transactionId);
      return;
    }
    await tx.transactionsMeta.put({
      transactionId: this.transactionId,
      status: depositRec.operationStatus,
      timestamp: depositRec.timestampCreated,
      currency: depositRec.currency,
      exchanges: Object.keys(depositRec.infoPerExchange ?? {}),
    });
  }

  async deleteTransaction(): Promise<void> {
    const res = await this.wex.db.runReadWriteTx(
      {
        storeNames: ["depositGroups", "tombstones", "transactionsMeta"],
      },
      async (tx) => {
        return this.deleteTransactionInTx(tx);
      },
    );
    for (const notif of res.notifs) {
      this.wex.ws.notify(notif);
    }
  }

  async deleteTransactionInTx(
    tx: WalletDbReadWriteTransaction<
      ["depositGroups", "tombstones", "transactionsMeta"]
    >,
  ): Promise<{ notifs: WalletNotification[] }> {
    const notifs: WalletNotification[] = [];
    const rec = await tx.depositGroups.get(this.depositGroupId);
    if (!rec) {
      return { notifs };
    }
    const oldTxState = computeDepositTransactionStatus(rec);
    await tx.depositGroups.delete(rec.depositGroupId);
    await this.updateTransactionMeta(tx);
    notifs.push({
      type: NotificationType.TransactionStateTransition,
      transactionId: this.transactionId,
      oldTxState,
      newTxState: {
        major: TransactionMajorState.Deleted,
      },
      newStId: -1,
    });
    return { notifs };
  }

  async suspendTransaction(): Promise<void> {
    const { wex, depositGroupId, transactionId, taskId: retryTag } = this;
    await wex.db.runReadWriteTx(
      { storeNames: ["depositGroups", "transactionsMeta"] },
      async (tx) => {
        const dg = await tx.depositGroups.get(depositGroupId);
        if (!dg) {
          logger.warn(
            `can't suspend deposit group, depositGroupId=${depositGroupId} not found`,
          );
          return undefined;
        }
        const oldState = computeDepositTransactionStatus(dg);
        const oldStId = dg.operationStatus;
        let newOpStatus: DepositOperationStatus | undefined;
        switch (dg.operationStatus) {
          case DepositOperationStatus.AbortedDeposit:
          case DepositOperationStatus.FailedDeposit:
          case DepositOperationStatus.FailedTrack:
          case DepositOperationStatus.Finished:
          case DepositOperationStatus.SuspendedAborting:
          case DepositOperationStatus.SuspendedAggregateKyc:
          case DepositOperationStatus.SuspendedDeposit:
          case DepositOperationStatus.SuspendedDepositKyc:
          case DepositOperationStatus.LegacySuspendedTrack:
          case DepositOperationStatus.SuspendedDepositKycAuth:
          case DepositOperationStatus.SuspendedFinalizingTrack:
            break;
          case DepositOperationStatus.FinalizingTrack:
            newOpStatus = DepositOperationStatus.SuspendedFinalizingTrack;
            break;
          case DepositOperationStatus.PendingDepositKyc:
            newOpStatus = DepositOperationStatus.SuspendedDepositKyc;
            break;
          case DepositOperationStatus.PendingDeposit:
            newOpStatus = DepositOperationStatus.SuspendedDeposit;
            break;
          case DepositOperationStatus.PendingAggregateKyc:
            newOpStatus = DepositOperationStatus.SuspendedAggregateKyc;
            break;
          case DepositOperationStatus.LegacyPendingTrack:
            newOpStatus = DepositOperationStatus.LegacySuspendedTrack;
            break;
          case DepositOperationStatus.Aborting:
            newOpStatus = DepositOperationStatus.SuspendedAborting;
            break;
          case DepositOperationStatus.PendingDepositKycAuth:
            newOpStatus = DepositOperationStatus.SuspendedDepositKycAuth;
            break;
          default:
            assertUnreachable(dg.operationStatus);
        }
        if (!newOpStatus) {
          return undefined;
        }
        dg.operationStatus = newOpStatus;
        await tx.depositGroups.put(dg);
        await this.updateTransactionMeta(tx);
        applyNotifyTransition(tx.notify, transactionId, {
          oldTxState: oldState,
          newTxState: computeDepositTransactionStatus(dg),
          balanceEffect: BalanceEffect.None,
          newStId: dg.operationStatus,
          oldStId,
        });
      },
    );
    wex.taskScheduler.stopShepherdTask(retryTag);
  }

  async abortTransaction(reason?: TalerErrorDetail): Promise<void> {
    const { wex, depositGroupId, transactionId, taskId: retryTag } = this;
    await wex.db.runReadWriteTx(
      { storeNames: ["depositGroups", "transactionsMeta"] },
      async (tx) => {
        const dg = await tx.depositGroups.get(depositGroupId);
        if (!dg) {
          logger.warn(
            `can't suspend deposit group, depositGroupId=${depositGroupId} not found`,
          );
          return undefined;
        }
        const oldState = computeDepositTransactionStatus(dg);
        const oldStId = dg.operationStatus;
        switch (dg.operationStatus) {
          case DepositOperationStatus.PendingDepositKyc:
          case DepositOperationStatus.SuspendedDepositKyc:
          case DepositOperationStatus.PendingDepositKycAuth:
          case DepositOperationStatus.SuspendedDepositKycAuth:
          case DepositOperationStatus.PendingDeposit:
          case DepositOperationStatus.SuspendedDeposit: {
            dg.operationStatus = DepositOperationStatus.Aborting;
            dg.abortReason = reason;
            await tx.depositGroups.put(dg);
            await this.updateTransactionMeta(tx);
            applyNotifyTransition(tx.notify, transactionId, {
              oldTxState: oldState,
              newTxState: computeDepositTransactionStatus(dg),
              balanceEffect: BalanceEffect.Any,
              oldStId,
              newStId: dg.operationStatus,
            });
            return;
          }
          case DepositOperationStatus.FinalizingTrack:
          case DepositOperationStatus.SuspendedFinalizingTrack:
          case DepositOperationStatus.LegacyPendingTrack:
          case DepositOperationStatus.LegacySuspendedTrack:
          case DepositOperationStatus.AbortedDeposit:
          case DepositOperationStatus.Aborting:
          case DepositOperationStatus.FailedDeposit:
          case DepositOperationStatus.FailedTrack:
          case DepositOperationStatus.Finished:
          case DepositOperationStatus.SuspendedAborting:
          case DepositOperationStatus.PendingAggregateKyc:
          case DepositOperationStatus.SuspendedAggregateKyc:
            break;
          default:
            assertUnreachable(dg.operationStatus);
        }
        return undefined;
      },
    );
    wex.taskScheduler.stopShepherdTask(retryTag);
    wex.taskScheduler.startShepherdTask(retryTag);
  }

  async resumeTransaction(): Promise<void> {
    const { wex, depositGroupId, transactionId, taskId: retryTag } = this;
    await wex.db.runReadWriteTx(
      { storeNames: ["depositGroups", "transactionsMeta"] },
      async (tx) => {
        const dg = await tx.depositGroups.get(depositGroupId);
        if (!dg) {
          logger.warn(
            `can't resume deposit group, depositGroupId=${depositGroupId} not found`,
          );
          return;
        }
        const oldState = computeDepositTransactionStatus(dg);
        const oldStId = dg.operationStatus;
        let newOpStatus: DepositOperationStatus | undefined;
        switch (dg.operationStatus) {
          case DepositOperationStatus.AbortedDeposit:
          case DepositOperationStatus.Aborting:
          case DepositOperationStatus.FailedDeposit:
          case DepositOperationStatus.FailedTrack:
          case DepositOperationStatus.Finished:
          case DepositOperationStatus.PendingAggregateKyc:
          case DepositOperationStatus.PendingDeposit:
          case DepositOperationStatus.PendingDepositKyc:
          case DepositOperationStatus.LegacyPendingTrack:
          case DepositOperationStatus.PendingDepositKycAuth:
          case DepositOperationStatus.FinalizingTrack:
            break;
          case DepositOperationStatus.SuspendedDepositKyc:
            newOpStatus = DepositOperationStatus.PendingDepositKyc;
            break;
          case DepositOperationStatus.SuspendedDeposit:
            newOpStatus = DepositOperationStatus.PendingDeposit;
            break;
          case DepositOperationStatus.SuspendedAborting:
            newOpStatus = DepositOperationStatus.Aborting;
            break;
          case DepositOperationStatus.SuspendedAggregateKyc:
            newOpStatus = DepositOperationStatus.PendingAggregateKyc;
            break;
          case DepositOperationStatus.LegacySuspendedTrack:
            newOpStatus = DepositOperationStatus.LegacyPendingTrack;
            break;
          case DepositOperationStatus.SuspendedDepositKycAuth:
            newOpStatus = DepositOperationStatus.PendingDepositKycAuth;
            break;
          case DepositOperationStatus.SuspendedFinalizingTrack:
            newOpStatus = DepositOperationStatus.FinalizingTrack;
            break;
          default:
            assertUnreachable(dg.operationStatus);
        }
        if (!newOpStatus) {
          return undefined;
        }
        dg.operationStatus = newOpStatus;
        await tx.depositGroups.put(dg);
        await this.updateTransactionMeta(tx);
        applyNotifyTransition(tx.notify, transactionId, {
          oldTxState: oldState,
          newTxState: computeDepositTransactionStatus(dg),
          balanceEffect: BalanceEffect.None,
          newStId: dg.operationStatus,
          oldStId,
        });
      },
    );
    wex.taskScheduler.startShepherdTask(retryTag);
  }

  async failTransaction(reason?: TalerErrorDetail): Promise<void> {
    const { wex, depositGroupId, transactionId, taskId } = this;
    const transitionInfo = await wex.db.runReadWriteTx(
      { storeNames: ["depositGroups", "transactionsMeta"] },
      async (tx) => {
        const dg = await tx.depositGroups.get(depositGroupId);
        if (!dg) {
          logger.warn(
            `can't cancel aborting deposit group, depositGroupId=${depositGroupId} not found`,
          );
          return undefined;
        }
        const oldState = computeDepositTransactionStatus(dg);
        const oldStId = dg.operationStatus;
        let newState: DepositOperationStatus;
        switch (dg.operationStatus) {
          case DepositOperationStatus.PendingAggregateKyc:
          case DepositOperationStatus.SuspendedAggregateKyc:
          case DepositOperationStatus.SuspendedAborting:
          case DepositOperationStatus.Aborting: {
            newState = DepositOperationStatus.FailedDeposit;
            break;
          }
          case DepositOperationStatus.LegacyPendingTrack:
          case DepositOperationStatus.LegacySuspendedTrack: {
            newState = DepositOperationStatus.FailedTrack;
            break;
          }
          case DepositOperationStatus.AbortedDeposit:
          case DepositOperationStatus.FailedDeposit:
          case DepositOperationStatus.FailedTrack:
          case DepositOperationStatus.Finished:
          case DepositOperationStatus.PendingDeposit:
          case DepositOperationStatus.PendingDepositKyc:
          case DepositOperationStatus.PendingDepositKycAuth:
          case DepositOperationStatus.SuspendedDeposit:
          case DepositOperationStatus.SuspendedDepositKyc:
          case DepositOperationStatus.SuspendedDepositKycAuth:
          case DepositOperationStatus.FinalizingTrack:
          case DepositOperationStatus.SuspendedFinalizingTrack:
            throw Error("failing not supported in current state");
          default:
            assertUnreachable(dg.operationStatus);
        }
        dg.operationStatus = newState;
        dg.failReason = reason;
        await tx.depositGroups.put(dg);
        await this.updateTransactionMeta(tx);
        applyNotifyTransition(tx.notify, transactionId, {
          oldTxState: oldState,
          newTxState: computeDepositTransactionStatus(dg),
          balanceEffect: BalanceEffect.Any,
          newStId: dg.operationStatus,
          oldStId,
        });
      },
    );
    wex.taskScheduler.stopShepherdTask(taskId);
  }
}

/**
 * Get the (DD37-style) transaction status based on the
 * database record of a deposit group.
 */
export function computeDepositTransactionStatus(
  dg: DepositGroupRecord,
): TransactionState {
  switch (dg.operationStatus) {
    case DepositOperationStatus.Finished:
      return {
        major: TransactionMajorState.Done,
      };
    case DepositOperationStatus.PendingDeposit:
      return {
        major: TransactionMajorState.Pending,
        minor: TransactionMinorState.Deposit,
      };
    case DepositOperationStatus.PendingAggregateKyc:
      if (dg.kycInfo?.accessToken != null) {
        return {
          major: TransactionMajorState.Pending,
          minor: TransactionMinorState.KycRequired,
        };
      } else {
        return {
          major: TransactionMajorState.Pending,
          minor: TransactionMinorState.KycInit,
        };
      }
    case DepositOperationStatus.LegacyPendingTrack:
      return {
        major: TransactionMajorState.Pending,
        minor: TransactionMinorState.Track,
      };
    case DepositOperationStatus.SuspendedAggregateKyc:
      if (dg.kycInfo?.accessToken != null) {
        return {
          major: TransactionMajorState.Suspended,
          minor: TransactionMinorState.KycRequired,
        };
      } else {
        return {
          major: TransactionMajorState.Suspended,
          minor: TransactionMinorState.KycInit,
        };
      }
    case DepositOperationStatus.LegacySuspendedTrack:
      return {
        major: TransactionMajorState.Suspended,
        minor: TransactionMinorState.Track,
      };
    case DepositOperationStatus.SuspendedDeposit:
      return {
        major: TransactionMajorState.Suspended,
      };
    case DepositOperationStatus.Aborting:
      return {
        major: TransactionMajorState.Aborting,
      };
    case DepositOperationStatus.AbortedDeposit:
      return {
        major: TransactionMajorState.Aborted,
      };
    case DepositOperationStatus.FailedDeposit:
      return {
        major: TransactionMajorState.Failed,
        minor: TransactionMinorState.Deposit,
      };
    case DepositOperationStatus.FailedTrack:
      return {
        major: TransactionMajorState.Failed,
        minor: TransactionMinorState.Track,
      };
    case DepositOperationStatus.SuspendedAborting:
      return {
        major: TransactionMajorState.SuspendedAborting,
      };
    case DepositOperationStatus.PendingDepositKyc:
      if (dg.kycInfo?.accessToken != null) {
        return {
          major: TransactionMajorState.Pending,
          // We lie to the UI by hiding the specific KYC state.
          minor: TransactionMinorState.KycRequired,
        };
      } else {
        return {
          major: TransactionMajorState.Pending,
          minor: TransactionMinorState.KycInit,
        };
      }
    case DepositOperationStatus.SuspendedDepositKyc:
      if (dg.kycInfo?.accessToken != null) {
        return {
          major: TransactionMajorState.Suspended,
          // We lie to the UI by hiding the specific KYC state.
          minor: TransactionMinorState.KycRequired,
        };
      } else {
        return {
          major: TransactionMajorState.Suspended,
          minor: TransactionMinorState.KycInit,
        };
      }
    case DepositOperationStatus.PendingDepositKycAuth:
      return {
        major: TransactionMajorState.Pending,
        // We lie to the UI by hiding the specific KYC state.
        minor: TransactionMinorState.KycAuthRequired,
      };
    case DepositOperationStatus.SuspendedDepositKycAuth:
      return {
        major: TransactionMajorState.Suspended,
        // We lie to the UI by hiding the specific KYC state.
        minor: TransactionMinorState.KycAuthRequired,
      };
    case DepositOperationStatus.FinalizingTrack: {
      return {
        major: TransactionMajorState.Finalizing,
        minor: TransactionMinorState.Track,
      };
    }
    case DepositOperationStatus.SuspendedFinalizingTrack:
      return {
        major: TransactionMajorState.SuspendedFinalizing,
        minor: TransactionMinorState.Track,
      };
    default:
      assertUnreachable(dg.operationStatus);
  }
}

/**
 * Compute the possible actions possible on a deposit transaction
 * based on the current transaction state.
 */
export function computeDepositTransactionActions(
  dg: DepositGroupRecord,
): TransactionAction[] {
  switch (dg.operationStatus) {
    case DepositOperationStatus.Finished:
      return [TransactionAction.Delete];
    case DepositOperationStatus.PendingDeposit:
      return [
        TransactionAction.Retry,
        TransactionAction.Suspend,
        TransactionAction.Abort,
      ];
    case DepositOperationStatus.SuspendedDeposit:
      return [TransactionAction.Resume];
    case DepositOperationStatus.Aborting:
      return [
        TransactionAction.Retry,
        TransactionAction.Fail,
        TransactionAction.Suspend,
      ];
    case DepositOperationStatus.AbortedDeposit:
      return [TransactionAction.Delete];
    case DepositOperationStatus.FailedDeposit:
    case DepositOperationStatus.FailedTrack:
      return [TransactionAction.Delete];
    case DepositOperationStatus.SuspendedAborting:
      return [TransactionAction.Resume, TransactionAction.Fail];
    case DepositOperationStatus.PendingAggregateKyc:
      return [
        TransactionAction.Retry,
        TransactionAction.Suspend,
        TransactionAction.Fail,
      ];
    case DepositOperationStatus.LegacyPendingTrack:
      return [
        TransactionAction.Retry,
        TransactionAction.Suspend,
        TransactionAction.Fail,
      ];
    case DepositOperationStatus.SuspendedAggregateKyc:
      return [TransactionAction.Resume, TransactionAction.Fail];
    case DepositOperationStatus.LegacySuspendedTrack:
      return [TransactionAction.Resume, TransactionAction.Fail];
    case DepositOperationStatus.PendingDepositKyc:
      return [TransactionAction.Suspend, TransactionAction.Abort];
    case DepositOperationStatus.SuspendedDepositKyc:
      return [TransactionAction.Resume, TransactionAction.Abort];
    case DepositOperationStatus.PendingDepositKycAuth:
      return [TransactionAction.Suspend, TransactionAction.Abort];
    case DepositOperationStatus.SuspendedDepositKycAuth:
      return [TransactionAction.Resume, TransactionAction.Abort];
    case DepositOperationStatus.FinalizingTrack:
      return [TransactionAction.Suspend, TransactionAction.Delete];
    case DepositOperationStatus.SuspendedFinalizingTrack:
      return [TransactionAction.Resume, TransactionAction.Delete];
    default:
      assertUnreachable(dg.operationStatus);
  }
}

async function refundDepositGroup(
  wex: WalletExecutionContext,
  depositGroup: DepositGroupRecord,
): Promise<TaskRunResult> {
  const ctx = new DepositTransactionContext(wex, depositGroup.depositGroupId);
  const currency = Amounts.currencyOf(depositGroup.totalPayCost);
  const statusPerCoin = depositGroup.statusPerCoin;
  const payCoinSelection = depositGroup.payCoinSelection;

  if (!statusPerCoin) {
    throw Error(
      "unable to refund deposit group without coin selection (status missing)",
    );
  }

  if (!payCoinSelection) {
    throw Error(
      "unable to refund deposit group without coin selection (selection missing)",
    );
  }
  let newTxPerCoin = [...statusPerCoin];
  // Refunds that might need to be handed off to the refresh,
  // as we don't know if deposit request will still arrive
  // before doing the refresh.
  const refundReqPerCoin: ExchangeRefundRequest[] = Array(newTxPerCoin.length);

  for (let i = 0; i < statusPerCoin.length; i++) {
    const st = statusPerCoin[i];
    switch (st) {
      case DepositElementStatus.RefundFailed:
      case DepositElementStatus.RefundSuccess:
      case DepositElementStatus.RefundNotFound:
        break;
      default: {
        const coinPub = payCoinSelection.coinPubs[i];
        const coinExchange = await wex.db.runReadOnlyTx(
          { storeNames: ["coins"] },
          async (tx) => {
            const coinRecord = await tx.coins.get(coinPub);
            checkDbInvariant(!!coinRecord, `coin ${coinPub} not found in DB`);
            return coinRecord.exchangeBaseUrl;
          },
        );
        const refundAmount = payCoinSelection.coinContributions[i];
        // We use a constant refund transaction ID, since there can
        // only be one refund for this contract.
        const rtid = 1;
        const sig = await wex.cryptoApi.signRefund({
          coinPub,
          contractTermsHash: depositGroup.contractTermsHash,
          merchantPriv: depositGroup.merchantPriv,
          merchantPub: depositGroup.merchantPub,
          refundAmount: refundAmount,
          rtransactionId: rtid,
        });
        const refundReq: ExchangeRefundRequest = {
          h_contract_terms: depositGroup.contractTermsHash,
          merchant_pub: depositGroup.merchantPub,
          merchant_sig: sig.sig,
          refund_amount: refundAmount,
          rtransaction_id: rtid,
        };
        const refundUrl = new URL(`coins/${coinPub}/refund`, coinExchange);
        const httpResp = await cancelableFetch(wex, refundUrl, {
          method: "POST",
          body: refundReq,
        });
        logger.info(
          `coin ${i} refund HTTP status for coin: ${httpResp.status}`,
        );
        let newStatus: DepositElementStatus;
        if (httpResp.status === 200) {
          // FIXME: validate response
          newStatus = DepositElementStatus.RefundSuccess;
        } else if (httpResp.status == 404) {
          // Exchange doesn't know about the deposit.
          // It's possible that we already sent out the
          // deposit request, but it didn't arrive yet,
          // so the subsequent refresh request might fail.
          newStatus = DepositElementStatus.RefundNotFound;
          refundReqPerCoin[i] = refundReq;
        } else {
          // FIXME: Store problem somewhere!
          newStatus = DepositElementStatus.RefundFailed;
        }
        // FIXME: Handle case where refund request needs to be tried again
        newTxPerCoin[i] = newStatus;
        await wex.db.runReadWriteTx(
          {
            storeNames: ["depositGroups"],
          },
          async (tx) => {
            const newDg = await tx.depositGroups.get(
              depositGroup.depositGroupId,
            );
            if (!newDg || !newDg.statusPerCoin) {
              return;
            }
            newDg.statusPerCoin[i] = newStatus;
            await tx.depositGroups.put(newDg);
            newTxPerCoin = [...newDg.statusPerCoin];
          },
        );
        break;
      }
    }
  }

  // Check if we are done trying to refund.

  const res = await wex.db.runReadWriteTx(
    {
      storeNames: [
        "coinAvailability",
        "coinHistory",
        "coins",
        "denominations",
        "depositGroups",
        "refreshGroups",
        "refreshSessions",
        "transactionsMeta",
      ],
    },
    async (tx) => {
      const newDg = await tx.depositGroups.get(depositGroup.depositGroupId);
      if (!newDg || !newDg.statusPerCoin) {
        return;
      }
      let refundsAllDone = true;
      for (let i = 0; i < newTxPerCoin.length; i++) {
        switch (newTxPerCoin[i]) {
          case DepositElementStatus.RefundFailed:
          case DepositElementStatus.RefundNotFound:
          case DepositElementStatus.RefundSuccess:
            break;
          default:
            refundsAllDone = false;
        }
      }
      if (!refundsAllDone) {
        return;
      }
      newTxPerCoin = [...newDg.statusPerCoin];
      const refreshCoins: CoinRefreshRequest[] = [];
      for (let i = 0; i < newTxPerCoin.length; i++) {
        refreshCoins.push({
          amount: payCoinSelection.coinContributions[i],
          coinPub: payCoinSelection.coinPubs[i],
          refundRequest: refundReqPerCoin[i],
        });
      }
      const refreshRes = await createRefreshGroup(
        wex,
        tx,
        currency,
        refreshCoins,
        RefreshReason.AbortDeposit,
        constructTransactionIdentifier({
          tag: TransactionType.Deposit,
          depositGroupId: newDg.depositGroupId,
        }),
      );
      newDg.abortRefreshGroupId = refreshRes.refreshGroupId;
      await tx.depositGroups.put(newDg);
      await ctx.updateTransactionMeta(tx);
      return { refreshRes };
    },
  );

  if (res?.refreshRes) {
    return TaskRunResult.progress();
  }

  return TaskRunResult.backoff();
}

/**
 * Check whether the refresh associated with the
 * aborting deposit group is done.
 *
 * If done, mark the deposit transaction as aborted.
 *
 * Otherwise continue waiting.
 *
 * FIXME:  Wait for the refresh group notifications instead of periodically
 * checking the refresh group status.
 * FIXME: This is just one transaction, can't we do this in the initial
 * transaction of processDepositGroup?
 */
async function waitForRefreshOnDepositGroup(
  wex: WalletExecutionContext,
  depositGroup: DepositGroupRecord,
): Promise<TaskRunResult> {
  const abortRefreshGroupId = depositGroup.abortRefreshGroupId;
  checkLogicInvariant(!!abortRefreshGroupId);
  const ctx = new DepositTransactionContext(wex, depositGroup.depositGroupId);

  const refreshCtx = new RefreshTransactionContext(wex, abortRefreshGroupId);

  // Wait for the refresh transaction to be in a final state.
  await genericWaitForState(wex, {
    async checkState() {
      return await wex.db.runReadWriteTx(
        { storeNames: ["depositGroups", "refreshGroups", "transactionsMeta"] },
        async (tx) => {
          const refreshGroup = await tx.refreshGroups.get(abortRefreshGroupId);
          switch (refreshGroup?.operationStatus) {
            case undefined:
            case RefreshOperationStatus.Failed:
            case RefreshOperationStatus.Finished:
              return true;
          }
          return false;
        },
      );
    },
    filterNotification(notif) {
      return (
        notif.type === NotificationType.TransactionStateTransition &&
        notif.transactionId === refreshCtx.transactionId
      );
    },
  });

  const didTransition = await wex.db.runReadWriteTx(
    { storeNames: ["depositGroups", "refreshGroups", "transactionsMeta"] },
    async (tx) => {
      const refreshGroup = await tx.refreshGroups.get(abortRefreshGroupId);
      let newOpState: DepositOperationStatus | undefined;
      switch (refreshGroup?.operationStatus) {
        case undefined: {
          // Maybe it got manually deleted? Means that we should
          // just go into aborted.
          logger.warn("no aborting refresh group found for deposit group");
          newOpState = DepositOperationStatus.AbortedDeposit;
          break;
        }
        case RefreshOperationStatus.Failed:
        case RefreshOperationStatus.Finished: {
          newOpState = DepositOperationStatus.AbortedDeposit;
          break;
        }
        default:
          return false;
      }
      const newDg = await tx.depositGroups.get(depositGroup.depositGroupId);
      if (!newDg) {
        return false;
      }
      const oldTxState = computeDepositTransactionStatus(newDg);
      const oldStId = newDg.operationStatus;
      newDg.operationStatus = newOpState;
      const newTxState = computeDepositTransactionStatus(newDg);
      const newStId = newDg.operationStatus;
      await tx.depositGroups.put(newDg);
      await ctx.updateTransactionMeta(tx);
      applyNotifyTransition(tx.notify, ctx.transactionId, {
        oldTxState,
        newTxState,
        balanceEffect: BalanceEffect.Any,
        newStId,
        oldStId,
      });
      return true;
    },
  );
  if (didTransition) {
    return TaskRunResult.progress();
  }
  return TaskRunResult.backoff();
}

async function processDepositGroupAborting(
  wex: WalletExecutionContext,
  depositGroup: DepositGroupRecord,
): Promise<TaskRunResult> {
  logger.info("processing deposit tx in 'aborting'");
  const abortRefreshGroupId = depositGroup.abortRefreshGroupId;
  if (!abortRefreshGroupId) {
    logger.info("refunding deposit group");
    return refundDepositGroup(wex, depositGroup);
  }
  logger.info("waiting for refresh");
  return waitForRefreshOnDepositGroup(wex, depositGroup);
}

/**
 * Process the transaction in states where KYC is required.
 * Used for both the deposit KYC and aggregate KYC.
 */
async function processDepositGroupPendingKyc(
  wex: WalletExecutionContext,
  depositGroup: DepositGroupRecord,
): Promise<TaskRunResult> {
  const { depositGroupId } = depositGroup;
  const ctx = new DepositTransactionContext(wex, depositGroupId);

  const maybeKycInfo = depositGroup.kycInfo;

  let myKycState: GenericKycStatusReq | undefined;

  if (maybeKycInfo) {
    myKycState = {
      accountPriv: depositGroup.merchantPriv,
      accountPub: depositGroup.merchantPub,
      amount: depositGroup.amount,
      operation: "DEPOSIT",
      exchangeBaseUrl: maybeKycInfo.exchangeBaseUrl,
      paytoHash: maybeKycInfo.paytoHash,
      lastAmlReview: maybeKycInfo.lastAmlReview,
      lastCheckCode: maybeKycInfo.lastCheckCode,
      lastCheckStatus: maybeKycInfo.lastCheckStatus,
      lastDeny: maybeKycInfo.lastDeny,
      lastRuleGen: maybeKycInfo.lastRuleGen,
      haveAccessToken: maybeKycInfo.accessToken != null,
      lastBadKycAuth: maybeKycInfo.lastBadKycAuth,
    };
  }

  if (myKycState == null) {
    logger.info("no kyc state yet");
  }

  if (myKycState == null || isKycOperationDue(myKycState)) {
    switch (depositGroup.operationStatus) {
      case DepositOperationStatus.PendingDepositKyc:
        logger.info(
          `deposit group is in pending(deposit-kyc), but trying deposit anyway after two minutes since last attempt`,
        );
        return await processDepositGroupPendingDeposit(wex, depositGroup);
      case DepositOperationStatus.PendingAggregateKyc:
        return await processDepositGroupTrack(wex, depositGroup);
      case DepositOperationStatus.PendingDepositKycAuth:
        logger.info(
          `deposit group is in pending(deposit-kyc-auth), but trying deposit anyway after two minutes since last attempt`,
        );
        return await processDepositGroupPendingDeposit(wex, depositGroup);
      default:
        return TaskRunResult.backoff();
    }
  }

  const algoRes = await runKycCheckAlgo(wex, myKycState);

  if (!algoRes.updatedStatus) {
    return algoRes.taskResult;
  }

  checkLogicInvariant(!!maybeKycInfo);

  const kycInfo = maybeKycInfo;

  // FIXME: Brittle, can't we use the same data structure for all txns and copy it?
  kycInfo.lastAmlReview = algoRes.updatedStatus.lastAmlReview;
  kycInfo.lastCheckStatus = algoRes.updatedStatus.lastCheckStatus;
  kycInfo.lastCheckCode = algoRes.updatedStatus.lastCheckCode;
  kycInfo.lastDeny = algoRes.updatedStatus.lastDeny;
  kycInfo.lastRuleGen = algoRes.updatedStatus.lastRuleGen;
  kycInfo.accessToken = algoRes.updatedStatus.accessToken;
  kycInfo.lastBadKycAuth = algoRes.updatedStatus.lastBadKycAuth;

  const requiresAuth = algoRes.requiresAuth;

  if (logger.shouldLogTrace()) {
    logger.trace(`kyc check algo result: ${j2s(algoRes)}`);
  }

  // Now store the result.

  return await wex.db.runReadWriteTx(
    { storeNames: ["depositGroups", "transactionsMeta"] },
    async (tx) => {
      const newDg = await tx.depositGroups.get(depositGroupId);
      if (!newDg) {
        return TaskRunResult.finished();
      }
      const oldTxState = computeDepositTransactionStatus(newDg);
      const oldStId = newDg.operationStatus;
      switch (newDg.operationStatus) {
        case DepositOperationStatus.PendingAggregateKyc:
          if (requiresAuth) {
            throw Error("kyc auth during aggregation not yet supported");
          }
          break;
        case DepositOperationStatus.PendingDepositKyc:
          if (requiresAuth) {
            newDg.operationStatus =
              DepositOperationStatus.PendingDepositKycAuth;
          }
          break;
        case DepositOperationStatus.PendingDepositKycAuth:
          if (!requiresAuth) {
            newDg.operationStatus = DepositOperationStatus.PendingDepositKyc;
          }
          break;
        default:
          return TaskRunResult.backoff();
      }
      newDg.kycInfo = kycInfo;
      await tx.depositGroups.put(newDg);
      await ctx.updateTransactionMeta(tx);
      const newTxState = computeDepositTransactionStatus(newDg);
      const newStId = newDg.operationStatus;
      applyNotifyTransition(tx.notify, ctx.transactionId, {
        oldTxState,
        newTxState,
        balanceEffect: BalanceEffect.Any,
        oldStId,
        newStId,
      });
      return algoRes.taskResult;
    },
  );
}

/**
 * Finds the reserve key pair of the most recent withdrawal
 * with the given exchange.
 * Returns undefined if no such withdrawal exists.
 */
async function getLastWithdrawalKeyPair(
  wex: WalletExecutionContext,
  exchangeBaseUrl: string,
): Promise<EddsaKeyPairStrings | undefined> {
  let candidateTimestamp: AbsoluteTime | undefined = undefined;
  let candidateRes: EddsaKeyPairStrings | undefined = undefined;
  await wex.db.runAllStoresReadOnlyTx({}, async (tx) => {
    const withdrawalRecs =
      await tx.withdrawalGroups.indexes.byExchangeBaseUrl.getAll(
        exchangeBaseUrl,
      );
    for (const rec of withdrawalRecs) {
      if (!rec.timestampFinish) {
        continue;
      }
      const currTimestamp = timestampAbsoluteFromDb(rec.timestampFinish);
      if (
        candidateTimestamp == null ||
        AbsoluteTime.cmp(currTimestamp, candidateTimestamp) > 0
      ) {
        candidateTimestamp = currTimestamp;
        candidateRes = {
          priv: rec.reservePriv,
          pub: rec.reservePub,
        };
      }
    }

    if (candidateRes) {
      // We already found a good candidate.
      return;
    }

    // No good candidate, try finding a withdrawal group that's at
    // least currently pending, so it might be completed in the future.
    for (const rec of withdrawalRecs) {
      switch (rec.status) {
        case WithdrawalGroupStatus.PendingBalanceKyc:
        case WithdrawalGroupStatus.PendingBalanceKycInit:
        case WithdrawalGroupStatus.PendingKyc:
        case WithdrawalGroupStatus.PendingQueryingStatus:
        case WithdrawalGroupStatus.PendingReady:
        case WithdrawalGroupStatus.PendingRegisteringBank:
        case WithdrawalGroupStatus.PendingWaitConfirmBank:
        case WithdrawalGroupStatus.SuspendedBalanceKyc:
        case WithdrawalGroupStatus.SuspendedBalanceKycInit:
        case WithdrawalGroupStatus.SuspendedKyc:
        case WithdrawalGroupStatus.SuspendedQueryingStatus:
        case WithdrawalGroupStatus.SuspendedReady:
        case WithdrawalGroupStatus.SuspendedRegisteringBank:
        case WithdrawalGroupStatus.SuspendedWaitConfirmBank:
      }
      const currTimestamp = timestampAbsoluteFromDb(rec.timestampStart);
      if (
        candidateTimestamp == null ||
        AbsoluteTime.cmp(currTimestamp, candidateTimestamp) > 0
      ) {
        candidateTimestamp = currTimestamp;
        candidateRes = {
          priv: rec.reservePriv,
          pub: rec.reservePub,
        };
      }
    }
  });
  return candidateRes;
}

/**
 * Tracking information from the exchange indicated that
 * KYC is required.  We need to check the KYC info
 * and transition the transaction to the KYC required state.
 */
async function transitionToKycRequired(
  wex: WalletExecutionContext,
  depositGroup: DepositGroupRecord,
  args: {
    kycPaytoHash: string;
    exchangeUrl: string;
    badKycAuth: boolean;
  },
): Promise<TaskRunResult> {
  const { depositGroupId } = depositGroup;

  const ctx = new DepositTransactionContext(wex, depositGroupId);

  await wex.db.runReadWriteTx(
    { storeNames: ["depositGroups", "transactionsMeta"] },
    async (tx) => {
      const dg = await tx.depositGroups.get(depositGroupId);
      if (!dg) {
        return undefined;
      }
      const oldTxState = computeDepositTransactionStatus(dg);
      const oldStId = dg.operationStatus;
      switch (dg.operationStatus) {
        case DepositOperationStatus.LegacyPendingTrack:
        case DepositOperationStatus.FinalizingTrack:
          if (args.badKycAuth) {
            throw Error("not yet supported");
          } else {
            dg.operationStatus = DepositOperationStatus.PendingAggregateKyc;
          }
          break;
        case DepositOperationStatus.PendingDeposit:
          if (args.badKycAuth) {
            dg.operationStatus = DepositOperationStatus.PendingDepositKycAuth;
          } else {
            dg.operationStatus = DepositOperationStatus.PendingDepositKyc;
          }
          break;
        case DepositOperationStatus.PendingDepositKycAuth:
          if (!args.badKycAuth) {
            dg.operationStatus = DepositOperationStatus.PendingDepositKyc;
          }
          break;
        case DepositOperationStatus.PendingDepositKyc:
          if (args.badKycAuth) {
            dg.operationStatus = DepositOperationStatus.PendingDepositKycAuth;
          }
          break;
        default:
          logger.warn(
            `transitionToKycRequired: state ${dg.operationStatus} / ${
              DepositOperationStatus[dg.operationStatus]
            } not handled`,
          );
          return;
      }
      if (dg.kycInfo && dg.kycInfo.exchangeBaseUrl === args.exchangeUrl) {
        dg.kycInfo.lastDeny = timestampPreciseToDb(TalerPreciseTimestamp.now());
        dg.kycInfo.lastBadKycAuth = args.badKycAuth;
      } else {
        // Reset other info when new exchange is involved.
        dg.kycInfo = {
          exchangeBaseUrl: args.exchangeUrl,
          paytoHash: args.kycPaytoHash,
          lastDeny: timestampPreciseToDb(TalerPreciseTimestamp.now()),
          lastBadKycAuth: args.badKycAuth,
        };
      }
      await tx.depositGroups.put(dg);
      await ctx.updateTransactionMeta(tx);
      const newTxState = computeDepositTransactionStatus(dg);
      const newStId = dg.operationStatus;
      applyNotifyTransition(tx.notify, ctx.transactionId, {
        oldTxState,
        newTxState,
        balanceEffect: BalanceEffect.Any,
        newStId,
        oldStId,
      });
    },
  );
  return TaskRunResult.progress();
}

async function processDepositGroupTrack(
  wex: WalletExecutionContext,
  depositGroup: DepositGroupRecord,
): Promise<TaskRunResult> {
  const statusPerCoin = depositGroup.statusPerCoin;
  const payCoinSelection = depositGroup.payCoinSelection;
  if (!statusPerCoin) {
    throw Error(
      "unable to refund deposit group without coin selection (status missing)",
    );
  }
  if (!payCoinSelection) {
    throw Error(
      "unable to refund deposit group without coin selection (selection missing)",
    );
  }
  logger.trace(`tracking deposit group, status ${j2s(statusPerCoin)}`);
  const { depositGroupId } = depositGroup;
  const ctx = new DepositTransactionContext(wex, depositGroupId);
  for (let i = 0; i < statusPerCoin.length; i++) {
    const coinPub = payCoinSelection.coinPubs[i];
    // FIXME: Make the URL part of the coin selection?
    const exchangeBaseUrl = await wex.db.runReadWriteTx(
      { storeNames: ["coins"] },
      async (tx) => {
        const coinRecord = await tx.coins.get(coinPub);
        checkDbInvariant(!!coinRecord, `coin ${coinPub} not found in DB`);
        return coinRecord.exchangeBaseUrl;
      },
    );

    let updatedTxStatus: DepositElementStatus | undefined = undefined;
    let newWiredCoin:
      | {
          id: string;
          value: DepositTrackingInfo;
        }
      | undefined;

    if (statusPerCoin[i] !== DepositElementStatus.Wired) {
      const track = await trackDeposit(
        wex,
        depositGroup,
        coinPub,
        exchangeBaseUrl,
      );

      logger.trace(`track response: ${j2s(track)}`);
      if (track.type === "accepted") {
        if (!track.kyc_ok && track.requirement_row !== undefined) {
          // FIXME: Take this from the response.
          const paytoHash = encodeCrock(
            hashNormalizedPaytoUri(depositGroup.wire.payto_uri),
          );
          return transitionToKycRequired(wex, depositGroup, {
            exchangeUrl: exchangeBaseUrl,
            kycPaytoHash: paytoHash,
            badKycAuth: false, // ??
          });
        } else {
          updatedTxStatus = DepositElementStatus.Tracking;
        }
      } else if (track.type === "wired") {
        updatedTxStatus = DepositElementStatus.Wired;

        const payto = parsePaytoUri(depositGroup.wire.payto_uri);
        if (!payto) {
          throw Error(`unparsable payto: ${depositGroup.wire.payto_uri}`);
        }

        const fee = await getExchangeWireFee(
          wex,
          payto.targetType,
          exchangeBaseUrl,
          track.execution_time,
        );
        const raw = Amounts.parseOrThrow(track.coin_contribution);
        const wireFee = Amounts.parseOrThrow(fee.wireFee);

        newWiredCoin = {
          value: {
            amountRaw: Amounts.stringify(raw),
            wireFee: Amounts.stringify(wireFee),
            exchangePub: track.exchange_pub,
            timestampExecuted: timestampProtocolToDb(track.execution_time),
            wireTransferId: track.wtid,
          },
          id: track.exchange_sig,
        };
      } else {
        updatedTxStatus = DepositElementStatus.DepositPending;
      }
    }

    if (updatedTxStatus !== undefined) {
      await wex.db.runReadWriteTx(
        { storeNames: ["depositGroups", "transactionsMeta"] },
        async (tx) => {
          const dg = await tx.depositGroups.get(depositGroupId);
          if (!dg) {
            return;
          }
          if (!dg.statusPerCoin) {
            return;
          }
          if (updatedTxStatus !== undefined) {
            dg.statusPerCoin[i] = updatedTxStatus;
          }
          if (newWiredCoin) {
            /**
             * FIXME: if there is a new wire information from the exchange
             * it should add up to the previous tracking states.
             *
             * This may loose information by overriding prev state.
             *
             * And: add checks to integration tests
             */
            if (!dg.trackingState) {
              dg.trackingState = {};
            }

            dg.trackingState[newWiredCoin.id] = newWiredCoin.value;
          }
          await tx.depositGroups.put(dg);
          await ctx.updateTransactionMeta(tx);
        },
      );
    }
  }

  let allWired = true;

  await wex.db.runReadWriteTx(
    { storeNames: ["depositGroups", "transactionsMeta"] },
    async (tx) => {
      const dg = await tx.depositGroups.get(depositGroupId);
      if (!dg) {
        return undefined;
      }
      if (!dg.statusPerCoin) {
        return undefined;
      }
      const oldTxState = computeDepositTransactionStatus(dg);
      const oldStId = dg.operationStatus;
      for (let i = 0; i < dg.statusPerCoin.length; i++) {
        if (dg.statusPerCoin[i] !== DepositElementStatus.Wired) {
          allWired = false;
          break;
        }
      }
      if (allWired) {
        dg.timestampFinished = timestampPreciseToDb(
          TalerPreciseTimestamp.now(),
        );
        dg.operationStatus = DepositOperationStatus.Finished;
        await tx.depositGroups.put(dg);
        await ctx.updateTransactionMeta(tx);
      }
      const newTxState = computeDepositTransactionStatus(dg);
      const newStId = dg.operationStatus;
      applyNotifyTransition(tx.notify, ctx.transactionId, {
        oldTxState,
        newTxState,
        balanceEffect: BalanceEffect.Any,
        oldStId,
        newStId,
      });
    },
  );
  if (allWired) {
    return TaskRunResult.finished();
  } else {
    return TaskRunResult.longpollReturnedPending();
  }
}

async function doCoinSelection(
  ctx: DepositTransactionContext,
  depositGroup: DepositGroupRecord,
  contractData: MerchantContractTerms,
): Promise<TaskRunResult> {
  const wex = ctx.wex;
  const depositGroupId = ctx.depositGroupId;

  if (
    contractData.version !== undefined &&
    contractData.version !== MerchantContractVersion.V0
  ) {
    throw Error("assertion failed");
  }

  const transitionDone = await wex.db.runAllStoresReadWriteTx(
    {},
    async (tx) => {
      const dg = await tx.depositGroups.get(depositGroupId);
      if (!dg) {
        return false;
      }
      if (dg.statusPerCoin) {
        return false;
      }

      const contractTermsRec = tx.contractTerms.get(
        depositGroup.contractTermsHash,
      );
      if (!contractTermsRec) {
        throw Error("contract terms for deposit not found in database");
      }

      const payCoinSel = await selectPayCoinsInTx(wex, tx, {
        restrictExchanges: {
          auditors: [],
          exchanges: contractData.exchanges.map((ex) => ({
            exchangeBaseUrl: ex.url,
            exchangePub: ex.master_pub,
          })),
        },
        restrictWireMethod: contractData.wire_method,
        depositPaytoUri: dg.wire.payto_uri,
        contractTermsAmount: Amounts.parseOrThrow(contractData.amount),
        depositFeeLimit: Amounts.parseOrThrow(contractData.max_fee),
        prevPayCoins: [],
      });

      switch (payCoinSel.type) {
        case "success":
          logger.info("coin selection success");
          break;
        case "failure":
          logger.info("coin selection failure");
          throw TalerError.fromDetail(
            TalerErrorCode.WALLET_DEPOSIT_GROUP_INSUFFICIENT_BALANCE,
            {
              insufficientBalanceDetails: payCoinSel.insufficientBalanceDetails,
            },
          );
        case "prospective":
          logger.info("coin selection prospective");
          throw Error("insufficient balance (waiting on pending refresh)");
        default:
          assertUnreachable(payCoinSel);
      }

      dg.payCoinSelection = {
        coinContributions: payCoinSel.coinSel.coins.map((x) => x.contribution),
        coinPubs: payCoinSel.coinSel.coins.map((x) => x.coinPub),
      };
      dg.payCoinSelectionUid = encodeCrock(getRandomBytes(32));
      dg.statusPerCoin = payCoinSel.coinSel.coins.map(
        () => DepositElementStatus.DepositPending,
      );
      await tx.depositGroups.put(dg);
      await ctx.updateTransactionMeta(tx);
      await spendCoins(wex, tx, {
        transactionId: ctx.transactionId,
        coinPubs: dg.payCoinSelection.coinPubs,
        contributions: dg.payCoinSelection.coinContributions.map((x) =>
          Amounts.parseOrThrow(x),
        ),
        refreshReason: RefreshReason.PayDeposit,
      });
      return true;
    },
  );

  if (transitionDone) {
    return TaskRunResult.progress();
  } else {
    return TaskRunResult.backoff();
  }
}

interface SubmitBatchArgs {
  exchangeBaseUrl: string;
  contractTerms: MerchantContractTerms;
  depositPermissions: CoinDepositPermission[];
  depositGroup: DepositGroupRecord;
  merchantSigResp: SignContractTermsHashResponse;
  coinIndexes: number[];
}

/**
 * Submit a single deposit batch to the exchange.
 *
 * Returns null on success or a TaskRunResult
 * to abort the task with on error.
 */
async function submitDepositBatch(
  wex: WalletExecutionContext,
  args: SubmitBatchArgs,
): Promise<TaskRunResult | null> {
  const coins: BatchDepositRequestCoin[] = [];
  const {
    merchantSigResp,
    exchangeBaseUrl,
    contractTerms,
    depositGroup,
    depositPermissions,
    coinIndexes,
  } = args;
  const depositGroupId = depositGroup.depositGroupId;
  const batchReq: ExchangeBatchDepositRequest = {
    coins,
    h_contract_terms: depositGroup.contractTermsHash,
    merchant_payto_uri: depositGroup.wire.payto_uri,
    merchant_pub: contractTerms.merchant_pub,
    timestamp: contractTerms.timestamp,
    wire_salt: depositGroup.wire.salt,
    wire_transfer_deadline: contractTerms.wire_transfer_deadline,
    refund_deadline: contractTerms.refund_deadline,
    merchant_sig: merchantSigResp.sig,
  };
  const ctx = new DepositTransactionContext(wex, depositGroupId);
  for (let i = 0; i < coinIndexes.length; i++) {
    const perm = depositPermissions[coinIndexes[i]];
    if (perm.exchange_url != exchangeBaseUrl) {
      continue;
    }
    coins.push({
      coin_pub: perm.coin_pub,
      coin_sig: perm.coin_sig,
      contribution: Amounts.stringify(perm.contribution),
      denom_pub_hash: perm.h_denom,
      ub_sig: perm.ub_sig,
      h_age_commitment: perm.h_age_commitment,
    });
  }

  // Check for cancellation before making network request.
  wex.cancellationToken?.throwIfCancelled();
  const url = new URL(`batch-deposit`, exchangeBaseUrl);
  logger.info(`depositing to ${url.href}`);
  logger.trace(`deposit request: ${j2s(batchReq)}`);
  const httpResp = await cancelableFetch(wex, url, {
    method: "POST",
    body: batchReq,
  });

  logger.info(`deposit result status ${httpResp.status}`);

  switch (httpResp.status) {
    case HttpStatusCode.Accepted:
    case HttpStatusCode.Ok:
      break;
    case HttpStatusCode.UnavailableForLegalReasons: {
      const kycLegiNeededResp = await readResponseJsonOrThrow(
        httpResp,
        codecForLegitimizationNeededResponse(),
      );
      logger.info(
        `kyc legitimization needed response: ${j2s(kycLegiNeededResp)}`,
      );
      return transitionToKycRequired(wex, depositGroup, {
        exchangeUrl: exchangeBaseUrl,
        kycPaytoHash: kycLegiNeededResp.h_payto,
        badKycAuth: kycLegiNeededResp.bad_kyc_auth ?? false,
      });
    }
  }

  await readSuccessResponseJsonOrThrow(httpResp, codecForBatchDepositSuccess());

  await wex.db.runReadWriteTx(
    { storeNames: ["depositGroups", "transactionsMeta"] },
    async (tx) => {
      const dg = await tx.depositGroups.get(depositGroupId);
      if (!dg) {
        return;
      }
      if (!dg.statusPerCoin) {
        return;
      }
      for (const batchIndex of coinIndexes) {
        const coinStatus = dg.statusPerCoin[batchIndex];
        switch (coinStatus) {
          case DepositElementStatus.DepositPending:
            dg.statusPerCoin[batchIndex] = DepositElementStatus.Tracking;
            await tx.depositGroups.put(dg);
        }
      }
      await ctx.updateTransactionMeta(tx);
    },
  );

  return null;
}

/**
 * Try to deposit coins with the exchange.
 *
 * May either be called directly when the deposit group is
 * in the pending(deposit) state or indirectly when the deposit
 * group is in a KYC state but wants to try deposit anyway (in case KYC
 * is for another operation).
 */
async function processDepositGroupPendingDeposit(
  wex: WalletExecutionContext,
  depositGroup: DepositGroupRecord,
): Promise<TaskRunResult> {
  logger.info("processing deposit group in pending(deposit)");
  const depositGroupId = depositGroup.depositGroupId;
  const contractTermsRec = await wex.db.runReadOnlyTx(
    { storeNames: ["contractTerms"] },
    async (tx) => {
      return tx.contractTerms.get(depositGroup.contractTermsHash);
    },
  );
  if (!contractTermsRec) {
    throw Error("contract terms for deposit not found in database");
  }
  const contractTerms = codecForMerchantContractTerms().decode(
    contractTermsRec.contractTermsRaw,
  );

  const ctx = new DepositTransactionContext(wex, depositGroupId);

  // Check for cancellation before expensive operations.
  wex.cancellationToken?.throwIfCancelled();

  if (!depositGroup.payCoinSelection) {
    logger.info("missing coin selection for deposit group, selecting now");
    return await doCoinSelection(ctx, depositGroup, contractTerms);
  }

  await wex.db.runReadWriteTx({ storeNames: ["depositGroups"] }, async (tx) => {
    const dg = await tx.depositGroups.get(depositGroup.depositGroupId);
    if (!dg) {
      logger.warn(`deposit group ${depositGroup.depositGroupId} not found`);
      return;
    }
    dg.timestampLastDepositAttempt = timestampPreciseToDb(
      TalerPreciseTimestamp.now(),
    );
    await tx.depositGroups.put(dg);
  });

  // FIXME: Cache these!
  const depositPermissions = await generateDepositPermissions(
    wex,
    depositGroup.payCoinSelection,
    contractTerms,
    contractTermsRec.h,
  );

  // Exchanges involved in the deposit
  const exchanges: Set<string> = new Set();

  for (const dp of depositPermissions) {
    exchanges.add(dp.exchange_url);
  }

  const merchantSigResp = await wex.ws.cryptoApi.signContractTermsHash({
    contractTermsHash: depositGroup.contractTermsHash,
    merchantPriv: depositGroup.merchantPriv,
  });

  let maxBatchSize = 63;

  // We need to do one or mre batches per exchange.
  for (const exchangeBaseUrl of exchanges.values()) {
    const batchArgs: SubmitBatchArgs = {
      exchangeBaseUrl,
      contractTerms,
      depositPermissions,
      depositGroup,
      merchantSigResp,
      coinIndexes: [],
    };
    for (let i = 0; i < depositPermissions.length; i++) {
      const perm = depositPermissions[i];
      if (perm.exchange_url != exchangeBaseUrl) {
        continue;
      }
      batchArgs.coinIndexes.push(i);
      if (batchArgs.coinIndexes.length >= maxBatchSize) {
        const r = await submitDepositBatch(wex, batchArgs);
        if (r != null) {
          return r;
        }
        batchArgs.coinIndexes = [];
      }
    }
    if (batchArgs.coinIndexes.length >= 0) {
      const r = await submitDepositBatch(wex, batchArgs);
      if (r != null) {
        return r;
      }
    }
  }

  await wex.db.runReadWriteTx(
    { storeNames: ["depositGroups", "transactionsMeta"] },
    async (tx) => {
      const dg = await tx.depositGroups.get(depositGroupId);
      if (!dg) {
        return undefined;
      }
      const oldTxState = computeDepositTransactionStatus(dg);
      const oldStId = dg.operationStatus;
      dg.operationStatus = DepositOperationStatus.FinalizingTrack;
      await tx.depositGroups.put(dg);
      await ctx.updateTransactionMeta(tx);
      const newTxState = computeDepositTransactionStatus(dg);
      const newStId = dg.operationStatus;
      applyNotifyTransition(tx.notify, ctx.transactionId, {
        oldTxState,
        newTxState,
        balanceEffect: BalanceEffect.None,
        oldStId,
        newStId,
      });
    },
  );
  return TaskRunResult.progress();
}

/**
 * Process a deposit group that is not in its final state yet.
 */
export async function processDepositGroup(
  wex: WalletExecutionContext,
  depositGroupId: string,
): Promise<TaskRunResult> {
  if (!wex.ws.networkAvailable) {
    return TaskRunResult.networkRequired();
  }

  const depositGroup = await wex.db.runReadOnlyTx(
    { storeNames: ["depositGroups"] },
    async (tx) => {
      return tx.depositGroups.get(depositGroupId);
    },
  );
  if (!depositGroup) {
    logger.warn(`deposit group ${depositGroupId} not found`);
    return TaskRunResult.finished();
  }

  switch (depositGroup.operationStatus) {
    case DepositOperationStatus.LegacyPendingTrack:
    case DepositOperationStatus.FinalizingTrack:
      return processDepositGroupTrack(wex, depositGroup);
    case DepositOperationStatus.PendingAggregateKyc:
    case DepositOperationStatus.PendingDepositKyc:
    case DepositOperationStatus.PendingDepositKycAuth:
      return processDepositGroupPendingKyc(wex, depositGroup);
    case DepositOperationStatus.PendingDeposit:
      return processDepositGroupPendingDeposit(wex, depositGroup);
    case DepositOperationStatus.Aborting:
      return processDepositGroupAborting(wex, depositGroup);
  }

  return TaskRunResult.finished();
}

/**
 * Long-poll on the deposit tracking information.
 */
async function trackDeposit(
  wex: WalletExecutionContext,
  depositGroup: DepositGroupRecord,
  coinPub: string,
  exchangeUrl: string,
): Promise<TrackTransaction> {
  const wireHash = hashWire(
    depositGroup.wire.payto_uri,
    depositGroup.wire.salt,
  );

  const url = new URL(
    `deposits/${wireHash}/${depositGroup.merchantPub}/${depositGroup.contractTermsHash}/${coinPub}`,
    exchangeUrl,
  );
  const sigResp = await wex.cryptoApi.signTrackTransaction({
    coinPub,
    contractTermsHash: depositGroup.contractTermsHash,
    merchantPriv: depositGroup.merchantPriv,
    merchantPub: depositGroup.merchantPub,
    wireHash,
  });
  url.searchParams.set("merchant_sig", sigResp.sig);
  // wait for the a 202 state where kyc_ok is false or a 200 OK response
  url.searchParams.set("lpt", `1`);
  const httpResp = await cancelableLongPoll(wex, url);
  logger.trace(`deposits response status: ${httpResp.status}`);
  switch (httpResp.status) {
    case HttpStatusCode.Accepted: {
      const accepted = await readSuccessResponseJsonOrThrow(
        httpResp,
        codecForTackTransactionAccepted(),
      );
      logger.trace(`deposits response: ${j2s(accepted)}`);
      return { type: "accepted", ...accepted };
    }
    case HttpStatusCode.Ok: {
      const wired = await readSuccessResponseJsonOrThrow(
        httpResp,
        codecForTackTransactionWired(),
      );
      return { type: "wired", ...wired };
    }
    default: {
      throwUnexpectedRequestError(
        httpResp,
        await readTalerErrorResponse(httpResp),
      );
    }
  }
}

/**
 * Check if creating a deposit group is possible and calculate
 * the associated fees.
 */
export async function checkDepositGroup(
  wex: WalletExecutionContext,
  req: CheckDepositRequest,
): Promise<CheckDepositResponse> {
  return await runWithClientCancellation(
    wex,
    "checkDepositGroup",
    req.clientCancellationId,
    () => internalCheckDepositGroup(wex, req),
  );
}

async function getExchangesForDeposit(
  wex: WalletExecutionContext,
  req: { restrictScope?: ScopeInfo; currency: string },
): Promise<Exchange[]> {
  logger.trace(`getting exchanges for deposit ${j2s(req)}`);
  const exchangeInfos: Exchange[] = [];
  await wex.db.runAllStoresReadOnlyTx({}, async (tx) => {
    const allExchanges = await tx.exchanges.iter().toArray();
    for (const e of allExchanges) {
      const details = await getExchangeWireDetailsInTx(tx, e.baseUrl);
      if (!details) {
        logger.trace(`skipping ${e.baseUrl}, no details`);
        continue;
      }
      if (req.currency !== details.currency) {
        logger.trace(`skipping ${e.baseUrl}, currency mismatch`);
        continue;
      }
      if (req.restrictScope) {
        const inScope = await checkExchangeInScopeTx(
          tx,
          e.baseUrl,
          req.restrictScope,
        );
        if (!inScope) {
          continue;
        }
      }
      exchangeInfos.push({
        master_pub: details.masterPublicKey,
        priority: 1,
        url: e.baseUrl,
      });
    }
  });
  return exchangeInfos;
}

/**
 * Check if creating a deposit group is possible and calculate
 * the associated fees.
 */
export async function internalCheckDepositGroup(
  wex: WalletExecutionContext,
  req: CheckDepositRequest,
): Promise<CheckDepositResponse> {
  const p = parsePaytoUri(req.depositPaytoUri);
  if (!p) {
    throw Error("invalid payto URI");
  }
  const amount = Amounts.parseOrThrow(req.amount);
  const currency = Amounts.currencyOf(amount);

  const exchangeInfos: Exchange[] = await getExchangesForDeposit(wex, {
    currency,
    restrictScope: req.restrictScope,
  });

  const depositFeeLimit = amount;

  const payCoinSel = await selectPayCoins(wex, {
    restrictExchanges: {
      auditors: [],
      exchanges: exchangeInfos.map((x) => {
        return {
          exchangeBaseUrl: x.url,
          exchangePub: x.master_pub,
        };
      }),
    },
    restrictScope: req.restrictScope,
    restrictWireMethod: p.targetType,
    depositPaytoUri: req.depositPaytoUri,
    contractTermsAmount: Amounts.parseOrThrow(req.amount),
    depositFeeLimit,
    prevPayCoins: [],
  });

  let selCoins: SelectedProspectiveCoin[] | undefined = undefined;

  switch (payCoinSel.type) {
    case "failure":
      throw TalerError.fromDetail(
        TalerErrorCode.WALLET_DEPOSIT_GROUP_INSUFFICIENT_BALANCE,
        {
          insufficientBalanceDetails: payCoinSel.insufficientBalanceDetails,
        },
      );
    case "prospective":
      selCoins = payCoinSel.result.prospectiveCoins;
      break;
    case "success":
      selCoins = payCoinSel.coinSel.coins;
      break;
    default:
      assertUnreachable(payCoinSel);
  }

  const totalDepositCost = await getTotalPaymentCost(wex, currency, selCoins);

  const effectiveDepositAmount = await getCounterpartyEffectiveDepositAmount(
    wex,
    p.targetType,
    selCoins,
  );

  const usedExchangesSet = new Set<string>();
  for (const c of selCoins) {
    usedExchangesSet.add(c.exchangeBaseUrl);
  }

  const exchanges: ReadyExchangeSummary[] = [];

  for (const exchangeBaseUrl of usedExchangesSet) {
    exchanges.push(await fetchFreshExchange(wex, exchangeBaseUrl));
  }

  const fees = await getTotalFeesForDepositAmount(
    wex,
    p.targetType,
    amount,
    selCoins,
  );

  return {
    totalDepositCost: Amounts.stringify(totalDepositCost),
    effectiveDepositAmount: Amounts.stringify(effectiveDepositAmount),
    fees,
    ...getDepositLimitInfo(exchanges, effectiveDepositAmount),
  };
}

export function generateDepositGroupTxId(): string {
  const depositGroupId = encodeCrock(getRandomBytes(32));
  return constructTransactionIdentifier({
    tag: TransactionType.Deposit,
    depositGroupId: depositGroupId,
  });
}

export async function createDepositGroup(
  wex: WalletExecutionContext,
  req: CreateDepositGroupRequest,
): Promise<CreateDepositGroupResponse> {
  const depositPayto = parsePaytoUri(req.depositPaytoUri);
  if (!depositPayto) {
    throw Error("invalid payto URI");
  }

  const amount = Amounts.parseOrThrow(req.amount);
  const currency = amount.currency;

  const exchangeInfos: Exchange[] = await getExchangesForDeposit(wex, {
    currency,
    restrictScope: req.restrictScope,
  });

  const now = AbsoluteTime.now();
  const wireDeadline = AbsoluteTime.toProtocolTimestamp(
    AbsoluteTime.addDuration(now, Duration.fromSpec({ minutes: 5 })),
  );
  const nowRounded = AbsoluteTime.toProtocolTimestamp(now);

  const payCoinSel = await selectPayCoins(wex, {
    restrictExchanges: {
      auditors: [],
      exchanges: exchangeInfos.map((x) => ({
        exchangeBaseUrl: x.url,
        exchangePub: x.master_pub,
      })),
    },
    restrictScope: req.restrictScope,
    restrictWireMethod: depositPayto.targetType,
    depositPaytoUri: req.depositPaytoUri,
    contractTermsAmount: amount,
    depositFeeLimit: amount,
    prevPayCoins: [],
  });

  let coins: SelectedProspectiveCoin[] | undefined = undefined;

  switch (payCoinSel.type) {
    case "success":
      coins = payCoinSel.coinSel.coins;
      break;
    case "failure":
      throw TalerError.fromDetail(
        TalerErrorCode.WALLET_DEPOSIT_GROUP_INSUFFICIENT_BALANCE,
        {
          insufficientBalanceDetails: payCoinSel.insufficientBalanceDetails,
        },
      );
    case "prospective":
      coins = payCoinSel.result.prospectiveCoins;
      break;
    default:
      assertUnreachable(payCoinSel);
  }

  const usedExchangesSet = new Set<string>();
  for (const c of coins) {
    usedExchangesSet.add(c.exchangeBaseUrl);
  }

  const exchanges: ReadyExchangeSummary[] = [];

  for (const exchangeBaseUrl of usedExchangesSet) {
    exchanges.push(await fetchFreshExchange(wex, exchangeBaseUrl));
  }

  if (checkDepositHardLimitExceeded(exchanges, req.amount)) {
    throw Error("deposit would exceed hard KYC limit");
  }

  // Heuristic for the merchant key pair: If there's an exchange where we made
  // a withdrawal from, use that key pair, so the user doesn't have to do
  // a KYC transfer to establish a kyc account key pair.
  // FIXME: Extend the heuristic to use the last used merchant key pair?
  let merchantPair: EddsaKeyPairStrings | undefined = undefined;
  if (req.testingFixedPriv) {
    const merchantPub = await wex.cryptoApi.eddsaGetPublic({
      priv: req.testingFixedPriv,
    });
    merchantPair = {
      priv: req.testingFixedPriv,
      pub: merchantPub.pub,
    };
  } else if (coins.length > 0) {
    const res = await getLastWithdrawalKeyPair(wex, coins[0].exchangeBaseUrl);
    if (res) {
      logger.info(
        `reusing reserve pub ${res.pub} from last withdrawal to ${coins[0].exchangeBaseUrl}`,
      );
      merchantPair = res;
    }
  }
  if (!merchantPair) {
    logger.info(`creating new merchant key pair for deposit`);
    merchantPair = await wex.cryptoApi.createEddsaKeypair({});
  }

  const noncePair = await wex.cryptoApi.createEddsaKeypair({});
  const wireSalt = encodeCrock(getRandomBytes(16));
  const wireHash = hashWire(req.depositPaytoUri, wireSalt);
  const contractTerms: MerchantContractTermsV0 = {
    exchanges: exchangeInfos.map((x) => ({
      master_pub: x.master_pub,
      priority: 1,
      url: x.url,
    })),
    amount: req.amount,
    max_fee: Amounts.stringify(amount),
    wire_method: depositPayto.targetType,
    timestamp: nowRounded,
    merchant_base_url: "",
    summary: "",
    nonce: noncePair.pub,
    wire_transfer_deadline: wireDeadline,
    order_id: "",
    h_wire: wireHash,
    pay_deadline: AbsoluteTime.toProtocolTimestamp(
      AbsoluteTime.addDuration(now, Duration.fromSpec({ hours: 1 })),
    ),
    merchant: {
      name: "(wallet)",
    },
    merchant_pub: merchantPair.pub,
    refund_deadline: TalerProtocolTimestamp.zero(),
  };

  const { h: contractTermsHash } = await wex.cryptoApi.hashString({
    str: canonicalJson(contractTerms),
  });

  const contractData: DownloadedContractData = {
    contractTerms: contractTerms,
    contractTermsRaw: contractTerms,
    contractTermsHash,
  }

  if (
    contractData.contractTerms.version !== undefined &&
    contractData.contractTerms.version !== MerchantContractVersion.V0
  ) {
    throw Error(`unsupported contract version ${contractData.contractTerms.version}`);
  }

  const totalDepositCost = await getTotalPaymentCost(wex, currency, coins);

  let depositGroupId: string;
  if (req.transactionId) {
    const txId = parseTransactionIdentifier(req.transactionId);
    if (!txId || txId.tag !== TransactionType.Deposit) {
      throw Error("invalid transaction ID");
    }
    depositGroupId = txId.depositGroupId;
  } else {
    depositGroupId = encodeCrock(getRandomBytes(32));
  }

  const infoPerExchange: Record<string, DepositInfoPerExchange> = {};

  for (let i = 0; i < coins.length; i++) {
    let depPerExchange = infoPerExchange[coins[i].exchangeBaseUrl];
    if (!depPerExchange) {
      infoPerExchange[coins[i].exchangeBaseUrl] = depPerExchange = {
        amountEffective: Amounts.stringify(
          Amounts.zeroOfAmount(totalDepositCost),
        ),
      };
    }
    const contrib = coins[i].contribution;
    depPerExchange.amountEffective = Amounts.stringify(
      Amounts.add(depPerExchange.amountEffective, contrib).amount,
    );
  }

  const counterpartyEffectiveDepositAmount =
    await getCounterpartyEffectiveDepositAmount(
      wex,
      depositPayto.targetType,
      coins,
    );

  const depositGroup: DepositGroupRecord = {
    contractTermsHash,
    depositGroupId,
    currency: Amounts.currencyOf(totalDepositCost),
    amount: contractData.contractTerms.amount,
    noncePriv: noncePair.priv,
    noncePub: noncePair.pub,
    timestampCreated: timestampPreciseToDb(
      AbsoluteTime.toPreciseTimestamp(now),
    ),
    timestampFinished: undefined,
    statusPerCoin: undefined,
    payCoinSelection: undefined,
    payCoinSelectionUid: undefined,
    merchantPriv: merchantPair.priv,
    merchantPub: merchantPair.pub,
    totalPayCost: Amounts.stringify(totalDepositCost),
    counterpartyEffectiveDepositAmount: Amounts.stringify(
      counterpartyEffectiveDepositAmount,
    ),
    wireTransferDeadline: timestampProtocolToDb(
      contractTerms.wire_transfer_deadline,
    ),
    wire: {
      payto_uri: req.depositPaytoUri,
      salt: wireSalt,
    },
    timestampLastDepositAttempt: undefined,
    operationStatus: DepositOperationStatus.PendingDeposit,
    infoPerExchange,
  };

  if (payCoinSel.type === "success") {
    depositGroup.payCoinSelection = {
      coinContributions: payCoinSel.coinSel.coins.map((x) => x.contribution),
      coinPubs: payCoinSel.coinSel.coins.map((x) => x.coinPub),
    };
    depositGroup.payCoinSelectionUid = encodeCrock(getRandomBytes(32));
    depositGroup.statusPerCoin = payCoinSel.coinSel.coins.map(
      () => DepositElementStatus.DepositPending,
    );
  }

  const ctx = new DepositTransactionContext(wex, depositGroupId);
  const transactionId = ctx.transactionId;

  const newTxState = await wex.db.runReadWriteTx(
    {
      storeNames: [
        "coinAvailability",
        "coinHistory",
        "coins",
        "contractTerms",
        "denominations",
        "depositGroups",
        "recoupGroups",
        "refreshGroups",
        "refreshSessions",
        "transactionsMeta",
      ],
    },
    async (tx) => {
      if (depositGroup.payCoinSelection) {
        await spendCoins(wex, tx, {
          transactionId,
          coinPubs: depositGroup.payCoinSelection.coinPubs,
          contributions: depositGroup.payCoinSelection.coinContributions.map(
            (x) => Amounts.parseOrThrow(x),
          ),
          refreshReason: RefreshReason.PayDeposit,
        });
      }
      await tx.depositGroups.put(depositGroup);
      await tx.contractTerms.put({
        contractTermsRaw: contractTerms,
        h: contractTermsHash,
      });
      await ctx.updateTransactionMeta(tx);
      const oldTxState = { major: TransactionMajorState.None };
      const oldStId = 0;
      const newTxState = computeDepositTransactionStatus(depositGroup);
      const newStId = depositGroup.operationStatus;
      applyNotifyTransition(tx.notify, transactionId, {
        oldTxState,
        newTxState,
        balanceEffect: BalanceEffect.Any,
        oldStId,
        newStId,
      });
      return newTxState;
    },
  );

  wex.taskScheduler.startShepherdTask(ctx.taskId);

  return {
    depositGroupId,
    transactionId,
    txState: newTxState,
  };
}

/**
 * Get the amount that will be deposited on the users bank
 * account after depositing, not considering aggregation.
 */
async function getCounterpartyEffectiveDepositAmount(
  wex: WalletExecutionContext,
  wireType: string,
  pcs: SelectedProspectiveCoin[],
): Promise<AmountJson> {
  const amt: AmountJson[] = [];
  const fees: AmountJson[] = [];
  const exchangeSet: Set<string> = new Set();

  await wex.db.runReadOnlyTx(
    { storeNames: ["coins", "denominations", "exchangeDetails", "exchanges"] },
    async (tx) => {
      for (let i = 0; i < pcs.length; i++) {
        const denom = await getDenomInfo(
          wex,
          tx,
          pcs[i].exchangeBaseUrl,
          pcs[i].denomPubHash,
        );
        if (!denom) {
          throw Error("can't find denomination to calculate deposit amount");
        }
        amt.push(Amounts.parseOrThrow(pcs[i].contribution));
        fees.push(Amounts.parseOrThrow(denom.feeDeposit));
        exchangeSet.add(pcs[i].exchangeBaseUrl);
      }

      for (const exchangeUrl of exchangeSet.values()) {
        const exchangeDetails = await getExchangeWireDetailsInTx(
          tx,
          exchangeUrl,
        );
        if (!exchangeDetails) {
          continue;
        }

        // FIXME/NOTE: the line below _likely_ throws exception
        // about "find method not found on undefined" when the wireType
        // is not supported by the Exchange.
        const fee = exchangeDetails.wireInfo.feesForType[wireType].find((x) => {
          return AbsoluteTime.isBetween(
            AbsoluteTime.now(),
            AbsoluteTime.fromProtocolTimestamp(x.startStamp),
            AbsoluteTime.fromProtocolTimestamp(x.endStamp),
          );
        })?.wireFee;
        if (fee) {
          fees.push(Amounts.parseOrThrow(fee));
        }
      }
    },
  );
  return Amounts.sub(Amounts.sum(amt).amount, Amounts.sum(fees).amount).amount;
}

/**
 * Get the fee amount that will be charged when trying to deposit the
 * specified amount using the selected coins and the wire method.
 */
async function getTotalFeesForDepositAmount(
  wex: WalletExecutionContext,
  wireType: string,
  total: AmountJson,
  pcs: SelectedProspectiveCoin[],
): Promise<DepositGroupFees> {
  const wireFee: AmountJson[] = [];
  const coinFee: AmountJson[] = [];
  const refreshFee: AmountJson[] = [];
  const exchangeSet: Set<string> = new Set();

  await wex.db.runReadOnlyTx(
    { storeNames: ["coins", "denominations", "exchanges", "exchangeDetails"] },
    async (tx) => {
      for (let i = 0; i < pcs.length; i++) {
        const denom = await getDenomInfo(
          wex,
          tx,
          pcs[i].exchangeBaseUrl,
          pcs[i].denomPubHash,
        );
        if (!denom) {
          throw Error("can't find denomination to calculate deposit amount");
        }
        coinFee.push(Amounts.parseOrThrow(denom.feeDeposit));
        exchangeSet.add(pcs[i].exchangeBaseUrl);
        const amountLeft = Amounts.sub(denom.value, pcs[i].contribution).amount;
        const refreshCost = await getTotalRefreshCost(
          wex,
          tx,
          denom,
          amountLeft,
        );
        refreshFee.push(refreshCost);
      }

      for (const exchangeUrl of exchangeSet.values()) {
        const exchangeDetails = await getExchangeWireDetailsInTx(
          tx,
          exchangeUrl,
        );
        if (!exchangeDetails) {
          continue;
        }
        const fee = exchangeDetails.wireInfo.feesForType[wireType]?.find(
          (x) => {
            return AbsoluteTime.isBetween(
              AbsoluteTime.now(),
              AbsoluteTime.fromProtocolTimestamp(x.startStamp),
              AbsoluteTime.fromProtocolTimestamp(x.endStamp),
            );
          },
        )?.wireFee;
        if (fee) {
          wireFee.push(Amounts.parseOrThrow(fee));
        }
      }
    },
  );

  return {
    coin: Amounts.stringify(Amounts.sumOrZero(total.currency, coinFee).amount),
    wire: Amounts.stringify(Amounts.sumOrZero(total.currency, wireFee).amount),
    refresh: Amounts.stringify(
      Amounts.sumOrZero(total.currency, refreshFee).amount,
    ),
  };
}
