/*
 This file is part of GNU Taler
 (C) 2019-2021 Taler Systems SA

 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 {
  AbsoluteTime,
  AcceptManualWithdrawalResult,
  AcceptWithdrawalResponse,
  addPaytoQueryParams,
  AgeRestriction,
  AmountJson,
  AmountLike,
  Amounts,
  BankWithdrawDetails,
  CancellationToken,
  canonicalizeBaseUrl,
  codecForBankWithdrawalOperationPostResponse,
  codecForReserveStatus,
  codecForTalerConfigResponse,
  codecForWalletKycUuid,
  codecForWithdrawBatchResponse,
  codecForWithdrawOperationStatusResponse,
  codecForWithdrawResponse,
  CoinStatus,
  DenomKeyType,
  DenomSelectionState,
  Duration,
  encodeCrock,
  ExchangeListItem,
  ExchangeWithdrawalDetails,
  ExchangeWithdrawRequest,
  ForcedDenomSel,
  getRandomBytes,
  HttpStatusCode,
  j2s,
  LibtoolVersion,
  Logger,
  NotificationType,
  parseWithdrawUri,
  TalerErrorCode,
  TalerErrorDetail,
  TalerProtocolTimestamp,
  TransactionType,
  UnblindedSignature,
  URL,
  ExchangeWithdrawBatchResponse,
  ExchangeWithdrawResponse,
  WithdrawUriInfoResponse,
  ExchangeBatchWithdrawRequest,
  WalletNotification,
  TransactionState,
  TransactionMajorState,
  TransactionMinorState,
  TalerPreciseTimestamp,
  TransactionAction,
} from "@gnu-taler/taler-util";
import { EddsaKeypair } from "../crypto/cryptoImplementation.js";
import {
  CoinRecord,
  CoinSourceType,
  DenominationRecord,
  DenominationVerificationStatus,
  KycPendingInfo,
  KycUserType,
  PlanchetRecord,
  PlanchetStatus,
  WalletStoresV1,
  WgInfo,
  WithdrawalGroupRecord,
  WithdrawalGroupStatus,
  WithdrawalRecordType,
} from "../db.js";
import {
  getErrorDetailFromException,
  makeErrorDetail,
  TalerError,
} from "@gnu-taler/taler-util";
import { InternalWalletState } from "../internal-wallet-state.js";
import {
  makeCoinAvailable,
  makeExchangeListItem,
  runLongpollAsync,
  runOperationWithErrorReporting,
} from "../operations/common.js";
import {
  HttpRequestLibrary,
  HttpResponse,
  readSuccessResponseJsonOrErrorCode,
  readSuccessResponseJsonOrThrow,
  throwUnexpectedRequestError,
} from "@gnu-taler/taler-util/http";
import {
  checkDbInvariant,
  checkLogicInvariant,
  InvariantViolatedError,
} from "../util/invariants.js";
import { DbAccess, GetReadOnlyAccess } from "../util/query.js";
import {
  OperationAttemptResult,
  OperationAttemptResultType,
  TaskIdentifiers,
  constructTaskIdentifier,
} from "../util/retries.js";
import {
  WALLET_BANK_INTEGRATION_PROTOCOL_VERSION,
  WALLET_EXCHANGE_PROTOCOL_VERSION,
} from "../versions.js";
import {
  getExchangeDetails,
  getExchangePaytoUri,
  getExchangeTrust,
  updateExchangeFromUrl,
} from "./exchanges.js";
import {
  selectForcedWithdrawalDenominations,
  selectWithdrawalDenominations,
} from "../util/coinSelection.js";
import { PendingTaskType, isWithdrawableDenom } from "../index.js";
import {
  constructTransactionIdentifier,
  notifyTransition,
  stopLongpolling,
} from "./transactions.js";
import { assertUnreachable } from "../util/assertUnreachable.js";

/**
 * Logger for this file.
 */
const logger = new Logger("operations/withdraw.ts");

export async function suspendWithdrawalTransaction(
  ws: InternalWalletState,
  withdrawalGroupId: string,
) {
  const taskId = constructTaskIdentifier({
    tag: PendingTaskType.Withdraw,
    withdrawalGroupId,
  });
  stopLongpolling(ws, taskId);
  const transitionInfo = await ws.db
    .mktx((x) => [x.withdrawalGroups])
    .runReadWrite(async (tx) => {
      const wg = await tx.withdrawalGroups.get(withdrawalGroupId);
      if (!wg) {
        logger.warn(`withdrawal group ${withdrawalGroupId} not found`);
        return;
      }
      let newStatus: WithdrawalGroupStatus | undefined = undefined;
      switch (wg.status) {
        case WithdrawalGroupStatus.PendingReady:
          newStatus = WithdrawalGroupStatus.SuspendedReady;
          break;
        case WithdrawalGroupStatus.AbortingBank:
          newStatus = WithdrawalGroupStatus.SuspendedAbortingBank;
          break;
        case WithdrawalGroupStatus.PendingWaitConfirmBank:
          newStatus = WithdrawalGroupStatus.SuspendedWaitConfirmBank;
          break;
        case WithdrawalGroupStatus.PendingRegisteringBank:
          newStatus = WithdrawalGroupStatus.SuspendedRegisteringBank;
          break;
        case WithdrawalGroupStatus.PendingQueryingStatus:
          newStatus = WithdrawalGroupStatus.SuspendedQueryingStatus;
          break;
        case WithdrawalGroupStatus.PendingKyc:
          newStatus = WithdrawalGroupStatus.SuspendedKyc;
          break;
        case WithdrawalGroupStatus.PendingAml:
          newStatus = WithdrawalGroupStatus.SuspendedAml;
          break;
        default:
          logger.warn(
            `Unsupported 'suspend' on withdrawal transaction in status ${wg.status}`,
          );
      }
      if (newStatus != null) {
        const oldTxState = computeWithdrawalTransactionStatus(wg);
        wg.status = newStatus;
        const newTxState = computeWithdrawalTransactionStatus(wg);
        await tx.withdrawalGroups.put(wg);
        return {
          oldTxState,
          newTxState,
        };
      }
      return undefined;
    });

  const transactionId = constructTransactionIdentifier({
    tag: TransactionType.Withdrawal,
    withdrawalGroupId,
  });
  notifyTransition(ws, transactionId, transitionInfo);
}

export async function resumeWithdrawalTransaction(
  ws: InternalWalletState,
  withdrawalGroupId: string,
) {
  const transitionInfo = await ws.db
    .mktx((x) => [x.withdrawalGroups])
    .runReadWrite(async (tx) => {
      const wg = await tx.withdrawalGroups.get(withdrawalGroupId);
      if (!wg) {
        logger.warn(`withdrawal group ${withdrawalGroupId} not found`);
        return;
      }
      let newStatus: WithdrawalGroupStatus | undefined = undefined;
      switch (wg.status) {
        case WithdrawalGroupStatus.SuspendedReady:
          newStatus = WithdrawalGroupStatus.PendingReady;
          break;
        case WithdrawalGroupStatus.SuspendedAbortingBank:
          newStatus = WithdrawalGroupStatus.AbortingBank;
          break;
        case WithdrawalGroupStatus.SuspendedWaitConfirmBank:
          newStatus = WithdrawalGroupStatus.PendingWaitConfirmBank;
          break;
        case WithdrawalGroupStatus.SuspendedQueryingStatus:
          newStatus = WithdrawalGroupStatus.PendingQueryingStatus;
          break;
        case WithdrawalGroupStatus.SuspendedRegisteringBank:
          newStatus = WithdrawalGroupStatus.PendingRegisteringBank;
          break;
        case WithdrawalGroupStatus.SuspendedAml:
          newStatus = WithdrawalGroupStatus.PendingAml;
          break;
        case WithdrawalGroupStatus.SuspendedKyc:
          newStatus = WithdrawalGroupStatus.PendingKyc;
          break;
        default:
          logger.warn(
            `Unsupported 'resume' on withdrawal transaction in status ${wg.status}`,
          );
      }
      if (newStatus != null) {
        const oldTxState = computeWithdrawalTransactionStatus(wg);
        wg.status = newStatus;
        const newTxState = computeWithdrawalTransactionStatus(wg);
        await tx.withdrawalGroups.put(wg);
        return {
          oldTxState,
          newTxState,
        };
      }
      return undefined;
    });
  ws.workAvailable.trigger();
  const transactionId = constructTransactionIdentifier({
    tag: TransactionType.Withdrawal,
    withdrawalGroupId,
  });
  notifyTransition(ws, transactionId, transitionInfo);
}

export async function abortWithdrawalTransaction(
  ws: InternalWalletState,
  withdrawalGroupId: string,
) {
  const taskId = constructTaskIdentifier({
    tag: PendingTaskType.Withdraw,
    withdrawalGroupId,
  });
  const transactionId = constructTransactionIdentifier({
    tag: TransactionType.Withdrawal,
    withdrawalGroupId,
  });
  stopLongpolling(ws, taskId);
  const transitionInfo = await ws.db
    .mktx((x) => [x.withdrawalGroups])
    .runReadWrite(async (tx) => {
      const wg = await tx.withdrawalGroups.get(withdrawalGroupId);
      if (!wg) {
        logger.warn(`withdrawal group ${withdrawalGroupId} not found`);
        return;
      }
      let newStatus: WithdrawalGroupStatus | undefined = undefined;
      switch (wg.status) {
        case WithdrawalGroupStatus.SuspendedRegisteringBank:
        case WithdrawalGroupStatus.SuspendedWaitConfirmBank:
        case WithdrawalGroupStatus.PendingWaitConfirmBank:
        case WithdrawalGroupStatus.PendingRegisteringBank:
          newStatus = WithdrawalGroupStatus.AbortingBank;
          break;
        case WithdrawalGroupStatus.SuspendedAml:
        case WithdrawalGroupStatus.SuspendedKyc:
        case WithdrawalGroupStatus.SuspendedQueryingStatus:
        case WithdrawalGroupStatus.SuspendedReady:
        case WithdrawalGroupStatus.PendingAml:
        case WithdrawalGroupStatus.PendingKyc:
        case WithdrawalGroupStatus.PendingQueryingStatus:
          newStatus = WithdrawalGroupStatus.AbortedExchange;
          break;
        case WithdrawalGroupStatus.PendingReady:
          newStatus = WithdrawalGroupStatus.SuspendedReady;
          break;
        case WithdrawalGroupStatus.SuspendedAbortingBank:
        case WithdrawalGroupStatus.AbortingBank:
          // No transition needed, but not an error
          break;
        case WithdrawalGroupStatus.Finished:
        case WithdrawalGroupStatus.FailedBankAborted:
        case WithdrawalGroupStatus.AbortedExchange:
        case WithdrawalGroupStatus.AbortedBank:
        case WithdrawalGroupStatus.FailedAbortingBank:
          // Not allowed
          throw Error("abort not allowed in current state");
          break;
        default:
          assertUnreachable(wg.status);
      }
      if (newStatus != null) {
        const oldTxState = computeWithdrawalTransactionStatus(wg);
        wg.status = newStatus;
        const newTxState = computeWithdrawalTransactionStatus(wg);
        await tx.withdrawalGroups.put(wg);
        return {
          oldTxState,
          newTxState,
        };
      }
      return undefined;
    });
  ws.workAvailable.trigger();
  notifyTransition(ws, transactionId, transitionInfo);
}

