/*
 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 
 */
/**
 * 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,
  TransactionState,
  TransactionMajorState,
  TransactionMinorState,
  TalerPreciseTimestamp,
  TransactionAction,
} from "@gnu-taler/taler-util";
import { EddsaKeypair } from "../crypto/cryptoImplementation.js";
import {
  CoinRecord,
  CoinSourceType,
  DenominationRecord,
  DenominationVerificationStatus,
  KycPendingInfo,
  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 {
  TaskRunResult,
  TaskRunResultType,
  TaskIdentifiers,
  constructTaskIdentifier,
  makeCoinAvailable,
  makeCoinsVisible,
  makeExchangeListItem,
  runLongpollAsync,
} from "../operations/common.js";
import {
  HttpRequestLibrary,
  HttpResponse,
  readSuccessResponseJsonOrErrorCode,
  readSuccessResponseJsonOrThrow,
  throwUnexpectedRequestError,
} from "@gnu-taler/taler-util/http";
import { checkDbInvariant, checkLogicInvariant } from "../util/invariants.js";
import {
  DbAccess,
  GetReadOnlyAccess,
  GetReadWriteAccess,
} from "../util/query.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 {
  ExchangeDetailsRecord,
  PendingTaskType,
  isWithdrawableDenom,
} from "../index.js";
import {
  TransitionInfo,
  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 {
  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 {
  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 {
  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;
}
enum AmlStatus {
  normal= 0, pending = 1, fronzen = 2,
}
async function handleKycRequired(
  ws: InternalWalletState,
  withdrawalGroup: WithdrawalGroupRecord,
  resp: HttpResponse,
  startIdx: number,
  requestCoinIdxs: number[],
): Promise {
  logger.info("withdrawal requires KYC");
  const respJson = await resp.json();
  const uuidResp = codecForWalletKycUuid().decode(respJson);
  const withdrawalGroupId = withdrawalGroup.withdrawalGroupId;
  const transactionId = constructTransactionIdentifier({
    tag: TransactionType.Withdrawal,
    withdrawalGroupId,
  });
  logger.info(`kyc uuid response: ${j2s(uuidResp)}`);
  const exchangeUrl = withdrawalGroup.exchangeBaseUrl;
  const userType = "individual";
  const kycInfo: KycPendingInfo = {
    paytoHash: uuidResp.h_payto,
    requirementRow: uuidResp.requirement_row,
  };
  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",
  });
  let kycUrl: string;
  let amlStatus: AmlStatus | undefined;
  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)}`);
    kycUrl = kycStatus.kyc_url;
  } else if (kycStatusRes.status === HttpStatusCode.UnavailableForLegalReasons) {
    const kycStatus = await kycStatusRes.json();
    logger.info(`aml status: ${j2s(kycStatus)}`);
    amlStatus = kycStatus.aml_status;
  } else {
    throw Error(`unexpected response from kyc-check (${kycStatusRes.status})`);
  }
  const transitionInfo = 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;
      }
      const oldTxState = computeWithdrawalTransactionStatus(wg2);
      switch (wg2.status) {
        case WithdrawalGroupStatus.PendingReady: {
          wg2.kycPending = {
            paytoHash: uuidResp.h_payto,
            requirementRow: uuidResp.requirement_row,
          };
          wg2.kycUrl = kycUrl;
          wg2.status = amlStatus === AmlStatus.normal || amlStatus === undefined ? WithdrawalGroupStatus.PendingKyc :
            amlStatus === AmlStatus.pending ? WithdrawalGroupStatus.PendingAml :
            amlStatus === AmlStatus.fronzen ? WithdrawalGroupStatus.SuspendedAml :
            assertUnreachable(amlStatus); 
          
          await tx.withdrawalGroups.put(wg2);
          const newTxState = computeWithdrawalTransactionStatus(wg2);
          return {
            oldTxState,
            newTxState,
          };
        }
        default:
          return undefined;
      }
    });
  notifyTransition(ws, transactionId, transitionInfo);
}
/**
 * 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 {
  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 storeCoinError(e: any, coinIdx: number): Promise {
    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(ws, withdrawalGroup, resp, 0, requestCoinIdxs);
      }
      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.fetch(reqUrl, { method: "POST", body: p });
        if (resp.status === HttpStatusCode.UnavailableForLegalReasons) {
          await handleKycRequired(
            ws,
            withdrawalGroup,
            resp,
            i,
            requestCoinIdxs,
          );
          // 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) {
        if (e instanceof TalerError) {
          logger.warn(`withdrawing planchet failed: ${j2s(e.errorDetail)}`);
          logger.warn(`planchet denom pub hash: ${batchReq.planchets[i].denom_pub_hash}`);
        }
        await storeCoinError(e, requestCoinIdxs[i]);
      }
    }
    return {
      coinIdxs: responseCoinIdxs,
      batchResp: responses,
    };
  }
}
async function processPlanchetVerifyAndStoreCoin(
  ws: InternalWalletState,
  wgContext: WithdrawalGroupContext,
  coinIdx: number,
  resp: ExchangeWithdrawResponse,
): Promise {
  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 transactionId = constructTransactionIdentifier({
    tag: TransactionType.Withdrawal,
    withdrawalGroupId: wgContext.wgRecord.withdrawalGroupId,
  });
  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,
    },
    sourceTransactionId: transactionId,
    maxAge: withdrawalGroup.restrictAge ?? AgeRestriction.AGE_UNRESTRICTED,
    ageCommitmentProof: planchet.ageCommitmentProof,
    spendAllocation: undefined,
  };
  const planchetCoinPub = planchet.coinPub;
  wgContext.planchetsFinished.add(planchet.coinPub);
  // Check if this is the first time that the whole
  // withdrawal succeeded.  If so, mark the withdrawal
  // group as finished.
  const success = 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;
    });
}
/**
 * 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 {
  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.trace(`querying reserve status via ${reserveUrl.href}`);
  const resp = await ws.http.fetch(reserveUrl.href, {
    timeout: getReserveRequestTimeout(withdrawalGroup),
    cancellationToken,
  });
  logger.trace(`reserve status code: HTTP ${resp.status}`);
  const result = await readSuccessResponseJsonOrErrorCode(
    resp,
    codecForReserveStatus(),
  );
  if (result.isError) {
    logger.trace(
      `got reserve status error, EC=${result.talerErrorResponse.code}`,
    );
    if (
      resp.status === 404 &&
      result.talerErrorResponse.code ===
        TalerErrorCode.EXCHANGE_RESERVES_STATUS_UNKNOWN
    ) {
      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);
  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;
  /**
   * Cached withdrawal group record from the database.
   */
  wgRecord: WithdrawalGroupRecord;
}
async function processWithdrawalGroupAbortingBank(
  ws: InternalWalletState,
  withdrawalGroup: WithdrawalGroupRecord,
): Promise {
  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 TaskRunResult.finished();
}
/**
 * Store in the database that the KYC for a withdrawal is now
 * satisfied.
 */
