aboutsummaryrefslogtreecommitdiff
path: root/packages/taler-wallet-core/src/withdraw.ts
diff options
context:
space:
mode:
Diffstat (limited to 'packages/taler-wallet-core/src/withdraw.ts')
-rw-r--r--packages/taler-wallet-core/src/withdraw.ts2754
1 files changed, 2754 insertions, 0 deletions
diff --git a/packages/taler-wallet-core/src/withdraw.ts b/packages/taler-wallet-core/src/withdraw.ts
new file mode 100644
index 000000000..a986d00a9
--- /dev/null
+++ b/packages/taler-wallet-core/src/withdraw.ts
@@ -0,0 +1,2754 @@
+/*
+ This file is part of GNU Taler
+ (C) 2019-2024 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,
+ AgeRestriction,
+ AmountJson,
+ AmountLike,
+ AmountString,
+ Amounts,
+ BankWithdrawDetails,
+ CancellationToken,
+ CoinStatus,
+ CurrencySpecification,
+ DenomKeyType,
+ DenomSelectionState,
+ Duration,
+ ExchangeBatchWithdrawRequest,
+ ExchangeUpdateStatus,
+ ExchangeWireAccount,
+ ExchangeWithdrawBatchResponse,
+ ExchangeWithdrawRequest,
+ ExchangeWithdrawResponse,
+ ExchangeWithdrawalDetails,
+ ForcedDenomSel,
+ HttpStatusCode,
+ LibtoolVersion,
+ Logger,
+ NotificationType,
+ TalerBankIntegrationHttpClient,
+ TalerError,
+ TalerErrorCode,
+ TalerErrorDetail,
+ TalerPreciseTimestamp,
+ TalerProtocolTimestamp,
+ TransactionAction,
+ TransactionIdStr,
+ TransactionMajorState,
+ TransactionMinorState,
+ TransactionState,
+ TransactionType,
+ URL,
+ UnblindedSignature,
+ WalletNotification,
+ WithdrawUriInfoResponse,
+ WithdrawalExchangeAccountDetails,
+ addPaytoQueryParams,
+ canonicalizeBaseUrl,
+ codecForAny,
+ codecForCashinConversionResponse,
+ codecForConversionBankConfig,
+ codecForExchangeWithdrawBatchResponse,
+ codecForReserveStatus,
+ codecForWalletKycUuid,
+ codecForWithdrawOperationStatusResponse,
+ encodeCrock,
+ getErrorDetailFromException,
+ getRandomBytes,
+ j2s,
+ makeErrorDetail,
+ parseWithdrawUri,
+} from "@gnu-taler/taler-util";
+import {
+ HttpRequestLibrary,
+ HttpResponse,
+ readSuccessResponseJsonOrErrorCode,
+ readSuccessResponseJsonOrThrow,
+ throwUnexpectedRequestError,
+} from "@gnu-taler/taler-util/http";
+import { EddsaKeypair } from "./crypto/cryptoImplementation.js";
+import {
+ CoinRecord,
+ CoinSourceType,
+ DenominationRecord,
+ DenominationVerificationStatus,
+ KycPendingInfo,
+ PlanchetRecord,
+ PlanchetStatus,
+ WalletStoresV1,
+ WgInfo,
+ WithdrawalGroupRecord,
+ WithdrawalGroupStatus,
+ WithdrawalRecordType,
+} from "./db.js";
+import {
+ WalletDbReadOnlyTransaction,
+ WalletDbReadWriteTransaction,
+ isWithdrawableDenom,
+ timestampPreciseToDb,
+} from "./index.js";
+import { InternalWalletState } from "./internal-wallet-state.js";
+import {
+ TaskRunResult,
+ TaskRunResultType,
+ TombstoneTag,
+ TransactionContext,
+ constructTaskIdentifier,
+ makeCoinAvailable,
+ makeCoinsVisible,
+} from "./common.js";
+import { PendingTaskType, TaskId } from "./pending-types.js";
+import { assertUnreachable } from "./util/assertUnreachable.js";
+import {
+ selectForcedWithdrawalDenominations,
+ selectWithdrawalDenominations,
+} from "./util/coinSelection.js";
+import { checkDbInvariant, checkLogicInvariant } from "./util/invariants.js";
+import { DbAccess } from "./query.js";
+import {
+ WALLET_BANK_INTEGRATION_PROTOCOL_VERSION,
+ WALLET_EXCHANGE_PROTOCOL_VERSION,
+} from "./versions.js";
+import {
+ ReadyExchangeSummary,
+ fetchFreshExchange,
+ getExchangePaytoUri,
+ getExchangeWireDetailsInTx,
+ listExchanges,
+ markExchangeUsed,
+} from "./exchanges.js";
+import {
+ TransitionInfo,
+ constructTransactionIdentifier,
+ notifyTransition,
+} from "./transactions.js";
+
+/**
+ * Logger for this file.
+ */
+const logger = new Logger("operations/withdraw.ts");
+
+export class WithdrawTransactionContext implements TransactionContext {
+ readonly transactionId: TransactionIdStr;
+ readonly taskId: TaskId;
+
+ constructor(
+ public ws: InternalWalletState,
+ public withdrawalGroupId: string,
+ ) {
+ this.transactionId = constructTransactionIdentifier({
+ tag: TransactionType.Withdrawal,
+ withdrawalGroupId,
+ });
+ this.taskId = constructTaskIdentifier({
+ tag: PendingTaskType.Withdraw,
+ withdrawalGroupId,
+ });
+ }
+
+ async deleteTransaction(): Promise<void> {
+ const { ws, withdrawalGroupId } = this;
+ await ws.db.runReadWriteTx(
+ ["withdrawalGroups", "tombstones"],
+ async (tx) => {
+ const withdrawalGroupRecord =
+ await tx.withdrawalGroups.get(withdrawalGroupId);
+ if (withdrawalGroupRecord) {
+ await tx.withdrawalGroups.delete(withdrawalGroupId);
+ await tx.tombstones.put({
+ id: TombstoneTag.DeleteWithdrawalGroup + ":" + withdrawalGroupId,
+ });
+ return;
+ }
+ },
+ );
+ }
+
+ async suspendTransaction(): Promise<void> {
+ const { ws, withdrawalGroupId, transactionId, taskId } = this;
+ const transitionInfo = await ws.db.runReadWriteTx(
+ ["withdrawalGroups"],
+ 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;
+ },
+ );
+ ws.taskScheduler.stopShepherdTask(taskId);
+ notifyTransition(ws, transactionId, transitionInfo);
+ }
+
+ async abortTransaction(): Promise<void> {
+ const { ws, withdrawalGroupId, transactionId, taskId } = this;
+ const transitionInfo = await ws.db.runReadWriteTx(
+ ["withdrawalGroups"],
+ 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.Done:
+ case WithdrawalGroupStatus.FailedBankAborted:
+ case WithdrawalGroupStatus.AbortedExchange:
+ case WithdrawalGroupStatus.AbortedBank:
+ case WithdrawalGroupStatus.FailedAbortingBank:
+ // Not allowed
+ throw Error("abort not allowed in current state");
+ 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.taskScheduler.stopShepherdTask(taskId);
+ notifyTransition(ws, transactionId, transitionInfo);
+ ws.taskScheduler.startShepherdTask(taskId);
+ }
+
+ async resumeTransaction(): Promise<void> {
+ const { ws, withdrawalGroupId, transactionId, taskId: retryTag } = this;
+ const transitionInfo = await ws.db.runReadWriteTx(
+ ["withdrawalGroups"],
+ 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;
+ },
+ );
+ notifyTransition(ws, transactionId, transitionInfo);
+ ws.taskScheduler.startShepherdTask(retryTag);
+ }
+
+ async failTransaction(): Promise<void> {
+ const { ws, withdrawalGroupId, transactionId, taskId: retryTag } = this;
+ const stateUpdate = await ws.db.runReadWriteTx(
+ ["withdrawalGroups"],
+ 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;
+ },
+ );
+ ws.taskScheduler.stopShepherdTask(retryTag);
+ notifyTransition(ws, transactionId, stateUpdate);
+ ws.taskScheduler.startShepherdTask(retryTag);
+ }
+}
+
+/**
+ * Compute the DD37 transaction state of a withdrawal transaction
+ * from the database's withdrawal group record.
+ */
+export function computeWithdrawalTransactionStatus(
+ wgRecord: WithdrawalGroupRecord,
+): TransactionState {
+ switch (wgRecord.status) {
+ case WithdrawalGroupStatus.FailedBankAborted:
+ return {
+ major: TransactionMajorState.Aborted,
+ };
+ case WithdrawalGroupStatus.Done:
+ 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,
+ };
+ }
+}
+
+/**
+ * Compute DD37 transaction actions for a withdrawal transaction
+ * based on the database's withdrawal group record.
+ */
+export function computeWithdrawalTransactionActions(
+ wgRecord: WithdrawalGroupRecord,
+): TransactionAction[] {
+ switch (wgRecord.status) {
+ case WithdrawalGroupStatus.FailedBankAborted:
+ return [TransactionAction.Delete];
+ case WithdrawalGroupStatus.Done:
+ 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 bankApi = new TalerBankIntegrationHttpClient(
+ uriResult.bankIntegrationApiBaseUrl,
+ http,
+ );
+
+ const { body: config } = await bankApi.getConfig();
+
+ if (!bankApi.isCompatible(config.version)) {
+ throw TalerError.fromDetail(
+ TalerErrorCode.WALLET_BANK_INTEGRATION_PROTOCOL_VERSION_INCOMPATIBLE,
+ {
+ bankProtocolVersion: config.version,
+ walletProtocolVersion: WALLET_BANK_INTEGRATION_PROTOCOL_VERSION,
+ },
+ "bank integration protocol version not compatible with wallet",
+ );
+ }
+
+ const resp = await bankApi.getWithdrawalOperationById(
+ uriResult.withdrawalOperationId,
+ );
+
+ if (resp.type === "fail") {
+ throw TalerError.fromUncheckedDetail(resp.detail);
+ }
+ const { body: status } = resp;
+
+ logger.info(`bank withdrawal operation status: ${j2s(status)}`);
+
+ return {
+ operationId: uriResult.withdrawalOperationId,
+ apiBaseUrl: uriResult.bankIntegrationApiBaseUrl,
+ amount: Amounts.parseOrThrow(status.amount),
+ confirmTransferUrl: status.confirm_transfer_url,
+ senderWire: status.sender_wire,
+ suggestedExchange: status.suggested_exchange,
+ wireTypes: status.wire_types,
+ status: status.status,
+ };
+}
+
+/**
+ * Return denominations that can potentially used for a withdrawal.
+ */
+async function getCandidateWithdrawalDenoms(
+ ws: InternalWalletState,
+ exchangeBaseUrl: string,
+ currency: string,
+): Promise<DenominationRecord[]> {
+ return await ws.db.runReadOnlyTx(["denominations"], async (tx) => {
+ return getCandidateWithdrawalDenomsTx(ws, tx, exchangeBaseUrl, currency);
+ });
+}
+
+export async function getCandidateWithdrawalDenomsTx(
+ ws: InternalWalletState,
+ tx: WalletDbReadOnlyTransaction<["denominations"]>,
+ exchangeBaseUrl: string,
+ currency: string,
+): Promise<DenominationRecord[]> {
+ // FIXME: Use denom groups instead of querying all denominations!
+ const allDenoms =
+ await tx.denominations.indexes.byExchangeBaseUrl.getAll(exchangeBaseUrl);
+ return allDenoms
+ .filter((d) => d.currency === currency)
+ .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.runReadOnlyTx(["planchets"], 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.runReadOnlyTx(["denominations"], 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.runReadWriteTx(["planchets"], 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 {
+ coinStartIndex: number;
+
+ batchSize: number;
+}
+
+interface WithdrawalBatchResult {
+ coinIdxs: number[];
+ batchResp: ExchangeWithdrawBatchResponse;
+}
+
+enum AmlStatus {
+ normal = 0,
+ pending = 1,
+ fronzen = 2,
+}
+
+/**
+ * Transition a withdrawal transaction with a (new) KYC URL.
+ *
+ * Emit a notification for the (self-)transition.
+ */
+async function transitionKycUrlUpdate(
+ ws: InternalWalletState,
+ withdrawalGroupId: string,
+ kycUrl: string,
+): Promise<void> {
+ let notificationKycUrl: string | undefined = undefined;
+ const ctx = new WithdrawTransactionContext(ws, withdrawalGroupId);
+ const transactionId = ctx.transactionId;
+
+ const transitionInfo = await ws.db.runReadWriteTx(
+ ["withdrawalGroups"],
+ async (tx) => {
+ const wg2 = await tx.withdrawalGroups.get(withdrawalGroupId);
+ if (!wg2) {
+ return;
+ }
+ const oldTxState = computeWithdrawalTransactionStatus(wg2);
+ switch (wg2.status) {
+ case WithdrawalGroupStatus.PendingReady: {
+ wg2.kycUrl = kycUrl;
+ notificationKycUrl = kycUrl;
+ await tx.withdrawalGroups.put(wg2);
+ const newTxState = computeWithdrawalTransactionStatus(wg2);
+ return {
+ oldTxState,
+ newTxState,
+ };
+ }
+ default:
+ return undefined;
+ }
+ },
+ );
+ if (transitionInfo) {
+ // Always notify, even on self-transition, as the KYC URL might have changed.
+ ws.notify({
+ type: NotificationType.TransactionStateTransition,
+ oldTxState: transitionInfo.oldTxState,
+ newTxState: transitionInfo.newTxState,
+ transactionId,
+ experimentalUserData: notificationKycUrl,
+ });
+ }
+ ws.taskScheduler.startShepherdTask(ctx.taskId);
+}
+
+async function handleKycRequired(
+ ws: InternalWalletState,
+ withdrawalGroup: WithdrawalGroupRecord,
+ resp: HttpResponse,
+ startIdx: number,
+ requestCoinIdxs: number[],
+): Promise<void> {
+ 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})`);
+ }
+
+ let notificationKycUrl: string | undefined = undefined;
+
+ const transitionInfo = await ws.db.runReadWriteTx(
+ ["planchets", "withdrawalGroups"],
+ 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);
+
+ notificationKycUrl = kycUrl;
+
+ await tx.withdrawalGroups.put(wg2);
+ const newTxState = computeWithdrawalTransactionStatus(wg2);
+ return {
+ oldTxState,
+ newTxState,
+ };
+ }
+ default:
+ return undefined;
+ }
+ },
+ );
+ notifyTransition(ws, transactionId, transitionInfo, notificationKycUrl);
+}
+
+/**
+ * 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.runReadOnlyTx(["planchets", "denominations"], 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<void> {
+ const errDetail = getErrorDetailFromException(e);
+ logger.trace("withdrawal request failed", e);
+ logger.trace(String(e));
+ await ws.db.runReadWriteTx(["planchets"], 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!
+
+ const reqUrl = new URL(
+ `reserves/${withdrawalGroup.reservePub}/batch-withdraw`,
+ withdrawalGroup.exchangeBaseUrl,
+ ).href;
+
+ try {
+ const resp = await ws.http.fetch(reqUrl, {
+ method: "POST",
+ body: batchReq,
+ });
+ if (resp.status === HttpStatusCode.UnavailableForLegalReasons) {
+ await handleKycRequired(ws, withdrawalGroup, resp, 0, requestCoinIdxs);
+ return {
+ batchResp: { ev_sigs: [] },
+ coinIdxs: [],
+ };
+ }
+ const r = await readSuccessResponseJsonOrThrow(
+ resp,
+ codecForExchangeWithdrawBatchResponse(),
+ );
+ return {
+ coinIdxs: requestCoinIdxs,
+ batchResp: r,
+ };
+ } catch (e) {
+ await storeCoinError(e, requestCoinIdxs[0]);
+ return {
+ batchResp: { ev_sigs: [] },
+ coinIdxs: [],
+ };
+ }
+}
+
+async function processPlanchetVerifyAndStoreCoin(
+ ws: InternalWalletState,
+ wgContext: WithdrawalGroupContext,
+ coinIdx: number,
+ resp: ExchangeWithdrawResponse,
+): Promise<void> {
+ const withdrawalGroup = wgContext.wgRecord;
+ logger.trace(`checking and storing planchet idx=${coinIdx}`);
+ const d = await ws.db.runReadOnlyTx(
+ ["planchets", "denominations"],
+ 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.runReadWriteTx(["planchets"], 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);
+
+ await ws.db.runReadWriteTx(
+ ["planchets", "coins", "coinAvailability", "denominations"],
+ async (tx) => {
+ const p = await tx.planchets.get(planchetCoinPub);
+ if (!p || p.planchetStatus === PlanchetStatus.WithdrawalDone) {
+ return;
+ }
+ p.planchetStatus = PlanchetStatus.WithdrawalDone;
+ p.lastError = undefined;
+ await tx.planchets.put(p);
+ await makeCoinAvailable(ws, tx, coin);
+ },
+ );
+}
+
+/**
+ * Make sure that denominations that currently can be used for withdrawal
+ * are validated, and the result of validation is stored in the database.
+ */
+async function updateWithdrawalDenoms(
+ ws: InternalWalletState,
+ exchangeBaseUrl: string,
+): Promise<void> {
+ logger.trace(
+ `updating denominations used for withdrawal for ${exchangeBaseUrl}`,
+ );
+ const exchangeDetails = await ws.db.runReadOnlyTx(
+ ["exchanges", "exchangeDetails"],
+ async (tx) => {
+ return getExchangeWireDetailsInTx(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,
+ exchangeDetails.currency,
+ );
+ 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.runReadWriteTx(["denominations"], 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<TaskRunResult> {
+ const transactionId = constructTransactionIdentifier({
+ tag: TransactionType.Withdrawal,
+ withdrawalGroupId,
+ });
+ const withdrawalGroup = await getWithdrawalGroupRecordTx(ws.db, {
+ withdrawalGroupId,
+ });
+ checkDbInvariant(!!withdrawalGroup);
+ if (withdrawalGroup.status !== WithdrawalGroupStatus.PendingQueryingStatus) {
+ return TaskRunResult.backoff();
+ }
+ 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 === HttpStatusCode.NotFound) {
+ return TaskRunResult.backoff();
+ } else {
+ throwUnexpectedRequestError(resp, result.talerErrorResponse);
+ }
+ }
+
+ logger.trace(`got reserve status ${j2s(result.response)}`);
+
+ const transitionResult = await ws.db.runReadWriteTx(
+ ["withdrawalGroups"],
+ 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 TaskRunResult.backoff();
+}
+
+/**
+ * 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<TaskRunResult> {
+ 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.runReadWriteTx(
+ ["withdrawalGroups"],
+ async (tx) => {
+ const wg = await tx.withdrawalGroups.get(withdrawalGroupId);
+ if (!wg) {
+ return undefined;
+ }
+ const txStatusOld = computeWithdrawalTransactionStatus(wg);
+ wg.status = WithdrawalGroupStatus.AbortedBank;
+ wg.timestampFinish = timestampPreciseToDb(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<void> {
+ const transactionId = constructTransactionIdentifier({
+ tag: TransactionType.Withdrawal,
+ withdrawalGroupId: withdrawalGroup.withdrawalGroupId,
+ });
+ const transitionInfo = await ws.db.runReadWriteTx(
+ ["withdrawalGroups"],
+ 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,
+ cancellationToken: CancellationToken,
+): Promise<TaskRunResult> {
+ 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 withdrawalGroupId = withdrawalGroup.withdrawalGroupId;
+
+ 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);
+ } else if (kycStatusRes.status === HttpStatusCode.Accepted) {
+ const kycStatus = await kycStatusRes.json();
+ logger.info(`kyc status: ${j2s(kycStatus)}`);
+ const kycUrl = kycStatus.kyc_url;
+ if (typeof kycUrl === "string") {
+ await transitionKycUrlUpdate(ws, withdrawalGroupId, kycUrl);
+ }
+ } else if (
+ kycStatusRes.status === HttpStatusCode.UnavailableForLegalReasons
+ ) {
+ const kycStatus = await kycStatusRes.json();
+ logger.info(`aml status: ${j2s(kycStatus)}`);
+ } else {
+ throw Error(`unexpected response from kyc-check (${kycStatusRes.status})`);
+ }
+ return TaskRunResult.backoff();
+}
+
+async function processWithdrawalGroupPendingReady(
+ ws: InternalWalletState,
+ withdrawalGroup: WithdrawalGroupRecord,
+): Promise<TaskRunResult> {
+ const { withdrawalGroupId } = withdrawalGroup;
+ const transactionId = constructTransactionIdentifier({
+ tag: TransactionType.Withdrawal,
+ withdrawalGroupId,
+ });
+
+ await fetchFreshExchange(ws, withdrawalGroup.exchangeBaseUrl);
+
+ if (withdrawalGroup.denomsSel.selectedDenoms.length === 0) {
+ logger.warn("Finishing empty withdrawal group (no denoms)");
+ const transitionInfo = await ws.db.runReadWriteTx(
+ ["withdrawalGroups"],
+ async (tx) => {
+ const wg = await tx.withdrawalGroups.get(withdrawalGroupId);
+ if (!wg) {
+ return undefined;
+ }
+ const txStatusOld = computeWithdrawalTransactionStatus(wg);
+ wg.status = WithdrawalGroupStatus.Done;
+ wg.timestampFinish = timestampPreciseToDb(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<string>(),
+ wgRecord: withdrawalGroup,
+ };
+
+ await ws.db.runReadOnlyTx(["planchets"], 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,
+ });
+ 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;
+ const errorsPerCoin: Record<number, TalerErrorDetail> = {};
+ let numPlanchetErrors = 0;
+ const maxReportedErrors = 5;
+
+ const res = await ws.db.runReadWriteTx(
+ ["coins", "coinAvailability", "withdrawalGroups", "planchets"],
+ 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 = timestampPreciseToDb(TalerPreciseTimestamp.now());
+ wg.status = WithdrawalGroupStatus.Done;
+ 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,
+ hintTransactionId: transactionId,
+ });
+
+ if (numPlanchetErrors > 0) {
+ return {
+ type: TaskRunResultType.Error,
+ errorDetail: makeErrorDetail(
+ TalerErrorCode.WALLET_WITHDRAWAL_GROUP_INCOMPLETE,
+ {
+ errorsPerCoin,
+ numErrors: numPlanchetErrors,
+ },
+ ),
+ };
+ }
+
+ return TaskRunResult.backoff();
+}
+
+export async function processWithdrawalGroup(
+ ws: InternalWalletState,
+ withdrawalGroupId: string,
+ cancellationToken: CancellationToken,
+): Promise<TaskRunResult> {
+ logger.trace("processing withdrawal group", withdrawalGroupId);
+ const withdrawalGroup = await ws.db.runReadOnlyTx(
+ ["withdrawalGroups"],
+ async (tx) => {
+ return tx.withdrawalGroups.get(withdrawalGroupId);
+ },
+ );
+
+ if (!withdrawalGroup) {
+ throw Error(`withdrawal group ${withdrawalGroupId} not found`);
+ }
+
+ 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,
+ cancellationToken,
+ );
+ case WithdrawalGroupStatus.PendingQueryingStatus: {
+ return queryReserve(ws, withdrawalGroupId, cancellationToken);
+ }
+ case WithdrawalGroupStatus.PendingWaitConfirmBank: {
+ return await processReserveBankStatus(ws, withdrawalGroupId);
+ }
+ case WithdrawalGroupStatus.PendingAml:
+ // FIXME: Handle this case, withdrawal doesn't support AML yet.
+ return TaskRunResult.backoff();
+ case WithdrawalGroupStatus.PendingKyc:
+ return processWithdrawalGroupPendingKyc(
+ ws,
+ withdrawalGroup,
+ cancellationToken,
+ );
+ 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:
+ case WithdrawalGroupStatus.Done:
+ case WithdrawalGroupStatus.FailedBankAborted:
+ // 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<ExchangeWithdrawalDetails> {
+ logger.trace("updating exchange");
+ const exchange = await fetchFreshExchange(ws, exchangeBaseUrl);
+
+ if (exchange.currency != instructedAmount.currency) {
+ // Specifying the amount in the conversion input currency is not yet supported.
+ // We might add support for it later.
+ throw new Error(
+ `withdrawal only supported when specifying target currency ${exchange.currency}`,
+ );
+ }
+
+ const withdrawalAccountsList = await fetchWithdrawalAccountInfo(ws, {
+ exchange,
+ instructedAmount,
+ });
+
+ logger.trace("updating withdrawal denoms");
+ await updateWithdrawalDenoms(ws, exchangeBaseUrl);
+
+ logger.trace("getting candidate denoms");
+ const denoms = await getCandidateWithdrawalDenoms(
+ ws,
+ exchangeBaseUrl,
+ instructedAmount.currency,
+ );
+ 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 exchange.wireInfo.accounts) {
+ exchangeWireAccounts.push(account.payto_uri);
+ }
+
+ 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.runReadOnlyTx(["denominations"], 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 getCandidateWithdrawalDenoms(
+ ws,
+ exchangeBaseUrl,
+ instructedAmount.currency,
+ );
+
+ let versionMatch;
+ if (exchange.protocolVersionRange) {
+ versionMatch = LibtoolVersion.compare(
+ WALLET_EXCHANGE_PROTOCOL_VERSION,
+ exchange.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 ${exchange.protocolVersionRange}), checking for updates`,
+ );
+ }
+ }
+
+ let tosAccepted = false;
+ if (exchange.tosAcceptedTimestamp) {
+ if (exchange.tosAcceptedEtag === exchange.tosCurrentEtag) {
+ tosAccepted = true;
+ }
+ }
+
+ const paytoUris = exchange.wireInfo.accounts.map((x) => x.payto_uri);
+ if (!paytoUris) {
+ throw Error("exchange is in invalid state");
+ }
+
+ const ret: ExchangeWithdrawalDetails = {
+ earliestDepositExpiration,
+ exchangePaytoUris: paytoUris,
+ exchangeWireAccounts,
+ exchangeCreditAccountDetails: withdrawalAccountsList,
+ exchangeVersion: exchange.protocolVersionRange || "unknown",
+ 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,
+ scopeInfo: exchange.scopeInfo,
+ };
+ return ret;
+}
+
+export interface GetWithdrawalDetailsForUriOpts {
+ restrictAge?: number;
+ notifyChangeFromPendingTimeoutMs?: number;
+}
+
+type WithdrawalOperationMemoryMap = {
+ [uri: string]: boolean | undefined;
+};
+const ongoingChecks: WithdrawalOperationMemoryMap = {};
+/**
+ * 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 ephemerally 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) {
+ try {
+ // If the exchange entry doesn't exist yet,
+ // it'll be created as an ephemeral entry.
+ await fetchFreshExchange(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`,
+ );
+ }
+ }
+
+ const currency = Amounts.currencyOf(info.amount);
+
+ const listExchangesResp = await listExchanges(ws);
+ const possibleExchanges = listExchangesResp.exchanges.filter((x) => {
+ return (
+ x.currency === currency &&
+ (x.exchangeUpdateStatus === ExchangeUpdateStatus.Ready ||
+ x.exchangeUpdateStatus === ExchangeUpdateStatus.ReadyUpdate)
+ );
+ });
+
+ // FIXME: this should be removed after the extended version of
+ // withdrawal state machine. issue #8099
+ if (
+ info.status === "pending" &&
+ opts.notifyChangeFromPendingTimeoutMs !== undefined &&
+ !ongoingChecks[talerWithdrawUri]
+ ) {
+ ongoingChecks[talerWithdrawUri] = true;
+ const bankApi = new TalerBankIntegrationHttpClient(
+ info.apiBaseUrl,
+ ws.http,
+ );
+ console.log(
+ `waiting operation (${info.operationId}) to change from pending`,
+ );
+ bankApi
+ .getWithdrawalOperationById(info.operationId, {
+ old_state: "pending",
+ timeoutMs: opts.notifyChangeFromPendingTimeoutMs,
+ })
+ .then((resp) => {
+ console.log(
+ `operation (${info.operationId}) to change to ${JSON.stringify(
+ resp,
+ undefined,
+ 2,
+ )}`,
+ );
+ ws.notify({
+ type: NotificationType.WithdrawalOperationTransition,
+ operationId: info.operationId,
+ state: resp.type === "fail" ? info.status : resp.body.status,
+ });
+ ongoingChecks[talerWithdrawUri] = false;
+ });
+ }
+
+ return {
+ operationId: info.operationId,
+ confirmTransferUrl: info.confirmTransferUrl,
+ status: info.status,
+ amount: Amounts.stringify(info.amount),
+ defaultExchangeBaseUrl: info.suggestedExchange,
+ possibleExchanges,
+ };
+}
+
+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: WalletDbReadOnlyTransaction<
+ ["withdrawalGroups", "exchanges", "exchangeDetails"]
+ >,
+ withdrawalGroupId: string,
+): Promise<string[]> {
+ const withdrawalGroup = await tx.withdrawalGroups.get(withdrawalGroupId);
+ checkDbInvariant(!!withdrawalGroup);
+ const exchangeDetails = await getExchangeWireDetailsInTx(
+ 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.runReadOnlyTx(["withdrawalGroups"], 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.runReadOnlyTx(
+ ["withdrawalGroups"],
+ 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("expecting withdrarwal type = bank integrated");
+ }
+ 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),
+ });
+ // FIXME: libeufin-bank currently doesn't return a response in the right format, so we don't validate at all.
+ await readSuccessResponseJsonOrThrow(httpResp, codecForAny());
+ const transitionInfo = await ws.db.runReadWriteTx(
+ ["withdrawalGroups"],
+ 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 = timestampPreciseToDb(
+ 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);
+}
+
+async function processReserveBankStatus(
+ ws: InternalWalletState,
+ withdrawalGroupId: string,
+): Promise<TaskRunResult> {
+ 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 TaskRunResult.backoff();
+ }
+
+ if (
+ withdrawalGroup.wgInfo.withdrawalType != WithdrawalRecordType.BankIntegrated
+ ) {
+ throw Error("wrong withdrawal record type");
+ }
+ const bankInfo = withdrawalGroup.wgInfo.bankInfo;
+ if (!bankInfo) {
+ return TaskRunResult.backoff();
+ }
+
+ const bankStatusUrl = getBankStatusUrl(bankInfo.talerWithdrawUri);
+
+ const statusResp = await ws.http.fetch(bankStatusUrl, {
+ timeout: getReserveRequestTimeout(withdrawalGroup),
+ });
+ const status = await readSuccessResponseJsonOrThrow(
+ statusResp,
+ codecForWithdrawOperationStatusResponse(),
+ );
+
+ if (status.aborted) {
+ logger.info("bank aborted the withdrawal");
+ const transitionInfo = await ws.db.runReadWriteTx(
+ ["withdrawalGroups"],
+ 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 = timestampPreciseToDb(now);
+ r.status = WithdrawalGroupStatus.FailedBankAborted;
+ const newTxState = computeWithdrawalTransactionStatus(r);
+ await tx.withdrawalGroups.put(r);
+ return {
+ oldTxState,
+ newTxState,
+ };
+ },
+ );
+ notifyTransition(ws, transactionId, transitionInfo);
+ return TaskRunResult.finished();
+ }
+
+ // 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.runReadWriteTx(
+ ["withdrawalGroups"],
+ 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 = timestampPreciseToDb(now);
+ r.status = WithdrawalGroupStatus.PendingQueryingStatus;
+ } else {
+ logger.trace("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 (transitionInfo) {
+ return TaskRunResult.progress();
+ } else {
+ return TaskRunResult.backoff();
+ }
+}
+
+export interface PrepareCreateWithdrawalGroupResult {
+ withdrawalGroup: WithdrawalGroupRecord;
+ transactionId: string;
+ creationInfo?: {
+ amount: AmountJson;
+ canonExchange: string;
+ };
+}
+
+export async function internalPrepareCreateWithdrawalGroup(
+ ws: InternalWalletState,
+ args: {
+ reserveStatus: WithdrawalGroupStatus;
+ amount: AmountJson;
+ exchangeBaseUrl: string;
+ forcedWithdrawalGroupId?: string;
+ forcedDenomSel?: ForcedDenomSel;
+ reserveKeyPair?: EddsaKeypair;
+ restrictAge?: number;
+ wgInfo: WgInfo;
+ },
+): Promise<PrepareCreateWithdrawalGroupResult> {
+ 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;
+ const currency = Amounts.currencyOf(amount);
+
+ let withdrawalGroupId;
+
+ if (args.forcedWithdrawalGroupId) {
+ withdrawalGroupId = args.forcedWithdrawalGroupId;
+ const wgId = withdrawalGroupId;
+ const existingWg = await ws.db.runReadOnlyTx(
+ ["withdrawalGroups"],
+ 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,
+ currency,
+ );
+
+ 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: timestampPreciseToDb(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,
+ };
+
+ await fetchFreshExchange(ws, canonExchange);
+ const transactionId = constructTransactionIdentifier({
+ tag: TransactionType.Withdrawal,
+ withdrawalGroupId: withdrawalGroup.withdrawalGroupId,
+ });
+
+ return {
+ withdrawalGroup,
+ transactionId,
+ creationInfo: {
+ canonExchange,
+ amount,
+ },
+ };
+}
+
+export interface PerformCreateWithdrawalGroupResult {
+ withdrawalGroup: WithdrawalGroupRecord;
+ transitionInfo: TransitionInfo | undefined;
+
+ /**
+ * Notification for the exchange state transition.
+ *
+ * Should be emitted after the transaction has succeeded.
+ */
+ exchangeNotif: WalletNotification | undefined;
+}
+
+export async function internalPerformCreateWithdrawalGroup(
+ ws: InternalWalletState,
+ tx: WalletDbReadWriteTransaction<
+ ["withdrawalGroups", "reserves", "exchanges"]
+ >,
+ prep: PrepareCreateWithdrawalGroupResult,
+): Promise<PerformCreateWithdrawalGroupResult> {
+ const { withdrawalGroup } = prep;
+ if (!prep.creationInfo) {
+ return {
+ withdrawalGroup,
+ transitionInfo: undefined,
+ exchangeNotif: undefined,
+ };
+ }
+ const existingWg = await tx.withdrawalGroups.get(
+ withdrawalGroup.withdrawalGroupId,
+ );
+ if (existingWg) {
+ return {
+ withdrawalGroup: existingWg,
+ exchangeNotif: undefined,
+ transitionInfo: undefined,
+ };
+ }
+ 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 = timestampPreciseToDb(TalerPreciseTimestamp.now());
+ await tx.exchanges.put(exchange);
+ }
+
+ const oldTxState = {
+ major: TransactionMajorState.None,
+ minor: undefined,
+ };
+ const newTxState = computeWithdrawalTransactionStatus(withdrawalGroup);
+ const transitionInfo = {
+ oldTxState,
+ newTxState,
+ };
+
+ const exchangeUsedRes = await markExchangeUsed(
+ ws,
+ tx,
+ prep.withdrawalGroup.exchangeBaseUrl,
+ );
+
+ const ctx = new WithdrawTransactionContext(
+ ws,
+ withdrawalGroup.withdrawalGroupId,
+ );
+
+ ws.taskScheduler.startShepherdTask(ctx.taskId);
+
+ return {
+ withdrawalGroup,
+ transitionInfo,
+ exchangeNotif: exchangeUsedRes.notif,
+ };
+}
+
+/**
+ * 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 prep = await internalPrepareCreateWithdrawalGroup(ws, args);
+ const transactionId = constructTransactionIdentifier({
+ tag: TransactionType.Withdrawal,
+ withdrawalGroupId: prep.withdrawalGroup.withdrawalGroupId,
+ });
+ const res = await ws.db.runReadWriteTx(
+ ["withdrawalGroups", "reserves", "exchanges", "exchangeDetails"],
+ async (tx) => {
+ return await internalPerformCreateWithdrawalGroup(ws, tx, prep);
+ },
+ );
+ if (res.exchangeNotif) {
+ ws.notify(res.exchangeNotif);
+ }
+ notifyTransition(ws, transactionId, res.transitionInfo);
+ return res.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.runReadOnlyTx(
+ ["withdrawalGroups"],
+ 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 fetchFreshExchange(ws, selectedExchange);
+ const withdrawInfo = await getBankWithdrawalInfo(
+ ws.http,
+ req.talerWithdrawUri,
+ );
+ const exchangePaytoUri = await getExchangePaytoUri(
+ ws,
+ selectedExchange,
+ withdrawInfo.wireTypes,
+ );
+
+ const exchange = await fetchFreshExchange(ws, selectedExchange);
+
+ const withdrawalAccountList = await fetchWithdrawalAccountInfo(ws, {
+ exchange,
+ instructedAmount: withdrawInfo.amount,
+ });
+
+ const withdrawalGroup = await internalCreateWithdrawalGroup(ws, {
+ amount: withdrawInfo.amount,
+ exchangeBaseUrl: req.selectedExchange,
+ wgInfo: {
+ withdrawalType: WithdrawalRecordType.BankIntegrated,
+ exchangeCreditAccounts: withdrawalAccountList,
+ 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 ctx = new WithdrawTransactionContext(ws, withdrawalGroupId);
+
+ const transactionId = ctx.transactionId;
+
+ // 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.taskScheduler.startShepherdTask(ctx.taskId);
+
+ return {
+ reservePub: withdrawalGroup.reservePub,
+ confirmTransferUrl: withdrawInfo.confirmTransferUrl,
+ transactionId,
+ };
+}
+
+async function fetchAccount(
+ ws: InternalWalletState,
+ instructedAmount: AmountJson,
+ acct: ExchangeWireAccount,
+ reservePub?: string,
+): Promise<WithdrawalExchangeAccountDetails> {
+ let paytoUri: string;
+ let transferAmount: AmountString | undefined = undefined;
+ let currencySpecification: CurrencySpecification | undefined = undefined;
+ if (acct.conversion_url != null) {
+ const reqUrl = new URL("cashin-rate", acct.conversion_url);
+ reqUrl.searchParams.set(
+ "amount_credit",
+ Amounts.stringify(instructedAmount),
+ );
+ const httpResp = await ws.http.fetch(reqUrl.href);
+ const respOrErr = await readSuccessResponseJsonOrErrorCode(
+ httpResp,
+ codecForCashinConversionResponse(),
+ );
+ if (respOrErr.isError) {
+ return {
+ status: "error",
+ paytoUri: acct.payto_uri,
+ conversionError: respOrErr.talerErrorResponse,
+ };
+ }
+ const resp = respOrErr.response;
+ paytoUri = acct.payto_uri;
+ transferAmount = resp.amount_debit;
+ const configUrl = new URL("config", acct.conversion_url);
+ const configResp = await ws.http.fetch(configUrl.href);
+ const configRespOrError = await readSuccessResponseJsonOrErrorCode(
+ configResp,
+ codecForConversionBankConfig(),
+ );
+ if (configRespOrError.isError) {
+ return {
+ status: "error",
+ paytoUri: acct.payto_uri,
+ conversionError: configRespOrError.talerErrorResponse,
+ };
+ }
+ const configParsed = configRespOrError.response;
+ currencySpecification = configParsed.fiat_currency_specification;
+ } else {
+ paytoUri = acct.payto_uri;
+ transferAmount = Amounts.stringify(instructedAmount);
+ }
+ paytoUri = addPaytoQueryParams(paytoUri, {
+ amount: Amounts.stringify(transferAmount),
+ });
+ if (reservePub != null) {
+ paytoUri = addPaytoQueryParams(paytoUri, {
+ message: `Taler Withdrawal ${reservePub}`,
+ });
+ }
+ const acctInfo: WithdrawalExchangeAccountDetails = {
+ status: "ok",
+ paytoUri,
+ transferAmount,
+ currencySpecification,
+ creditRestrictions: acct.credit_restrictions,
+ };
+ if (transferAmount != null) {
+ acctInfo.transferAmount = transferAmount;
+ }
+ return acctInfo;
+}
+
+/**
+ * Gather information about bank accounts that can be used for
+ * withdrawals. This includes accounts that are in a different
+ * currency and require conversion.
+ */
+async function fetchWithdrawalAccountInfo(
+ ws: InternalWalletState,
+ req: {
+ exchange: ReadyExchangeSummary;
+ instructedAmount: AmountJson;
+ reservePub?: string;
+ },
+): Promise<WithdrawalExchangeAccountDetails[]> {
+ const { exchange, instructedAmount } = req;
+ const withdrawalAccounts: WithdrawalExchangeAccountDetails[] = [];
+ for (let acct of exchange.wireInfo.accounts) {
+ const acctInfo = await fetchAccount(
+ ws,
+ req.instructedAmount,
+ acct,
+ req.reservePub,
+ );
+ withdrawalAccounts.push(acctInfo);
+ }
+ return withdrawalAccounts;
+}
+
+/**
+ * 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 { exchangeBaseUrl } = req;
+ const amount = Amounts.parseOrThrow(req.amount);
+ const exchange = await fetchFreshExchange(ws, exchangeBaseUrl);
+
+ if (exchange.currency != amount.currency) {
+ throw Error(
+ "manual withdrawal with conversion from foreign currency is not yet supported",
+ );
+ }
+ const reserveKeyPair: EddsaKeypair = await ws.cryptoApi.createEddsaKeypair(
+ {},
+ );
+
+ const withdrawalAccountsList = await fetchWithdrawalAccountInfo(ws, {
+ exchange,
+ instructedAmount: amount,
+ reservePub: reserveKeyPair.pub,
+ });
+
+ const withdrawalGroup = await internalCreateWithdrawalGroup(ws, {
+ amount: Amounts.jsonifyAmount(req.amount),
+ wgInfo: {
+ withdrawalType: WithdrawalRecordType.BankManual,
+ exchangeCreditAccounts: withdrawalAccountsList,
+ },
+ exchangeBaseUrl: req.exchangeBaseUrl,
+ forcedDenomSel: req.forcedDenomSel,
+ restrictAge: req.restrictAge,
+ reserveStatus: WithdrawalGroupStatus.PendingQueryingStatus,
+ reserveKeyPair,
+ });
+
+ const withdrawalGroupId = withdrawalGroup.withdrawalGroupId;
+ const ctx = new WithdrawTransactionContext(ws, withdrawalGroupId);
+
+ const transactionId = ctx.transactionId;
+
+ const exchangePaytoUris = await ws.db.runReadOnlyTx(
+ ["withdrawalGroups", "exchanges", "exchangeDetails"],
+ async (tx) => {
+ return await getFundingPaytoUris(tx, withdrawalGroup.withdrawalGroupId);
+ },
+ );
+
+ ws.taskScheduler.startShepherdTask(ctx.taskId);
+
+ return {
+ reservePub: withdrawalGroup.reservePub,
+ exchangePaytoUris: exchangePaytoUris,
+ withdrawalAccountsList: withdrawalAccountsList,
+ transactionId,
+ };
+}