export async function failWithdrawalTransaction(
  ws: InternalWalletState,
  withdrawalGroupId: string,
) {
  const taskId = constructTaskIdentifier({
    tag: PendingTaskType.Withdraw,
    withdrawalGroupId,
  });
  const transactionId = constructTransactionIdentifier({
    tag: TransactionType.Withdrawal,
    withdrawalGroupId,
  });
  stopLongpolling(ws, taskId);
  const stateUpdate = await ws.db
    .mktx((x) => [x.withdrawalGroups])
    .runReadWrite(async (tx) => {
      const wg = await tx.withdrawalGroups.get(withdrawalGroupId);
      if (!wg) {
        logger.warn(`withdrawal group ${withdrawalGroupId} not found`);
        return;
      }
      let newStatus: WithdrawalGroupStatus | undefined = undefined;
      switch (wg.status) {
        case WithdrawalGroupStatus.SuspendedAbortingBank:
        case WithdrawalGroupStatus.AbortingBank:
          newStatus = WithdrawalGroupStatus.FailedAbortingBank;
          break;
        default:
          break;
      }
      if (newStatus != null) {
        const oldTxState = computeWithdrawalTransactionStatus(wg);
        wg.status = newStatus;
        const newTxState = computeWithdrawalTransactionStatus(wg);
        await tx.withdrawalGroups.put(wg);
        return {
          oldTxState,
          newTxState,
        };
      }
      return undefined;
    });
  notifyTransition(ws, transactionId, stateUpdate);
}

export function computeWithdrawalTransactionStatus(
  wgRecord: WithdrawalGroupRecord,
): TransactionState {
  switch (wgRecord.status) {
    case WithdrawalGroupStatus.FailedBankAborted:
      return {
        major: TransactionMajorState.Aborted,
      };
    case WithdrawalGroupStatus.Finished:
      return {
        major: TransactionMajorState.Done,
      };
    case WithdrawalGroupStatus.PendingRegisteringBank:
      return {
        major: TransactionMajorState.Pending,
        minor: TransactionMinorState.BankRegisterReserve,
      };
    case WithdrawalGroupStatus.PendingReady:
      return {
        major: TransactionMajorState.Pending,
        minor: TransactionMinorState.WithdrawCoins,
      };
    case WithdrawalGroupStatus.PendingQueryingStatus:
      return {
        major: TransactionMajorState.Pending,
        minor: TransactionMinorState.ExchangeWaitReserve,
      };
    case WithdrawalGroupStatus.PendingWaitConfirmBank:
      return {
        major: TransactionMajorState.Pending,
        minor: TransactionMinorState.BankConfirmTransfer,
      };
    case WithdrawalGroupStatus.AbortingBank:
      return {
        major: TransactionMajorState.Aborting,
        minor: TransactionMinorState.Bank,
      };
    case WithdrawalGroupStatus.SuspendedAbortingBank:
      return {
        major: TransactionMajorState.SuspendedAborting,
        minor: TransactionMinorState.Bank,
      };
    case WithdrawalGroupStatus.SuspendedQueryingStatus:
      return {
        major: TransactionMajorState.Suspended,
        minor: TransactionMinorState.ExchangeWaitReserve,
      };
    case WithdrawalGroupStatus.SuspendedRegisteringBank:
      return {
        major: TransactionMajorState.Suspended,
        minor: TransactionMinorState.BankRegisterReserve,
      };
    case WithdrawalGroupStatus.SuspendedWaitConfirmBank:
      return {
        major: TransactionMajorState.Suspended,
        minor: TransactionMinorState.BankConfirmTransfer,
      };
    case WithdrawalGroupStatus.SuspendedReady: {
      return {
        major: TransactionMajorState.Suspended,
        minor: TransactionMinorState.WithdrawCoins,
      };
    }
    case WithdrawalGroupStatus.PendingAml: {
      return {
        major: TransactionMajorState.Pending,
        minor: TransactionMinorState.AmlRequired,
      };
    }
    case WithdrawalGroupStatus.PendingKyc: {
      return {
        major: TransactionMajorState.Pending,
        minor: TransactionMinorState.KycRequired,
      };
    }
    case WithdrawalGroupStatus.SuspendedAml: {
      return {
        major: TransactionMajorState.Suspended,
        minor: TransactionMinorState.AmlRequired,
      };
    }
    case WithdrawalGroupStatus.SuspendedKyc: {
      return {
        major: TransactionMajorState.Suspended,
        minor: TransactionMinorState.KycRequired,
      };
    }
    case WithdrawalGroupStatus.FailedAbortingBank:
      return {
        major: TransactionMajorState.Failed,
        minor: TransactionMinorState.AbortingBank,
      };
    case WithdrawalGroupStatus.AbortedExchange:
      return {
        major: TransactionMajorState.Aborted,
        minor: TransactionMinorState.Exchange,
      };

    case WithdrawalGroupStatus.AbortedBank:
      return {
        major: TransactionMajorState.Aborted,
        minor: TransactionMinorState.Bank,
      };
  }
}

export function computeWithdrawalTransactionActions(
  wgRecord: WithdrawalGroupRecord,
): TransactionAction[] {
  switch (wgRecord.status) {
    case WithdrawalGroupStatus.FailedBankAborted:
      return [TransactionAction.Delete];
    case WithdrawalGroupStatus.Finished:
      return [TransactionAction.Delete];
    case WithdrawalGroupStatus.PendingRegisteringBank:
      return [TransactionAction.Suspend, TransactionAction.Abort];
    case WithdrawalGroupStatus.PendingReady:
      return [TransactionAction.Suspend, TransactionAction.Abort];
    case WithdrawalGroupStatus.PendingQueryingStatus:
      return [TransactionAction.Suspend, TransactionAction.Abort];
    case WithdrawalGroupStatus.PendingWaitConfirmBank:
      return [TransactionAction.Suspend, TransactionAction.Abort];
    case WithdrawalGroupStatus.AbortingBank:
      return [TransactionAction.Suspend, TransactionAction.Fail];
    case WithdrawalGroupStatus.SuspendedAbortingBank:
      return [TransactionAction.Resume, TransactionAction.Fail];
    case WithdrawalGroupStatus.SuspendedQueryingStatus:
      return [TransactionAction.Resume, TransactionAction.Abort];
    case WithdrawalGroupStatus.SuspendedRegisteringBank:
      return [TransactionAction.Resume, TransactionAction.Abort];
    case WithdrawalGroupStatus.SuspendedWaitConfirmBank:
      return [TransactionAction.Resume, TransactionAction.Abort];
    case WithdrawalGroupStatus.SuspendedReady:
      return [TransactionAction.Resume, TransactionAction.Abort];
    case WithdrawalGroupStatus.PendingAml:
      return [TransactionAction.Resume, TransactionAction.Abort];
    case WithdrawalGroupStatus.PendingKyc:
      return [TransactionAction.Resume, TransactionAction.Abort];
    case WithdrawalGroupStatus.SuspendedAml:
      return [TransactionAction.Resume, TransactionAction.Abort];
    case WithdrawalGroupStatus.SuspendedKyc:
      return [TransactionAction.Resume, TransactionAction.Abort];
    case WithdrawalGroupStatus.FailedAbortingBank:
      return [TransactionAction.Delete];
    case WithdrawalGroupStatus.AbortedExchange:
      return [TransactionAction.Delete];
    case WithdrawalGroupStatus.AbortedBank:
      return [TransactionAction.Delete];
  }
}

/**
 * Get information about a withdrawal from
 * a taler://withdraw URI by asking the bank.
 *
 * FIXME: Move into bank client.
 */
export async function getBankWithdrawalInfo(
  http: HttpRequestLibrary,
  talerWithdrawUri: string,
): Promise<BankWithdrawDetails> {
  const uriResult = parseWithdrawUri(talerWithdrawUri);
  if (!uriResult) {
    throw Error(`can't parse URL ${talerWithdrawUri}`);
  }

  const configReqUrl = new URL("config", uriResult.bankIntegrationApiBaseUrl);

  const configResp = await http.get(configReqUrl.href);
  const config = await readSuccessResponseJsonOrThrow(
    configResp,
    codecForTalerConfigResponse(),
  );

  const versionRes = LibtoolVersion.compare(
    WALLET_BANK_INTEGRATION_PROTOCOL_VERSION,
    config.version,
  );
  if (versionRes?.compatible != true) {
    throw TalerError.fromDetail(
      TalerErrorCode.WALLET_BANK_INTEGRATION_PROTOCOL_VERSION_INCOMPATIBLE,
      {
        exchangeProtocolVersion: config.version,
        walletProtocolVersion: WALLET_BANK_INTEGRATION_PROTOCOL_VERSION,
      },
      "bank integration protocol version not compatible with wallet",
    );
  }

  const reqUrl = new URL(
    `withdrawal-operation/${uriResult.withdrawalOperationId}`,
    uriResult.bankIntegrationApiBaseUrl,
  );

  logger.info(`bank withdrawal status URL: ${reqUrl.href}}`);

  const resp = await http.get(reqUrl.href);
  const status = await readSuccessResponseJsonOrThrow(
    resp,
    codecForWithdrawOperationStatusResponse(),
  );

  logger.info(`bank withdrawal operation status: ${j2s(status)}`);

  return {
    amount: Amounts.parseOrThrow(status.amount),
    confirmTransferUrl: status.confirm_transfer_url,
    selectionDone: status.selection_done,
    senderWire: status.sender_wire,
    suggestedExchange: status.suggested_exchange,
    transferDone: status.transfer_done,
    wireTypes: status.wire_types,
  };
}

/**
 * Return denominations that can potentially used for a withdrawal.
 */