async function transitionKycSatisfied(
  ws: InternalWalletState,
  withdrawalGroup: WithdrawalGroupRecord,
): Promise {
  const transactionId = constructTransactionIdentifier({
    tag: TransactionType.Withdrawal,
    withdrawalGroupId: withdrawalGroup.withdrawalGroupId,
  });
  const transitionInfo = await ws.db
    .mktx((x) => [x.withdrawalGroups])
    .runReadWrite(async (tx) => {
      const wg2 = await tx.withdrawalGroups.get(
        withdrawalGroup.withdrawalGroupId,
      );
      if (!wg2) {
        return;
      }
      const oldTxState = computeWithdrawalTransactionStatus(wg2);
      switch (wg2.status) {
        case WithdrawalGroupStatus.PendingKyc: {
          delete wg2.kycPending;
          delete wg2.kycUrl;
          wg2.status = WithdrawalGroupStatus.PendingReady;
          await tx.withdrawalGroups.put(wg2);
          const newTxState = computeWithdrawalTransactionStatus(wg2);
          return {
            oldTxState,
            newTxState,
          };
        }
        default:
          return undefined;
      }
    });
  notifyTransition(ws, transactionId, transitionInfo);
}
async function processWithdrawalGroupPendingKyc(
  ws: InternalWalletState,
  withdrawalGroup: WithdrawalGroupRecord,
): Promise {
  const userType = "individual";
  const kycInfo = withdrawalGroup.kycPending;
  if (!kycInfo) {
    throw Error("no kyc info available in pending(kyc)");
  }
  const exchangeUrl = withdrawalGroup.exchangeBaseUrl;
  const url = new URL(
    `kyc-check/${kycInfo.requirementRow}/${kycInfo.paytoHash}/${userType}`,
    exchangeUrl,
  );
  url.searchParams.set("timeout_ms", "30000");
  const retryTag = TaskIdentifiers.forWithdrawal(withdrawalGroup);
  runLongpollAsync(ws, retryTag, async (cancellationToken) => {
    logger.info(`long-polling for withdrawal KYC status via ${url.href}`);
    const kycStatusRes = await ws.http.fetch(url.href, {
      method: "GET",
      cancellationToken,
    });
    logger.info(
      `kyc long-polling response status: HTTP ${kycStatusRes.status}`,
    );
    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
    ) {
      await transitionKycSatisfied(ws, withdrawalGroup);
      return { ready: true };
    } else if (kycStatusRes.status === HttpStatusCode.Accepted) {
      const kycStatus = await kycStatusRes.json();
      logger.info(`kyc status: ${j2s(kycStatus)}`);
      // FIXME: do we need to update the KYC url, or does it always stay constant?
      return { ready: false };
    } else if (kycStatusRes.status === HttpStatusCode.UnavailableForLegalReasons) {
      const kycStatus = await kycStatusRes.json();
      logger.info(`aml status: ${j2s(kycStatus)}`);
      return {ready : false}
    } else {
      throw Error(
        `unexpected response from kyc-check (${kycStatusRes.status})`,
      );
    }
  });
  return TaskRunResult.longpoll();
}
async function processWithdrawalGroupPendingReady(
  ws: InternalWalletState,
  withdrawalGroup: WithdrawalGroupRecord,
): Promise {
  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 TaskRunResult.finished();
  }
  const numTotalCoins = withdrawalGroup.denomsSel.selectedDenoms
    .map((x) => x.count)
    .reduce((a, b) => a + b);
  const wgContext: WithdrawalGroupContext = {
    numPlanchets: numTotalCoins,
    planchetsFinished: new Set(),
    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[] = [];
    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;
  const errorsPerCoin: Record = {};
  let numPlanchetErrors = 0;
  const maxReportedErrors = 5;
  const res = await ws.db
    .mktx((x) => [x.coins, x.coinAvailability, 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.lastError) {
            numPlanchetErrors++;
            if (numPlanchetErrors < maxReportedErrors) {
              errorsPerCoin[x.coinIdx] = x.lastError;
            }
          }
        });
      const oldTxState = computeWithdrawalTransactionStatus(wg);
      logger.info(`now withdrawn ${numFinished} of ${numTotalCoins} coins`);
      if (wg.timestampFinish === undefined && numFinished === numTotalCoins) {
        wg.timestampFinish = TalerPreciseTimestamp.now();
        wg.status = WithdrawalGroupStatus.Finished;
        await makeCoinsVisible(ws, tx, transactionId);
      }
      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);
  ws.notify({ type: NotificationType.BalanceChange });
  if (numPlanchetErrors > 0) {
    return {
      type: TaskRunResultType.Error,
      errorDetail: makeErrorDetail(
        TalerErrorCode.WALLET_WITHDRAWAL_GROUP_INCOMPLETE,
        {
          errorsPerCoin,
          numErrors: numPlanchetErrors,
        },
      ),
    };
  }
  return TaskRunResult.finished();
}
export async function processWithdrawalGroup(
  ws: InternalWalletState,
  withdrawalGroupId: string,
): Promise {
  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: TaskRunResultType.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: TaskRunResultType.Longpoll,
      };
    }
    case WithdrawalGroupStatus.PendingWaitConfirmBank: {
      const res = await processReserveBankStatus(ws, withdrawalGroupId);
      switch (res.status) {
        case BankStatusResultCode.Aborted:
        case BankStatusResultCode.Done:
          return TaskRunResult.finished();
        case BankStatusResultCode.Waiting: {
          return TaskRunResult.pending();
        }
      }
      break;
    }
    case WithdrawalGroupStatus.Finished:
    case WithdrawalGroupStatus.FailedBankAborted: {
      // FIXME
      return TaskRunResult.pending();
    }
    case WithdrawalGroupStatus.PendingAml:
      // FIXME: Handle this case, withdrawal doesn't support AML yet.
      return TaskRunResult.pending();
    case WithdrawalGroupStatus.PendingKyc:
      return processWithdrawalGroupPendingKyc(ws, withdrawalGroup);
    case WithdrawalGroupStatus.PendingReady:
      // Continue with the actual withdrawal!
      return await processWithdrawalGroupPendingReady(ws, withdrawalGroup);
    case WithdrawalGroupStatus.AbortingBank:
      return await processWithdrawalGroupAbortingBank(ws, withdrawalGroup);
    case WithdrawalGroupStatus.AbortedBank:
    case WithdrawalGroupStatus.AbortedExchange:
    case WithdrawalGroupStatus.FailedAbortingBank:
    case WithdrawalGroupStatus.SuspendedAbortingBank:
    case WithdrawalGroupStatus.SuspendedAml:
    case WithdrawalGroupStatus.SuspendedKyc:
    case WithdrawalGroupStatus.SuspendedQueryingStatus:
    case WithdrawalGroupStatus.SuspendedReady:
    case WithdrawalGroupStatus.SuspendedRegisteringBank:
    case WithdrawalGroupStatus.SuspendedWaitConfirmBank:
      // Nothing to do.
      return TaskRunResult.finished();
    default:
      assertUnreachable(withdrawalGroup.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 {
  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 {
  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 {
  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 {
  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,
  req: {
    withdrawalGroupId: string;
  },
): Promise {
  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 {
  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);
}
interface BankStatusResult {
  status: BankStatusResultCode;
}
async function processReserveBankStatus(
  ws: InternalWalletState,
  withdrawalGroupId: string,
): Promise {
  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;
      } 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,
    };
  }
}
export interface PrepareCreateWithdrawalGroupResult {
  withdrawalGroup: WithdrawalGroupRecord;
  transactionId: string;
  creationInfo?: {
    isTrusted: boolean;
    isAudited: boolean;
    amount: AmountJson;
    canonExchange: string;
    exchangeDetails: ExchangeDetailsRecord;
  };
}
export async function internalPrepareCreateWithdrawalGroup(
  ws: InternalWalletState,
  args: {
    reserveStatus: WithdrawalGroupStatus;
    amount: AmountJson;
    exchangeBaseUrl: string;
    forcedWithdrawalGroupId?: string;
    forcedDenomSel?: ForcedDenomSel;
    reserveKeyPair?: EddsaKeypair;
    restrictAge?: number;
    wgInfo: WgInfo;
  },
): Promise {
  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) {
      const transactionId = constructTransactionIdentifier({
        tag: TransactionType.Withdrawal,
        withdrawalGroupId: existingWg.withdrawalGroupId,
      });
      return { withdrawalGroup: existingWg, transactionId };
    }
  } else {
    withdrawalGroupId = encodeCrock(getRandomBytes(32));
  }
  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 transactionId = constructTransactionIdentifier({
    tag: TransactionType.Withdrawal,
    withdrawalGroupId: withdrawalGroup.withdrawalGroupId,
  });
  return {
    withdrawalGroup,
    transactionId,
    creationInfo: {
      isAudited,
      isTrusted,
      canonExchange,
      amount,
      exchangeDetails,
    },
  };
}
export interface PerformCreateWithdrawalGroupResult {
  withdrawalGroup: WithdrawalGroupRecord;
  transitionInfo: TransitionInfo | undefined;
}
export async function internalPerformCreateWithdrawalGroup(
  ws: InternalWalletState,
  tx: GetReadWriteAccess<{
    withdrawalGroups: typeof WalletStoresV1.withdrawalGroups;
    reserves: typeof WalletStoresV1.reserves;
    exchanges: typeof WalletStoresV1.exchanges;
    exchangeTrust: typeof WalletStoresV1.exchangeTrust;
  }>,
  prep: PrepareCreateWithdrawalGroupResult,
): Promise {
  const transactionId = constructTransactionIdentifier({
    tag: TransactionType.Withdrawal,
    withdrawalGroupId: prep.withdrawalGroup.withdrawalGroupId,
  });
  const { withdrawalGroup } = prep;
  if (!prep.creationInfo) {
    return { withdrawalGroup, transitionInfo: undefined };
  }
  const { isAudited, isTrusted, amount, canonExchange, exchangeDetails } =
    prep.creationInfo;
  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,
    minor: undefined,
  };
  const newTxState = computeWithdrawalTransactionStatus(withdrawalGroup);
  const transitionInfo = {
    oldTxState,
    newTxState,
  };
  return { withdrawalGroup, transitionInfo };
}
/**
 * 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 {
  const prep = await internalPrepareCreateWithdrawalGroup(ws, args);
  const transactionId = constructTransactionIdentifier({
    tag: TransactionType.Withdrawal,
    withdrawalGroupId: prep.withdrawalGroup.withdrawalGroupId,
  });
  const res = await ws.db
    .mktx((x) => [
      x.withdrawalGroups,
      x.reserves,
      x.exchanges,
      x.exchangeDetails,
      x.exchangeTrust,
    ])
    .runReadWrite(async (tx) => {
      return await internalPerformCreateWithdrawalGroup(ws, tx, prep);
    });
  notifyTransition(ws, transactionId, res.transitionInfo);
  return res.withdrawalGroup;
}
export async function acceptWithdrawalFromUri(
  ws: InternalWalletState,
  req: {
    talerWithdrawUri: string;
    selectedExchange: string;
    forcedDenomSel?: ForcedDenomSel;
    restrictAge?: number;
  },
): Promise {
  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 {
  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,
  };
}