export async function getCandidateWithdrawalDenoms(
  ws: InternalWalletState,
  exchangeBaseUrl: string,
): Promise<DenominationRecord[]> {
  return await ws.db
    .mktx((x) => [x.denominations])
    .runReadOnly(async (tx) => {
      const allDenoms = await tx.denominations.indexes.byExchangeBaseUrl.getAll(
        exchangeBaseUrl,
      );
      return allDenoms.filter((d) =>
        isWithdrawableDenom(d, ws.config.testing.denomselAllowLate),
      );
    });
}

/**
 * Generate a planchet for a coin index in a withdrawal group.
 * Does not actually withdraw the coin yet.
 *
 * Split up so that we can parallelize the crypto, but serialize
 * the exchange requests per reserve.
 */
async function processPlanchetGenerate(
  ws: InternalWalletState,
  withdrawalGroup: WithdrawalGroupRecord,
  coinIdx: number,
): Promise<void> {
  let planchet = await ws.db
    .mktx((x) => [x.planchets])
    .runReadOnly(async (tx) => {
      return tx.planchets.indexes.byGroupAndIndex.get([
        withdrawalGroup.withdrawalGroupId,
        coinIdx,
      ]);
    });
  if (planchet) {
    return;
  }
  let ci = 0;
  let maybeDenomPubHash: string | undefined;
  for (let di = 0; di < withdrawalGroup.denomsSel.selectedDenoms.length; di++) {
    const d = withdrawalGroup.denomsSel.selectedDenoms[di];
    if (coinIdx >= ci && coinIdx < ci + d.count) {
      maybeDenomPubHash = d.denomPubHash;
      break;
    }
    ci += d.count;
  }
  if (!maybeDenomPubHash) {
    throw Error("invariant violated");
  }
  const denomPubHash = maybeDenomPubHash;

  const denom = await ws.db
    .mktx((x) => [x.denominations])
    .runReadOnly(async (tx) => {
      return ws.getDenomInfo(
        ws,
        tx,
        withdrawalGroup.exchangeBaseUrl,
        denomPubHash,
      );
    });
  checkDbInvariant(!!denom);
  const r = await ws.cryptoApi.createPlanchet({
    denomPub: denom.denomPub,
    feeWithdraw: Amounts.parseOrThrow(denom.feeWithdraw),
    reservePriv: withdrawalGroup.reservePriv,
    reservePub: withdrawalGroup.reservePub,
    value: Amounts.parseOrThrow(denom.value),
    coinIndex: coinIdx,
    secretSeed: withdrawalGroup.secretSeed,
    restrictAge: withdrawalGroup.restrictAge,
  });
  const newPlanchet: PlanchetRecord = {
    blindingKey: r.blindingKey,
    coinEv: r.coinEv,
    coinEvHash: r.coinEvHash,
    coinIdx,
    coinPriv: r.coinPriv,
    coinPub: r.coinPub,
    denomPubHash: r.denomPubHash,
    planchetStatus: PlanchetStatus.Pending,
    withdrawSig: r.withdrawSig,
    withdrawalGroupId: withdrawalGroup.withdrawalGroupId,
    ageCommitmentProof: r.ageCommitmentProof,
    lastError: undefined,
  };
  await ws.db
    .mktx((x) => [x.planchets])
    .runReadWrite(async (tx) => {
      const p = await tx.planchets.indexes.byGroupAndIndex.get([
        withdrawalGroup.withdrawalGroupId,
        coinIdx,
      ]);
      if (p) {
        planchet = p;
        return;
      }
      await tx.planchets.put(newPlanchet);
      planchet = newPlanchet;
    });
}

interface WithdrawalRequestBatchArgs {
  /**
   * Use the batched request on the network level.
   * Not supported by older exchanges.
   */
  useBatchRequest: boolean;

  coinStartIndex: number;

  batchSize: number;
}

interface WithdrawalBatchResult {
  coinIdxs: number[];
  batchResp: ExchangeWithdrawBatchResponse;
}

/**
 * Send the withdrawal request for a generated planchet to the exchange.
 *
 * The verification of the response is done asynchronously to enable parallelism.
 */
async function processPlanchetExchangeBatchRequest(
  ws: InternalWalletState,
  wgContext: WithdrawalGroupContext,
  args: WithdrawalRequestBatchArgs,
): Promise<WithdrawalBatchResult> {
  const withdrawalGroup: WithdrawalGroupRecord = wgContext.wgRecord;
  logger.info(
    `processing planchet exchange batch request ${withdrawalGroup.withdrawalGroupId}, start=${args.coinStartIndex}, len=${args.batchSize}`,
  );

  const batchReq: ExchangeBatchWithdrawRequest = { planchets: [] };
  // Indices of coins that are included in the batch request
  const requestCoinIdxs: number[] = [];

  await ws.db
    .mktx((x) => [
      x.withdrawalGroups,
      x.planchets,
      x.exchanges,
      x.denominations,
    ])
    .runReadOnly(async (tx) => {
      for (
        let coinIdx = args.coinStartIndex;
        coinIdx < args.coinStartIndex + args.batchSize &&
        coinIdx < wgContext.numPlanchets;
        coinIdx++
      ) {
        let planchet = await tx.planchets.indexes.byGroupAndIndex.get([
          withdrawalGroup.withdrawalGroupId,
          coinIdx,
        ]);
        if (!planchet) {
          continue;
        }
        if (planchet.planchetStatus === PlanchetStatus.WithdrawalDone) {
          logger.warn("processPlanchet: planchet already withdrawn");
          continue;
        }
        const denom = await ws.getDenomInfo(
          ws,
          tx,
          withdrawalGroup.exchangeBaseUrl,
          planchet.denomPubHash,
        );

        if (!denom) {
          logger.error("db inconsistent: denom for planchet not found");
          continue;
        }

        const planchetReq: ExchangeWithdrawRequest = {
          denom_pub_hash: planchet.denomPubHash,
          reserve_sig: planchet.withdrawSig,
          coin_ev: planchet.coinEv,
        };
        batchReq.planchets.push(planchetReq);
        requestCoinIdxs.push(coinIdx);
      }
    });

  if (batchReq.planchets.length == 0) {
    logger.warn("empty withdrawal batch");
    return {
      batchResp: { ev_sigs: [] },
      coinIdxs: [],
    };
  }

  async function handleKycRequired(
    resp: HttpResponse,
    startIdx: number,
  ): Promise<void> {
    logger.info("withdrawal requires KYC");
    const respJson = await resp.json();
    const uuidResp = codecForWalletKycUuid().decode(respJson);
    logger.info(`kyc uuid response: ${j2s(uuidResp)}`);
    await ws.db
      .mktx((x) => [x.planchets, x.withdrawalGroups])
      .runReadWrite(async (tx) => {
        for (let i = startIdx; i < requestCoinIdxs.length; i++) {
          let planchet = await tx.planchets.indexes.byGroupAndIndex.get([
            withdrawalGroup.withdrawalGroupId,
            requestCoinIdxs[i],
          ]);
          if (!planchet) {
            continue;
          }
          planchet.planchetStatus = PlanchetStatus.KycRequired;
          await tx.planchets.put(planchet);
        }
        const wg2 = await tx.withdrawalGroups.get(
          withdrawalGroup.withdrawalGroupId,
        );
        if (!wg2) {
          return;
        }
        wg2.kycPending = {
          paytoHash: uuidResp.h_payto,
          requirementRow: uuidResp.requirement_row,
        };
        await tx.withdrawalGroups.put(wg2);
      });
    return;
  }

  async function storeCoinError(e: any, coinIdx: number): Promise<void> {
    const errDetail = getErrorDetailFromException(e);
    logger.trace("withdrawal request failed", e);
    logger.trace(String(e));
    await ws.db
      .mktx((x) => [x.planchets])
      .runReadWrite(async (tx) => {
        let planchet = await tx.planchets.indexes.byGroupAndIndex.get([
          withdrawalGroup.withdrawalGroupId,
          coinIdx,
        ]);
        if (!planchet) {
          return;
        }
        planchet.lastError = errDetail;
        await tx.planchets.put(planchet);
      });
  }

  // FIXME: handle individual error codes better!

  if (args.useBatchRequest) {
    const reqUrl = new URL(
      `reserves/${withdrawalGroup.reservePub}/batch-withdraw`,
      withdrawalGroup.exchangeBaseUrl,
    ).href;

    try {
      const resp = await ws.http.postJson(reqUrl, batchReq);
      if (resp.status === HttpStatusCode.UnavailableForLegalReasons) {
        await handleKycRequired(resp, 0);
      }
      const r = await readSuccessResponseJsonOrThrow(
        resp,
        codecForWithdrawBatchResponse(),
      );
      return {
        coinIdxs: requestCoinIdxs,
        batchResp: r,
      };
    } catch (e) {
      await storeCoinError(e, requestCoinIdxs[0]);
      return {
        batchResp: { ev_sigs: [] },
        coinIdxs: [],
      };
    }
  } else {
    // We emulate the batch response here by making multiple individual requests
    const responses: ExchangeWithdrawBatchResponse = {
      ev_sigs: [],
    };
    const responseCoinIdxs: number[] = [];
    for (let i = 0; i < batchReq.planchets.length; i++) {
      try {
        const p = batchReq.planchets[i];
        const reqUrl = new URL(
          `reserves/${withdrawalGroup.reservePub}/withdraw`,
          withdrawalGroup.exchangeBaseUrl,
        ).href;
        const resp = await ws.http.postJson(reqUrl, p);
        if (resp.status === HttpStatusCode.UnavailableForLegalReasons) {
          await handleKycRequired(resp, i);
          // We still return blinded coins that we could actually withdraw.
          return {
            coinIdxs: responseCoinIdxs,
            batchResp: responses,
          };
        }
        const r = await readSuccessResponseJsonOrThrow(
          resp,
          codecForWithdrawResponse(),
        );
        responses.ev_sigs.push(r);
        responseCoinIdxs.push(requestCoinIdxs[i]);
      } catch (e) {
        await storeCoinError(e, requestCoinIdxs[i]);
      }
    }
    return {
      coinIdxs: responseCoinIdxs,
      batchResp: responses,
    };
  }
}

async function processPlanchetVerifyAndStoreCoin(
  ws: InternalWalletState,
  wgContext: WithdrawalGroupContext,
  coinIdx: number,
  resp: ExchangeWithdrawResponse,
): Promise<void> {
  const withdrawalGroup = wgContext.wgRecord;
  logger.info(`checking and storing planchet idx=${coinIdx}`);
  const d = await ws.db
    .mktx((x) => [x.withdrawalGroups, x.planchets, x.denominations])
    .runReadOnly(async (tx) => {
      let planchet = await tx.planchets.indexes.byGroupAndIndex.get([
        withdrawalGroup.withdrawalGroupId,
        coinIdx,
      ]);
      if (!planchet) {
        return;
      }
      if (planchet.planchetStatus === PlanchetStatus.WithdrawalDone) {
        logger.warn("processPlanchet: planchet already withdrawn");
        return;
      }
      const denomInfo = await ws.getDenomInfo(
        ws,
        tx,
        withdrawalGroup.exchangeBaseUrl,
        planchet.denomPubHash,
      );
      if (!denomInfo) {
        return;
      }
      return {
        planchet,
        denomInfo,
        exchangeBaseUrl: withdrawalGroup.exchangeBaseUrl,
      };
    });

  if (!d) {
    return;
  }

  const { planchet, denomInfo } = d;

  const planchetDenomPub = denomInfo.denomPub;
  if (planchetDenomPub.cipher !== DenomKeyType.Rsa) {
    throw Error(`cipher (${planchetDenomPub.cipher}) not supported`);
  }

  let evSig = resp.ev_sig;
  if (!(evSig.cipher === DenomKeyType.Rsa)) {
    throw Error("unsupported cipher");
  }

  const denomSigRsa = await ws.cryptoApi.rsaUnblind({
    bk: planchet.blindingKey,
    blindedSig: evSig.blinded_rsa_signature,
    pk: planchetDenomPub.rsa_public_key,
  });

  const isValid = await ws.cryptoApi.rsaVerify({
    hm: planchet.coinPub,
    pk: planchetDenomPub.rsa_public_key,
    sig: denomSigRsa.sig,
  });

  if (!isValid) {
    await ws.db
      .mktx((x) => [x.planchets])
      .runReadWrite(async (tx) => {
        let planchet = await tx.planchets.indexes.byGroupAndIndex.get([
          withdrawalGroup.withdrawalGroupId,
          coinIdx,
        ]);
        if (!planchet) {
          return;
        }
        planchet.lastError = makeErrorDetail(
          TalerErrorCode.WALLET_EXCHANGE_COIN_SIGNATURE_INVALID,
          {},
          "invalid signature from the exchange after unblinding",
        );
        await tx.planchets.put(planchet);
      });
    return;
  }

  let denomSig: UnblindedSignature;
  if (planchetDenomPub.cipher === DenomKeyType.Rsa) {
    denomSig = {
      cipher: planchetDenomPub.cipher,
      rsa_signature: denomSigRsa.sig,
    };
  } else {
    throw Error("unsupported cipher");
  }

  const coin: CoinRecord = {
    blindingKey: planchet.blindingKey,
    coinPriv: planchet.coinPriv,
    coinPub: planchet.coinPub,
    denomPubHash: planchet.denomPubHash,
    denomSig,
    coinEvHash: planchet.coinEvHash,
    exchangeBaseUrl: d.exchangeBaseUrl,
    status: CoinStatus.Fresh,
    coinSource: {
      type: CoinSourceType.Withdraw,
      coinIndex: coinIdx,
      reservePub: withdrawalGroup.reservePub,
      withdrawalGroupId: withdrawalGroup.withdrawalGroupId,
    },
    maxAge: withdrawalGroup.restrictAge ?? AgeRestriction.AGE_UNRESTRICTED,
    ageCommitmentProof: planchet.ageCommitmentProof,
    spendAllocation: undefined,
  };

  const planchetCoinPub = planchet.coinPub;

  wgContext.planchetsFinished.add(planchet.coinPub);

  // We create the notification here, as the async transaction below
  // allows other planchet withdrawals to change wgContext.planchetsFinished
  const notification: WalletNotification = {
    type: NotificationType.CoinWithdrawn,
    numTotal: wgContext.numPlanchets,
    numWithdrawn: wgContext.planchetsFinished.size,
  };

  // Check if this is the first time that the whole
  // withdrawal succeeded.  If so, mark the withdrawal
  // group as finished.
  const firstSuccess = await ws.db
    .mktx((x) => [
      x.coins,
      x.denominations,
      x.coinAvailability,
      x.withdrawalGroups,
      x.planchets,
    ])
    .runReadWrite(async (tx) => {
      const p = await tx.planchets.get(planchetCoinPub);
      if (!p || p.planchetStatus === PlanchetStatus.WithdrawalDone) {
        return false;
      }
      p.planchetStatus = PlanchetStatus.WithdrawalDone;
      await tx.planchets.put(p);
      await makeCoinAvailable(ws, tx, coin);
      return true;
    });

  if (firstSuccess) {
    ws.notify(notification);
  }
}

/**
 * Make sure that denominations that currently can be used for withdrawal
 * are validated, and the result of validation is stored in the database.
 */
export async function updateWithdrawalDenoms(
  ws: InternalWalletState,
  exchangeBaseUrl: string,
): Promise<void> {
  logger.trace(
    `updating denominations used for withdrawal for ${exchangeBaseUrl}`,
  );
  const exchangeDetails = await ws.db
    .mktx((x) => [x.exchanges, x.exchangeDetails])
    .runReadOnly(async (tx) => {
      return ws.exchangeOps.getExchangeDetails(tx, exchangeBaseUrl);
    });
  if (!exchangeDetails) {
    logger.error("exchange details not available");
    throw Error(`exchange ${exchangeBaseUrl} details not available`);
  }
  // First do a pass where the validity of candidate denominations
  // is checked and the result is stored in the database.
  logger.trace("getting candidate denominations");
  const denominations = await getCandidateWithdrawalDenoms(ws, exchangeBaseUrl);
  logger.trace(`got ${denominations.length} candidate denominations`);
  const batchSize = 500;
  let current = 0;

  while (current < denominations.length) {
    const updatedDenominations: DenominationRecord[] = [];
    // Do a batch of batchSize
    for (
      let batchIdx = 0;
      batchIdx < batchSize && current < denominations.length;
      batchIdx++, current++
    ) {
      const denom = denominations[current];
      if (
        denom.verificationStatus === DenominationVerificationStatus.Unverified
      ) {
        logger.trace(
          `Validating denomination (${current + 1}/${
            denominations.length
          }) signature of ${denom.denomPubHash}`,
        );
        let valid = false;
        if (ws.config.testing.insecureTrustExchange) {
          valid = true;
        } else {
          const res = await ws.cryptoApi.isValidDenom({
            denom,
            masterPub: exchangeDetails.masterPublicKey,
          });
          valid = res.valid;
        }
        logger.trace(`Done validating ${denom.denomPubHash}`);
        if (!valid) {
          logger.warn(
            `Signature check for denomination h=${denom.denomPubHash} failed`,
          );
          denom.verificationStatus = DenominationVerificationStatus.VerifiedBad;
        } else {
          denom.verificationStatus =
            DenominationVerificationStatus.VerifiedGood;
        }
        updatedDenominations.push(denom);
      }
    }
    if (updatedDenominations.length > 0) {
      logger.trace("writing denomination batch to db");
      await ws.db
        .mktx((x) => [x.denominations])
        .runReadWrite(async (tx) => {
          for (let i = 0; i < updatedDenominations.length; i++) {
            const denom = updatedDenominations[i];
            await tx.denominations.put(denom);
          }
        });
      logger.trace("done with DB write");
    }
  }
}

/**
 * Update the information about a reserve that is stored in the wallet
 * by querying the reserve's exchange.
 *
 * If the reserve have funds that are not allocated in a withdrawal group yet
 * and are big enough to withdraw with available denominations,
 * create a new withdrawal group for the remaining amount.
 */
async function queryReserve(
  ws: InternalWalletState,
  withdrawalGroupId: string,
  cancellationToken: CancellationToken,
): Promise<{ ready: boolean }> {
  const transactionId = constructTransactionIdentifier({
    tag: TransactionType.Withdrawal,
    withdrawalGroupId,
  });
  const withdrawalGroup = await getWithdrawalGroupRecordTx(ws.db, {
    withdrawalGroupId,
  });
  checkDbInvariant(!!withdrawalGroup);
  if (withdrawalGroup.status !== WithdrawalGroupStatus.PendingQueryingStatus) {
    return { ready: true };
  }
  const reservePub = withdrawalGroup.reservePub;

  const reserveUrl = new URL(
    `reserves/${reservePub}`,
    withdrawalGroup.exchangeBaseUrl,
  );
  reserveUrl.searchParams.set("timeout_ms", "30000");

  logger.info(`querying reserve status via ${reserveUrl.href}`);

  const resp = await ws.http.fetch(reserveUrl.href, {
    timeout: getReserveRequestTimeout(withdrawalGroup),
    cancellationToken,
  });

  logger.info(`reserve status code: HTTP ${resp.status}`);

  const result = await readSuccessResponseJsonOrErrorCode(
    resp,
    codecForReserveStatus(),
  );

  if (result.isError) {
    logger.info(
      `got reserve status error, EC=${result.talerErrorResponse.code}`,
    );
    if (
      resp.status === 404 &&
      result.talerErrorResponse.code ===
        TalerErrorCode.EXCHANGE_RESERVES_STATUS_UNKNOWN
    ) {
      ws.notify({
        type: NotificationType.ReserveNotYetFound,
        reservePub,
      });
      return { ready: false };
    } else {
      throwUnexpectedRequestError(resp, result.talerErrorResponse);
    }
  }

  logger.trace(`got reserve status ${j2s(result.response)}`);

  const transitionResult = await ws.db
    .mktx((x) => [x.withdrawalGroups])
    .runReadWrite(async (tx) => {
      const wg = await tx.withdrawalGroups.get(withdrawalGroupId);
      if (!wg) {
        logger.warn(`withdrawal group ${withdrawalGroupId} not found`);
        return undefined;
      }
      const txStateOld = computeWithdrawalTransactionStatus(wg);
      wg.status = WithdrawalGroupStatus.PendingReady;
      const txStateNew = computeWithdrawalTransactionStatus(wg);
      wg.reserveBalanceAmount = Amounts.stringify(result.response.balance);
      await tx.withdrawalGroups.put(wg);
      return {
        oldTxState: txStateOld,
        newTxState: txStateNew,
      };
    });

  notifyTransition(ws, transactionId, transitionResult);

  // FIXME: This notification is deprecated with DD37
  ws.notify({
    type: NotificationType.WithdrawalGroupReserveReady,
    transactionId,
  });

  return { ready: true };
}

enum BankStatusResultCode {
  Done = "done",
  Waiting = "waiting",
  Aborted = "aborted",
}

/**
 * Withdrawal context that is kept in-memory.
 *
 * Used to store some cached info during a withdrawal operation.
 */
export interface WithdrawalGroupContext {
  numPlanchets: number;
  planchetsFinished: Set<string>;

  /**
   * Cached withdrawal group record from the database.
   */
  wgRecord: WithdrawalGroupRecord;
}

async function processWithdrawalGroupAbortingBank(
  ws: InternalWalletState,
  withdrawalGroup: WithdrawalGroupRecord,
): Promise<OperationAttemptResult> {
  const { withdrawalGroupId } = withdrawalGroup;
  const transactionId = constructTransactionIdentifier({
    tag: TransactionType.Withdrawal,
    withdrawalGroupId,
  });

  const wgInfo = withdrawalGroup.wgInfo;
  if (wgInfo.withdrawalType != WithdrawalRecordType.BankIntegrated) {
    throw Error("invalid state (aborting(bank) without bank info");
  }
  const abortUrl = getBankAbortUrl(wgInfo.bankInfo.talerWithdrawUri);
  logger.info(`aborting withdrawal at ${abortUrl}`);
  const abortResp = await ws.http.fetch(abortUrl, {
    method: "POST",
    body: {},
  });
  logger.info(`abort response status: ${abortResp.status}`);

  const transitionInfo = await ws.db
    .mktx((x) => [x.withdrawalGroups])
    .runReadWrite(async (tx) => {
      const wg = await tx.withdrawalGroups.get(withdrawalGroupId);
      if (!wg) {
        return undefined;
      }
      const txStatusOld = computeWithdrawalTransactionStatus(wg);
      wg.status = WithdrawalGroupStatus.AbortedBank;
      wg.timestampFinish = TalerPreciseTimestamp.now();
      const txStatusNew = computeWithdrawalTransactionStatus(wg);
      await tx.withdrawalGroups.put(wg);
      return {
        oldTxState: txStatusOld,
        newTxState: txStatusNew,
      };
    });
  notifyTransition(ws, transactionId, transitionInfo);
  return {
    type: OperationAttemptResultType.Finished,
    result: undefined,
  };
}

async function processWithdrawalGroupPendingReady(
  ws: InternalWalletState,
  withdrawalGroup: WithdrawalGroupRecord,
): Promise<OperationAttemptResult> {
  const { withdrawalGroupId } = withdrawalGroup;
  const transactionId = constructTransactionIdentifier({
    tag: TransactionType.Withdrawal,
    withdrawalGroupId,
  });

  await ws.exchangeOps.updateExchangeFromUrl(
    ws,
    withdrawalGroup.exchangeBaseUrl,
  );

  if (withdrawalGroup.denomsSel.selectedDenoms.length === 0) {
    logger.warn("Finishing empty withdrawal group (no denoms)");
    const transitionInfo = await ws.db
      .mktx((x) => [x.withdrawalGroups])
      .runReadWrite(async (tx) => {
        const wg = await tx.withdrawalGroups.get(withdrawalGroupId);
        if (!wg) {
          return undefined;
        }
        const txStatusOld = computeWithdrawalTransactionStatus(wg);
        wg.status = WithdrawalGroupStatus.Finished;
        wg.timestampFinish = TalerPreciseTimestamp.now();
        const txStatusNew = computeWithdrawalTransactionStatus(wg);
        await tx.withdrawalGroups.put(wg);
        return {
          oldTxState: txStatusOld,
          newTxState: txStatusNew,
        };
      });
    notifyTransition(ws, transactionId, transitionInfo);
    return {
      type: OperationAttemptResultType.Finished,
      result: undefined,
    };
  }

  const numTotalCoins = withdrawalGroup.denomsSel.selectedDenoms
    .map((x) => x.count)
    .reduce((a, b) => a + b);

  const wgContext: WithdrawalGroupContext = {
    numPlanchets: numTotalCoins,
    planchetsFinished: new Set<string>(),
    wgRecord: withdrawalGroup,
  };

  await ws.db
    .mktx((x) => [x.planchets])
    .runReadOnly(async (tx) => {
      const planchets = await tx.planchets.indexes.byGroup.getAll(
        withdrawalGroupId,
      );
      for (const p of planchets) {
        if (p.planchetStatus === PlanchetStatus.WithdrawalDone) {
          wgContext.planchetsFinished.add(p.coinPub);
        }
      }
    });

  // We sequentially generate planchets, so that
  // large withdrawal groups don't make the wallet unresponsive.
  for (let i = 0; i < numTotalCoins; i++) {
    await processPlanchetGenerate(ws, withdrawalGroup, i);
  }

  const maxBatchSize = 100;

  for (let i = 0; i < numTotalCoins; i += maxBatchSize) {
    const resp = await processPlanchetExchangeBatchRequest(ws, wgContext, {
      batchSize: maxBatchSize,
      coinStartIndex: i,
      useBatchRequest: ws.config.features.batchWithdrawal,
    });
    let work: Promise<void>[] = [];
    work = [];
    for (let j = 0; j < resp.coinIdxs.length; j++) {
      if (!resp.batchResp.ev_sigs[j]) {
        //response may not be available when there is kyc needed
        continue;
      }
      work.push(
        processPlanchetVerifyAndStoreCoin(
          ws,
          wgContext,
          resp.coinIdxs[j],
          resp.batchResp.ev_sigs[j],
        ),
      );
    }
    await Promise.all(work);
  }

  let numFinished = 0;
  let numKycRequired = 0;
  let finishedForFirstTime = false;
  const errorsPerCoin: Record<number, TalerErrorDetail> = {};

  const res = await ws.db
    .mktx((x) => [x.coins, x.withdrawalGroups, x.planchets])
    .runReadWrite(async (tx) => {
      const wg = await tx.withdrawalGroups.get(withdrawalGroupId);
      if (!wg) {
        return;
      }

      await tx.planchets.indexes.byGroup
        .iter(withdrawalGroupId)
        .forEach((x) => {
          if (x.planchetStatus === PlanchetStatus.WithdrawalDone) {
            numFinished++;
          }
          if (x.planchetStatus === PlanchetStatus.KycRequired) {
            numKycRequired++;
          }
          if (x.lastError) {
            errorsPerCoin[x.coinIdx] = x.lastError;
          }
        });
      const oldTxState = computeWithdrawalTransactionStatus(wg);
      logger.info(`now withdrawn ${numFinished} of ${numTotalCoins} coins`);
      if (wg.timestampFinish === undefined && numFinished === numTotalCoins) {
        finishedForFirstTime = true;
        wg.timestampFinish = TalerPreciseTimestamp.now();
        wg.status = WithdrawalGroupStatus.Finished;
      }

      const newTxState = computeWithdrawalTransactionStatus(wg);
      await tx.withdrawalGroups.put(wg);

      return {
        kycInfo: wg.kycPending,
        transitionInfo: {
          oldTxState,
          newTxState,
        },
      };
    });

  if (!res) {
    throw Error("withdrawal group does not exist anymore");
  }

  notifyTransition(ws, transactionId, res.transitionInfo);

  const { kycInfo } = res;

  if (numKycRequired > 0) {
    if (kycInfo) {
      const txId = constructTransactionIdentifier({
        tag: TransactionType.Withdrawal,
        withdrawalGroupId: withdrawalGroup.withdrawalGroupId,
      });
      await checkWithdrawalKycStatus(
        ws,
        withdrawalGroup.exchangeBaseUrl,
        txId,
        kycInfo,
        "individual",
      );
      return {
        type: OperationAttemptResultType.Pending,
        result: undefined,
      };
    } else {
      throw TalerError.fromDetail(
        TalerErrorCode.WALLET_WITHDRAWAL_KYC_REQUIRED,
        {
          //FIXME we can't rise KYC error here since we don't have the url
        } as any,
        `KYC check required for withdrawal (not yet implemented in wallet-core)`,
      );
    }
  }
  if (numFinished != numTotalCoins) {
    throw TalerError.fromDetail(
      TalerErrorCode.WALLET_WITHDRAWAL_GROUP_INCOMPLETE,
      {
        errorsPerCoin,
      },
      `withdrawal did not finish (${numFinished} / ${numTotalCoins} coins withdrawn)`,
    );
  }

  // FIXME: Deprecated with DD37
  if (finishedForFirstTime) {
    ws.notify({
      type: NotificationType.WithdrawGroupFinished,
      reservePub: withdrawalGroup.reservePub,
    });
  }

  return {
    type: OperationAttemptResultType.Finished,
    result: undefined,
  };
}

export async function processWithdrawalGroup(
  ws: InternalWalletState,
  withdrawalGroupId: string,
): Promise<OperationAttemptResult> {
  logger.trace("processing withdrawal group", withdrawalGroupId);
  const withdrawalGroup = await ws.db
    .mktx((x) => [x.withdrawalGroups])
    .runReadOnly(async (tx) => {
      return tx.withdrawalGroups.get(withdrawalGroupId);
    });

  if (!withdrawalGroup) {
    throw Error(`withdrawal group ${withdrawalGroupId} not found`);
  }

  const retryTag = TaskIdentifiers.forWithdrawal(withdrawalGroup);

  // We're already running!
  if (ws.activeLongpoll[retryTag]) {
    logger.info("withdrawal group already in long-polling, returning!");
    return {
      type: OperationAttemptResultType.Longpoll,
    };
  }

  switch (withdrawalGroup.status) {
    case WithdrawalGroupStatus.PendingRegisteringBank:
      await processReserveBankStatus(ws, withdrawalGroupId);
      // FIXME: This will get called by the main task loop, why call it here?!
      return await processWithdrawalGroup(ws, withdrawalGroupId);
    case WithdrawalGroupStatus.PendingQueryingStatus: {
      runLongpollAsync(ws, retryTag, (ct) => {
        return queryReserve(ws, withdrawalGroupId, ct);
      });
      logger.trace(
        "returning early from withdrawal for long-polling in background",
      );
      return {
        type: OperationAttemptResultType.Longpoll,
      };
    }
    case WithdrawalGroupStatus.PendingWaitConfirmBank: {
      const res = await processReserveBankStatus(ws, withdrawalGroupId);
      switch (res.status) {
        case BankStatusResultCode.Aborted:
        case BankStatusResultCode.Done:
          return {
            type: OperationAttemptResultType.Finished,
            result: undefined,
          };
        case BankStatusResultCode.Waiting: {
          return {
            type: OperationAttemptResultType.Pending,
            result: undefined,
          };
        }
      }
      break;
    }
    case WithdrawalGroupStatus.Finished:
    case WithdrawalGroupStatus.FailedBankAborted: {
      // FIXME
      return {
        type: OperationAttemptResultType.Pending,
        result: undefined,
      };
    }
    case WithdrawalGroupStatus.PendingReady:
      // Continue with the actual withdrawal!
      return await processWithdrawalGroupPendingReady(ws, withdrawalGroup);
    case WithdrawalGroupStatus.AbortingBank:
      return await processWithdrawalGroupAbortingBank(ws, withdrawalGroup);
    default:
      throw new InvariantViolatedError(
        `unknown withdrawal group status: ${withdrawalGroup.status}`,
      );
  }
}

export async function checkWithdrawalKycStatus(
  ws: InternalWalletState,
  exchangeUrl: string,
  txId: string,
  kycInfo: KycPendingInfo,
  userType: KycUserType,
): Promise<void> {
  const url = new URL(
    `kyc-check/${kycInfo.requirementRow}/${kycInfo.paytoHash}/${userType}`,
    exchangeUrl,
  );
  logger.info(`kyc url ${url.href}`);
  const kycStatusRes = await ws.http.fetch(url.href, {
    method: "GET",
  });
  if (
    kycStatusRes.status === HttpStatusCode.Ok ||
    //FIXME: NoContent is not expected https://docs.taler.net/core/api-exchange.html#post--purses-$PURSE_PUB-merge
    // remove after the exchange is fixed or clarified
    kycStatusRes.status === HttpStatusCode.NoContent
  ) {
    logger.warn("kyc requested, but already fulfilled");
    return;
  } else if (kycStatusRes.status === HttpStatusCode.Accepted) {
    const kycStatus = await kycStatusRes.json();
    logger.info(`kyc status: ${j2s(kycStatus)}`);
    ws.notify({
      type: NotificationType.KycRequested,
      kycUrl: kycStatus.kyc_url,
      transactionId: txId,
    });
    throw TalerError.fromDetail(
      TalerErrorCode.WALLET_WITHDRAWAL_KYC_REQUIRED, //FIXME: another error code or rename for merge
      {
        kycUrl: kycStatus.kyc_url,
      },
      `KYC check required for transfer`,
    );
  } else {
    throw Error(`unexpected response from kyc-check (${kycStatusRes.status})`);
  }
}

const AGE_MASK_GROUPS = "8:10:12:14:16:18"
  .split(":")
  .map((n) => parseInt(n, 10));

export async function getExchangeWithdrawalInfo(
  ws: InternalWalletState,
  exchangeBaseUrl: string,
  instructedAmount: AmountJson,
  ageRestricted: number | undefined,
): Promise<ExchangeWithdrawalDetails> {
  logger.trace("updating exchange");
  const { exchange, exchangeDetails } =
    await ws.exchangeOps.updateExchangeFromUrl(ws, exchangeBaseUrl);
  logger.trace("updating withdrawal denoms");
  await updateWithdrawalDenoms(ws, exchangeBaseUrl);
  logger.trace("getting candidate denoms");
  const denoms = await getCandidateWithdrawalDenoms(ws, exchangeBaseUrl);
  logger.trace("selecting withdrawal denoms");
  const selectedDenoms = selectWithdrawalDenominations(
    instructedAmount,
    denoms,
    ws.config.testing.denomselAllowLate,
  );

  logger.trace("selection done");

  if (selectedDenoms.selectedDenoms.length === 0) {
    throw Error(
      `unable to withdraw from ${exchangeBaseUrl}, can't select denominations for instructed amount (${Amounts.stringify(
        instructedAmount,
      )}`,
    );
  }

  const exchangeWireAccounts: string[] = [];
  for (const account of exchangeDetails.wireInfo.accounts) {
    exchangeWireAccounts.push(account.payto_uri);
  }

  const { isTrusted, isAudited } = await ws.exchangeOps.getExchangeTrust(
    ws,
    exchange,
  );

  let hasDenomWithAgeRestriction = false;

  logger.trace("computing earliest deposit expiration");

  let earliestDepositExpiration: TalerProtocolTimestamp | undefined;
  for (let i = 0; i < selectedDenoms.selectedDenoms.length; i++) {
    const ds = selectedDenoms.selectedDenoms[i];
    // FIXME: Do in one transaction!
    const denom = await ws.db
      .mktx((x) => [x.denominations])
      .runReadOnly(async (tx) => {
        return ws.getDenomInfo(ws, tx, exchangeBaseUrl, ds.denomPubHash);
      });
    checkDbInvariant(!!denom);
    hasDenomWithAgeRestriction =
      hasDenomWithAgeRestriction || denom.denomPub.age_mask > 0;
    const expireDeposit = denom.stampExpireDeposit;
    if (!earliestDepositExpiration) {
      earliestDepositExpiration = expireDeposit;
      continue;
    }
    if (
      AbsoluteTime.cmp(
        AbsoluteTime.fromProtocolTimestamp(expireDeposit),
        AbsoluteTime.fromProtocolTimestamp(earliestDepositExpiration),
      ) < 0
    ) {
      earliestDepositExpiration = expireDeposit;
    }
  }

  checkLogicInvariant(!!earliestDepositExpiration);

  const possibleDenoms = await ws.db
    .mktx((x) => [x.denominations])
    .runReadOnly(async (tx) => {
      const ds = await tx.denominations.indexes.byExchangeBaseUrl.getAll(
        exchangeBaseUrl,
      );
      return ds.filter((x) => x.isOffered);
    });

  let versionMatch;
  if (exchangeDetails.protocolVersionRange) {
    versionMatch = LibtoolVersion.compare(
      WALLET_EXCHANGE_PROTOCOL_VERSION,
      exchangeDetails.protocolVersionRange,
    );

    if (
      versionMatch &&
      !versionMatch.compatible &&
      versionMatch.currentCmp === -1
    ) {
      logger.warn(
        `wallet's support for exchange protocol version ${WALLET_EXCHANGE_PROTOCOL_VERSION} might be outdated ` +
          `(exchange has ${exchangeDetails.protocolVersionRange}), checking for updates`,
      );
    }
  }

  let tosAccepted = false;
  if (exchangeDetails.tosAccepted?.timestamp) {
    if (exchangeDetails.tosAccepted.etag === exchangeDetails.tosCurrentEtag) {
      tosAccepted = true;
    }
  }

  const paytoUris = exchangeDetails.wireInfo.accounts.map((x) => x.payto_uri);
  if (!paytoUris) {
    throw Error("exchange is in invalid state");
  }

  const ret: ExchangeWithdrawalDetails = {
    earliestDepositExpiration,
    exchangePaytoUris: paytoUris,
    exchangeWireAccounts,
    exchangeVersion: exchangeDetails.protocolVersionRange || "unknown",
    isAudited,
    isTrusted,
    numOfferedDenoms: possibleDenoms.length,
    selectedDenoms,
    // FIXME: delete this field / replace by something we can display to the user
    trustedAuditorPubs: [],
    versionMatch,
    walletVersion: WALLET_EXCHANGE_PROTOCOL_VERSION,
    termsOfServiceAccepted: tosAccepted,
    withdrawalAmountEffective: Amounts.stringify(selectedDenoms.totalCoinValue),
    withdrawalAmountRaw: Amounts.stringify(instructedAmount),
    // TODO: remove hardcoding, this should be calculated from the denominations info
    // force enabled for testing
    ageRestrictionOptions: hasDenomWithAgeRestriction
      ? AGE_MASK_GROUPS
      : undefined,
  };
  return ret;
}

export interface GetWithdrawalDetailsForUriOpts {
  restrictAge?: number;
}

/**
 * Get more information about a taler://withdraw URI.
 *
 * As side effects, the bank (via the bank integration API) is queried
 * and the exchange suggested by the bank is permanently added
 * to the wallet's list of known exchanges.
 */
export async function getWithdrawalDetailsForUri(
  ws: InternalWalletState,
  talerWithdrawUri: string,
  opts: GetWithdrawalDetailsForUriOpts = {},
): Promise<WithdrawUriInfoResponse> {
  logger.trace(`getting withdrawal details for URI ${talerWithdrawUri}`);
  const info = await getBankWithdrawalInfo(ws.http, talerWithdrawUri);
  logger.trace(`got bank info`);
  if (info.suggestedExchange) {
    // FIXME: right now the exchange gets permanently added,
    // we might want to only temporarily add it.
    try {
      await ws.exchangeOps.updateExchangeFromUrl(ws, info.suggestedExchange);
    } catch (e) {
      // We still continued if it failed, as other exchanges might be available.
      // We don't want to fail if the bank-suggested exchange is broken/offline.
      logger.trace(
        `querying bank-suggested exchange (${info.suggestedExchange}) failed`,
      );
    }
  }

  // Extract information about possible exchanges for the withdrawal
  // operation from the database.

  const exchanges: ExchangeListItem[] = [];

  await ws.db
    .mktx((x) => [
      x.exchanges,
      x.exchangeDetails,
      x.exchangeTos,
      x.denominations,
      x.operationRetries,
    ])
    .runReadOnly(async (tx) => {
      const exchangeRecords = await tx.exchanges.iter().toArray();
      for (const r of exchangeRecords) {
        const exchangeDetails = await ws.exchangeOps.getExchangeDetails(
          tx,
          r.baseUrl,
        );
        const denominations = await tx.denominations.indexes.byExchangeBaseUrl
          .iter(r.baseUrl)
          .toArray();
        const retryRecord = await tx.operationRetries.get(
          TaskIdentifiers.forExchangeUpdate(r),
        );
        if (exchangeDetails && denominations) {
          exchanges.push(
            makeExchangeListItem(r, exchangeDetails, retryRecord?.lastError),
          );
        }
      }
    });

  return {
    amount: Amounts.stringify(info.amount),
    defaultExchangeBaseUrl: info.suggestedExchange,
    possibleExchanges: exchanges,
  };
}

export async function getFundingPaytoUrisTx(
  ws: InternalWalletState,
  withdrawalGroupId: string,
): Promise<string[]> {
  return await ws.db
    .mktx((x) => [x.exchanges, x.exchangeDetails, x.withdrawalGroups])
    .runReadWrite((tx) => getFundingPaytoUris(tx, withdrawalGroupId));
}

export function augmentPaytoUrisForWithdrawal(
  plainPaytoUris: string[],
  reservePub: string,
  instructedAmount: AmountLike,
): string[] {
  return plainPaytoUris.map((x) =>
    addPaytoQueryParams(x, {
      amount: Amounts.stringify(instructedAmount),
      message: `Taler Withdrawal ${reservePub}`,
    }),
  );
}

/**
 * Get payto URIs that can be used to fund a withdrawal operation.
 */
export async function getFundingPaytoUris(
  tx: GetReadOnlyAccess<{
    withdrawalGroups: typeof WalletStoresV1.withdrawalGroups;
    exchanges: typeof WalletStoresV1.exchanges;
    exchangeDetails: typeof WalletStoresV1.exchangeDetails;
  }>,
  withdrawalGroupId: string,
): Promise<string[]> {
  const withdrawalGroup = await tx.withdrawalGroups.get(withdrawalGroupId);
  checkDbInvariant(!!withdrawalGroup);
  const exchangeDetails = await getExchangeDetails(
    tx,
    withdrawalGroup.exchangeBaseUrl,
  );
  if (!exchangeDetails) {
    logger.error(`exchange ${withdrawalGroup.exchangeBaseUrl} not found`);
    return [];
  }
  const plainPaytoUris =
    exchangeDetails.wireInfo?.accounts.map((x) => x.payto_uri) ?? [];
  if (!plainPaytoUris) {
    logger.error(
      `exchange ${withdrawalGroup.exchangeBaseUrl} has no wire info`,
    );
    return [];
  }
  return augmentPaytoUrisForWithdrawal(
    plainPaytoUris,
    withdrawalGroup.reservePub,
    withdrawalGroup.instructedAmount,
  );
}

async function getWithdrawalGroupRecordTx(
  db: DbAccess<typeof WalletStoresV1>,
  req: {
    withdrawalGroupId: string;
  },
): Promise<WithdrawalGroupRecord | undefined> {
  return await db
    .mktx((x) => [x.withdrawalGroups])
    .runReadOnly(async (tx) => {
      return tx.withdrawalGroups.get(req.withdrawalGroupId);
    });
}

export function getReserveRequestTimeout(r: WithdrawalGroupRecord): Duration {
  return { d_ms: 60000 };
}

export function getBankStatusUrl(talerWithdrawUri: string): string {
  const uriResult = parseWithdrawUri(talerWithdrawUri);
  if (!uriResult) {
    throw Error(`can't parse withdrawal URL ${talerWithdrawUri}`);
  }
  const url = new URL(
    `withdrawal-operation/${uriResult.withdrawalOperationId}`,
    uriResult.bankIntegrationApiBaseUrl,
  );
  return url.href;
}

export function getBankAbortUrl(talerWithdrawUri: string): string {
  const uriResult = parseWithdrawUri(talerWithdrawUri);
  if (!uriResult) {
    throw Error(`can't parse withdrawal URL ${talerWithdrawUri}`);
  }
  const url = new URL(
    `withdrawal-operation/${uriResult.withdrawalOperationId}/abort`,
    uriResult.bankIntegrationApiBaseUrl,
  );
  return url.href;
}

async function registerReserveWithBank(
  ws: InternalWalletState,
  withdrawalGroupId: string,
): Promise<void> {
  const withdrawalGroup = await ws.db
    .mktx((x) => [x.withdrawalGroups])
    .runReadOnly(async (tx) => {
      return await tx.withdrawalGroups.get(withdrawalGroupId);
    });
  const transactionId = constructTransactionIdentifier({
    tag: TransactionType.Withdrawal,
    withdrawalGroupId,
  });
  switch (withdrawalGroup?.status) {
    case WithdrawalGroupStatus.PendingWaitConfirmBank:
    case WithdrawalGroupStatus.PendingRegisteringBank:
      break;
    default:
      return;
  }
  if (
    withdrawalGroup.wgInfo.withdrawalType != WithdrawalRecordType.BankIntegrated
  ) {
    throw Error();
  }
  const bankInfo = withdrawalGroup.wgInfo.bankInfo;
  if (!bankInfo) {
    return;
  }
  const bankStatusUrl = getBankStatusUrl(bankInfo.talerWithdrawUri);
  const reqBody = {
    reserve_pub: withdrawalGroup.reservePub,
    selected_exchange: bankInfo.exchangePaytoUri,
  };
  logger.info(`registering reserve with bank: ${j2s(reqBody)}`);
  const httpResp = await ws.http.fetch(bankStatusUrl, {
    method: "POST",
    body: reqBody,
    timeout: getReserveRequestTimeout(withdrawalGroup),
  });
  await readSuccessResponseJsonOrThrow(
    httpResp,
    codecForBankWithdrawalOperationPostResponse(),
  );
  const transitionInfo = await ws.db
    .mktx((x) => [x.withdrawalGroups])
    .runReadWrite(async (tx) => {
      const r = await tx.withdrawalGroups.get(withdrawalGroupId);
      if (!r) {
        return undefined;
      }
      switch (r.status) {
        case WithdrawalGroupStatus.PendingRegisteringBank:
        case WithdrawalGroupStatus.PendingWaitConfirmBank:
          break;
        default:
          return;
      }
      if (r.wgInfo.withdrawalType !== WithdrawalRecordType.BankIntegrated) {
        throw Error("invariant failed");
      }
      r.wgInfo.bankInfo.timestampReserveInfoPosted =
        AbsoluteTime.toPreciseTimestamp(AbsoluteTime.now());
      const oldTxState = computeWithdrawalTransactionStatus(r);
      r.status = WithdrawalGroupStatus.PendingWaitConfirmBank;
      const newTxState = computeWithdrawalTransactionStatus(r);
      await tx.withdrawalGroups.put(r);
      return {
        oldTxState,
        newTxState,
      };
    });

  notifyTransition(ws, transactionId, transitionInfo);
  // FIXME: This notification is deprecated with DD37
  ws.notify({ type: NotificationType.ReserveRegisteredWithBank });
}

interface BankStatusResult {
  status: BankStatusResultCode;
}

async function processReserveBankStatus(
  ws: InternalWalletState,
  withdrawalGroupId: string,
): Promise<BankStatusResult> {
  const withdrawalGroup = await getWithdrawalGroupRecordTx(ws.db, {
    withdrawalGroupId,
  });
  const transactionId = constructTransactionIdentifier({
    tag: TransactionType.Withdrawal,
    withdrawalGroupId,
  });
  switch (withdrawalGroup?.status) {
    case WithdrawalGroupStatus.PendingWaitConfirmBank:
    case WithdrawalGroupStatus.PendingRegisteringBank:
      break;
    default:
      return {
        status: BankStatusResultCode.Done,
      };
  }

  if (
    withdrawalGroup.wgInfo.withdrawalType != WithdrawalRecordType.BankIntegrated
  ) {
    throw Error("wrong withdrawal record type");
  }
  const bankInfo = withdrawalGroup.wgInfo.bankInfo;
  if (!bankInfo) {
    return {
      status: BankStatusResultCode.Done,
    };
  }

  const bankStatusUrl = getBankStatusUrl(bankInfo.talerWithdrawUri);

  const statusResp = await ws.http.get(bankStatusUrl, {
    timeout: getReserveRequestTimeout(withdrawalGroup),
  });
  const status = await readSuccessResponseJsonOrThrow(
    statusResp,
    codecForWithdrawOperationStatusResponse(),
  );

  if (status.aborted) {
    logger.info("bank aborted the withdrawal");
    const transitionInfo = await ws.db
      .mktx((x) => [x.withdrawalGroups])
      .runReadWrite(async (tx) => {
        const r = await tx.withdrawalGroups.get(withdrawalGroupId);
        if (!r) {
          return;
        }
        switch (r.status) {
          case WithdrawalGroupStatus.PendingRegisteringBank:
          case WithdrawalGroupStatus.PendingWaitConfirmBank:
            break;
          default:
            return;
        }
        if (r.wgInfo.withdrawalType !== WithdrawalRecordType.BankIntegrated) {
          throw Error("invariant failed");
        }
        const now = AbsoluteTime.toPreciseTimestamp(AbsoluteTime.now());
        const oldTxState = computeWithdrawalTransactionStatus(r);
        r.wgInfo.bankInfo.timestampBankConfirmed = now;
        r.status = WithdrawalGroupStatus.FailedBankAborted;
        const newTxState = computeWithdrawalTransactionStatus(r);
        await tx.withdrawalGroups.put(r);
        return {
          oldTxState,
          newTxState,
        };
      });
    notifyTransition(ws, transactionId, transitionInfo);
    return {
      status: BankStatusResultCode.Aborted,
    };
  }

  // Bank still needs to know our reserve info
  if (!status.selection_done) {
    await registerReserveWithBank(ws, withdrawalGroupId);
    return await processReserveBankStatus(ws, withdrawalGroupId);
  }

  // FIXME: Why do we do this?!
  if (withdrawalGroup.status === WithdrawalGroupStatus.PendingRegisteringBank) {
    await registerReserveWithBank(ws, withdrawalGroupId);
    return await processReserveBankStatus(ws, withdrawalGroupId);
  }

  const transitionInfo = await ws.db
    .mktx((x) => [x.withdrawalGroups])
    .runReadWrite(async (tx) => {
      const r = await tx.withdrawalGroups.get(withdrawalGroupId);
      if (!r) {
        return undefined;
      }
      // Re-check reserve status within transaction
      switch (r.status) {
        case WithdrawalGroupStatus.PendingRegisteringBank:
        case WithdrawalGroupStatus.PendingWaitConfirmBank:
          break;
        default:
          return undefined;
      }
      if (r.wgInfo.withdrawalType !== WithdrawalRecordType.BankIntegrated) {
        throw Error("invariant failed");
      }
      const oldTxState = computeWithdrawalTransactionStatus(r);
      if (status.transfer_done) {
        logger.info("withdrawal: transfer confirmed by bank.");
        const now = AbsoluteTime.toPreciseTimestamp(AbsoluteTime.now());
        r.wgInfo.bankInfo.timestampBankConfirmed = now;
        r.status = WithdrawalGroupStatus.PendingQueryingStatus;
        // FIXME: Notification is deprecated with DD37.
        const transactionId = constructTransactionIdentifier({
          tag: TransactionType.Withdrawal,
          withdrawalGroupId: r.withdrawalGroupId,
        });
        ws.notify({
          type: NotificationType.WithdrawalGroupBankConfirmed,
          transactionId,
        });
      } else {
        logger.info("withdrawal: transfer not yet confirmed by bank");
        r.wgInfo.bankInfo.confirmUrl = status.confirm_transfer_url;
        r.senderWire = status.sender_wire;
      }
      const newTxState = computeWithdrawalTransactionStatus(r);
      await tx.withdrawalGroups.put(r);
      return {
        oldTxState,
        newTxState,
      };
    });

  notifyTransition(ws, transactionId, transitionInfo);

  if (status.transfer_done) {
    return {
      status: BankStatusResultCode.Done,
    };
  } else {
    return {
      status: BankStatusResultCode.Waiting,
    };
  }
}

/**
 * Create a withdrawal group.
 *
 * If a forcedWithdrawalGroupId is given and a
 * withdrawal group with this ID already exists,
 * the existing one is returned.  No conflict checking
 * of the other arguments is done in that case.
 */
export async function internalCreateWithdrawalGroup(
  ws: InternalWalletState,
  args: {
    reserveStatus: WithdrawalGroupStatus;
    amount: AmountJson;
    exchangeBaseUrl: string;
    forcedWithdrawalGroupId?: string;
    forcedDenomSel?: ForcedDenomSel;
    reserveKeyPair?: EddsaKeypair;
    restrictAge?: number;
    wgInfo: WgInfo;
  },
): Promise<WithdrawalGroupRecord> {
  const reserveKeyPair =
    args.reserveKeyPair ?? (await ws.cryptoApi.createEddsaKeypair({}));
  const now = AbsoluteTime.toPreciseTimestamp(AbsoluteTime.now());
  const secretSeed = encodeCrock(getRandomBytes(32));
  const canonExchange = canonicalizeBaseUrl(args.exchangeBaseUrl);
  const amount = args.amount;

  let withdrawalGroupId;

  if (args.forcedWithdrawalGroupId) {
    withdrawalGroupId = args.forcedWithdrawalGroupId;
    const wgId = withdrawalGroupId;
    const existingWg = await ws.db
      .mktx((x) => [x.withdrawalGroups])
      .runReadOnly(async (tx) => {
        return tx.withdrawalGroups.get(wgId);
      });
    if (existingWg) {
      return existingWg;
    }
  } else {
    withdrawalGroupId = encodeCrock(getRandomBytes(32));
  }

  const transactionId = constructTransactionIdentifier({
    tag: TransactionType.Withdrawal,
    withdrawalGroupId,
  });

  await updateWithdrawalDenoms(ws, canonExchange);
  const denoms = await getCandidateWithdrawalDenoms(ws, canonExchange);

  let initialDenomSel: DenomSelectionState;
  const denomSelUid = encodeCrock(getRandomBytes(16));
  if (args.forcedDenomSel) {
    logger.warn("using forced denom selection");
    initialDenomSel = selectForcedWithdrawalDenominations(
      amount,
      denoms,
      args.forcedDenomSel,
      ws.config.testing.denomselAllowLate,
    );
  } else {
    initialDenomSel = selectWithdrawalDenominations(
      amount,
      denoms,
      ws.config.testing.denomselAllowLate,
    );
  }

  const withdrawalGroup: WithdrawalGroupRecord = {
    denomSelUid,
    denomsSel: initialDenomSel,
    exchangeBaseUrl: canonExchange,
    instructedAmount: Amounts.stringify(amount),
    timestampStart: now,
    rawWithdrawalAmount: initialDenomSel.totalWithdrawCost,
    effectiveWithdrawalAmount: initialDenomSel.totalCoinValue,
    secretSeed,
    reservePriv: reserveKeyPair.priv,
    reservePub: reserveKeyPair.pub,
    status: args.reserveStatus,
    withdrawalGroupId,
    restrictAge: args.restrictAge,
    senderWire: undefined,
    timestampFinish: undefined,
    wgInfo: args.wgInfo,
  };

  const exchangeInfo = await updateExchangeFromUrl(ws, canonExchange);
  const exchangeDetails = exchangeInfo.exchangeDetails;
  if (!exchangeDetails) {
    logger.trace(exchangeDetails);
    throw Error("exchange not updated");
  }
  const { isAudited, isTrusted } = await getExchangeTrust(
    ws,
    exchangeInfo.exchange,
  );

  const transitionInfo = await ws.db
    .mktx((x) => [
      x.withdrawalGroups,
      x.reserves,
      x.exchanges,
      x.exchangeDetails,
      x.exchangeTrust,
    ])
    .runReadWrite(async (tx) => {
      await tx.withdrawalGroups.add(withdrawalGroup);
      await tx.reserves.put({
        reservePub: withdrawalGroup.reservePub,
        reservePriv: withdrawalGroup.reservePriv,
      });

      const exchange = await tx.exchanges.get(withdrawalGroup.exchangeBaseUrl);
      if (exchange) {
        exchange.lastWithdrawal = TalerPreciseTimestamp.now();
        await tx.exchanges.put(exchange);
      }

      if (!isAudited && !isTrusted) {
        await tx.exchangeTrust.put({
          currency: amount.currency,
          exchangeBaseUrl: canonExchange,
          exchangeMasterPub: exchangeDetails.masterPublicKey,
          uids: [encodeCrock(getRandomBytes(32))],
        });
      }

      const oldTxState = {
        major: TransactionMajorState.None,
      };
      const newTxState = computeWithdrawalTransactionStatus(withdrawalGroup);
      return {
        oldTxState,
        newTxState,
      };
    });

  notifyTransition(ws, transactionId, transitionInfo);

  return withdrawalGroup;
}

export async function acceptWithdrawalFromUri(
  ws: InternalWalletState,
  req: {
    talerWithdrawUri: string;
    selectedExchange: string;
    forcedDenomSel?: ForcedDenomSel;
    restrictAge?: number;
  },
): Promise<AcceptWithdrawalResponse> {
  const selectedExchange = canonicalizeBaseUrl(req.selectedExchange);
  logger.info(
    `accepting withdrawal via ${req.talerWithdrawUri}, canonicalized selected exchange ${selectedExchange}`,
  );
  const existingWithdrawalGroup = await ws.db
    .mktx((x) => [x.withdrawalGroups])
    .runReadOnly(async (tx) => {
      return await tx.withdrawalGroups.indexes.byTalerWithdrawUri.get(
        req.talerWithdrawUri,
      );
    });

  if (existingWithdrawalGroup) {
    let url: string | undefined;
    if (
      existingWithdrawalGroup.wgInfo.withdrawalType ===
      WithdrawalRecordType.BankIntegrated
    ) {
      url = existingWithdrawalGroup.wgInfo.bankInfo.confirmUrl;
    }
    return {
      reservePub: existingWithdrawalGroup.reservePub,
      confirmTransferUrl: url,
      transactionId: constructTransactionIdentifier({
        tag: TransactionType.Withdrawal,
        withdrawalGroupId: existingWithdrawalGroup.withdrawalGroupId,
      }),
    };
  }

  await updateExchangeFromUrl(ws, selectedExchange);
  const withdrawInfo = await getBankWithdrawalInfo(
    ws.http,
    req.talerWithdrawUri,
  );
  const exchangePaytoUri = await getExchangePaytoUri(
    ws,
    selectedExchange,
    withdrawInfo.wireTypes,
  );

  const withdrawalGroup = await internalCreateWithdrawalGroup(ws, {
    amount: withdrawInfo.amount,
    exchangeBaseUrl: req.selectedExchange,
    wgInfo: {
      withdrawalType: WithdrawalRecordType.BankIntegrated,
      bankInfo: {
        exchangePaytoUri,
        talerWithdrawUri: req.talerWithdrawUri,
        confirmUrl: withdrawInfo.confirmTransferUrl,
        timestampBankConfirmed: undefined,
        timestampReserveInfoPosted: undefined,
      },
    },
    restrictAge: req.restrictAge,
    forcedDenomSel: req.forcedDenomSel,
    reserveStatus: WithdrawalGroupStatus.PendingRegisteringBank,
  });

  const withdrawalGroupId = withdrawalGroup.withdrawalGroupId;
  const transactionId = constructTransactionIdentifier({
    tag: TransactionType.Withdrawal,
    withdrawalGroupId,
  });

  // We do this here, as the reserve should be registered before we return,
  // so that we can redirect the user to the bank's status page.
  await processReserveBankStatus(ws, withdrawalGroupId);
  const processedWithdrawalGroup = await getWithdrawalGroupRecordTx(ws.db, {
    withdrawalGroupId,
  });
  if (
    processedWithdrawalGroup?.status === WithdrawalGroupStatus.FailedBankAborted
  ) {
    throw TalerError.fromDetail(
      TalerErrorCode.WALLET_WITHDRAWAL_OPERATION_ABORTED_BY_BANK,
      {},
    );
  }

  ws.workAvailable.trigger();

  return {
    reservePub: withdrawalGroup.reservePub,
    confirmTransferUrl: withdrawInfo.confirmTransferUrl,
    transactionId,
  };
}

/**
 * Create a manual withdrawal operation.
 *
 * Adds the corresponding exchange as a trusted exchange if it is neither
 * audited nor trusted already.
 *
 * Asynchronously starts the withdrawal.
 */
export async function createManualWithdrawal(
  ws: InternalWalletState,
  req: {
    exchangeBaseUrl: string;
    amount: AmountLike;
    restrictAge?: number;
    forcedDenomSel?: ForcedDenomSel;
  },
): Promise<AcceptManualWithdrawalResult> {
  const withdrawalGroup = await internalCreateWithdrawalGroup(ws, {
    amount: Amounts.jsonifyAmount(req.amount),
    wgInfo: {
      withdrawalType: WithdrawalRecordType.BankManual,
    },
    exchangeBaseUrl: req.exchangeBaseUrl,
    forcedDenomSel: req.forcedDenomSel,
    restrictAge: req.restrictAge,
    reserveStatus: WithdrawalGroupStatus.PendingQueryingStatus,
  });

  const withdrawalGroupId = withdrawalGroup.withdrawalGroupId;
  const transactionId = constructTransactionIdentifier({
    tag: TransactionType.Withdrawal,
    withdrawalGroupId,
  });

  const exchangePaytoUris = await ws.db
    .mktx((x) => [
      x.withdrawalGroups,
      x.exchanges,
      x.exchangeDetails,
      x.exchangeTrust,
    ])
    .runReadOnly(async (tx) => {
      return await getFundingPaytoUris(tx, withdrawalGroup.withdrawalGroupId);
    });

  ws.workAvailable.trigger();

  return {
    reservePub: withdrawalGroup.reservePub,
    exchangePaytoUris: exchangePaytoUris,
    transactionId,
  };
}