summaryrefslogtreecommitdiff
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.ts3455
1 files changed, 3455 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..106bd93a4
--- /dev/null
+++ b/packages/taler-wallet-core/src/withdraw.ts
@@ -0,0 +1,3455 @@
+/*
+ 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/>
+ */
+
+/**
+ * @fileoverview Implementation of Taler withdrawals, both
+ * bank-integrated and manual.
+ */
+
+/**
+ * Imports.
+ */
+import {
+ AbsoluteTime,
+ AcceptManualWithdrawalResult,
+ AcceptWithdrawalResponse,
+ AgeRestriction,
+ Amount,
+ AmountJson,
+ AmountLike,
+ AmountString,
+ Amounts,
+ AsyncFlag,
+ BankWithdrawDetails,
+ CancellationToken,
+ CoinStatus,
+ CurrencySpecification,
+ DenomKeyType,
+ DenomSelItem,
+ DenomSelectionState,
+ Duration,
+ EddsaPrivateKeyString,
+ ExchangeBatchWithdrawRequest,
+ ExchangeUpdateStatus,
+ ExchangeWireAccount,
+ ExchangeWithdrawBatchResponse,
+ ExchangeWithdrawRequest,
+ ExchangeWithdrawResponse,
+ ExchangeWithdrawalDetails,
+ ForcedDenomSel,
+ GetWithdrawalDetailsForAmountRequest,
+ HttpStatusCode,
+ LibtoolVersion,
+ Logger,
+ NotificationType,
+ ObservabilityEventType,
+ PrepareBankIntegratedWithdrawalResponse,
+ TalerBankIntegrationHttpClient,
+ TalerError,
+ TalerErrorCode,
+ TalerErrorDetail,
+ TalerPreciseTimestamp,
+ Transaction,
+ TransactionAction,
+ TransactionIdStr,
+ TransactionMajorState,
+ TransactionMinorState,
+ TransactionState,
+ TransactionType,
+ URL,
+ UnblindedSignature,
+ WalletNotification,
+ WithdrawUriInfoResponse,
+ WithdrawalDetailsForAmount,
+ WithdrawalExchangeAccountDetails,
+ WithdrawalType,
+ addPaytoQueryParams,
+ assertUnreachable,
+ canonicalizeBaseUrl,
+ checkDbInvariant,
+ checkLogicInvariant,
+ codeForBankWithdrawalOperationPostResponse,
+ codecForBankWithdrawalOperationStatus,
+ codecForCashinConversionResponse,
+ codecForConversionBankConfig,
+ codecForExchangeWithdrawBatchResponse,
+ codecForReserveStatus,
+ codecForWalletKycUuid,
+ codecForWithdrawOperationStatusResponse,
+ encodeCrock,
+ getErrorDetailFromException,
+ getRandomBytes,
+ j2s,
+ makeErrorDetail,
+ parseWithdrawUri,
+} from "@gnu-taler/taler-util";
+import {
+ HttpRequestLibrary,
+ HttpResponse,
+ readSuccessResponseJsonOrErrorCode,
+ readSuccessResponseJsonOrThrow,
+ readTalerErrorResponse,
+ throwUnexpectedRequestError,
+} from "@gnu-taler/taler-util/http";
+import {
+ PendingTaskType,
+ TaskIdStr,
+ TaskRunResult,
+ TaskRunResultType,
+ TombstoneTag,
+ TransactionContext,
+ TransitionResult,
+ TransitionResultType,
+ constructTaskIdentifier,
+ makeCoinAvailable,
+ makeCoinsVisible,
+} from "./common.js";
+import { EddsaKeypair } from "./crypto/cryptoImplementation.js";
+import {
+ CoinRecord,
+ CoinSourceType,
+ DenominationRecord,
+ DenominationVerificationStatus,
+ KycPendingInfo,
+ PlanchetRecord,
+ PlanchetStatus,
+ WalletDbReadOnlyTransaction,
+ WalletDbReadWriteTransaction,
+ WalletDbStoresArr,
+ WalletStoresV1,
+ WgInfo,
+ WithdrawalGroupRecord,
+ WithdrawalGroupStatus,
+ WithdrawalRecordType,
+ timestampAbsoluteFromDb,
+ timestampPreciseFromDb,
+ timestampPreciseToDb,
+} from "./db.js";
+import {
+ selectForcedWithdrawalDenominations,
+ selectWithdrawalDenominations,
+} from "./denomSelection.js";
+import { isWithdrawableDenom } from "./denominations.js";
+import {
+ ReadyExchangeSummary,
+ fetchFreshExchange,
+ getExchangePaytoUri,
+ getExchangeWireDetailsInTx,
+ listExchanges,
+ markExchangeUsed,
+} from "./exchanges.js";
+import { DbAccess } from "./query.js";
+import {
+ TransitionInfo,
+ constructTransactionIdentifier,
+ isUnsuccessfulTransaction,
+ notifyTransition,
+ parseTransactionIdentifier,
+} from "./transactions.js";
+import {
+ WALLET_BANK_INTEGRATION_PROTOCOL_VERSION,
+ WALLET_EXCHANGE_PROTOCOL_VERSION,
+} from "./versions.js";
+import { WalletExecutionContext, getDenomInfo } from "./wallet.js";
+
+/**
+ * Logger for this file.
+ */
+const logger = new Logger("withdraw.ts");
+
+/**
+ * Update the materialized withdrawal transaction based
+ * on the withdrawal group record.
+ */
+async function updateWithdrawalTransaction(
+ ctx: WithdrawTransactionContext,
+ tx: WalletDbReadWriteTransaction<
+ [
+ "withdrawalGroups",
+ "transactions",
+ "operationRetries",
+ "exchanges",
+ "exchangeDetails",
+ ]
+ >,
+): Promise<void> {
+ const wgRecord = await tx.withdrawalGroups.get(ctx.withdrawalGroupId);
+ if (!wgRecord) {
+ await tx.transactions.delete(ctx.transactionId);
+ return;
+ }
+ const retryRecord = await tx.operationRetries.get(ctx.taskId);
+
+ let transactionItem: Transaction;
+
+ if (wgRecord.wgInfo.withdrawalType === WithdrawalRecordType.BankIntegrated) {
+ const txState = computeWithdrawalTransactionStatus(wgRecord);
+ transactionItem = {
+ type: TransactionType.Withdrawal,
+ txState,
+ txActions: computeWithdrawalTransactionActions(wgRecord),
+ amountEffective: isUnsuccessfulTransaction(txState)
+ ? Amounts.stringify(Amounts.zeroOfAmount(wgRecord.instructedAmount))
+ : Amounts.stringify(wgRecord.denomsSel.totalCoinValue),
+ amountRaw: Amounts.stringify(wgRecord.instructedAmount),
+ withdrawalDetails: {
+ type: WithdrawalType.TalerBankIntegrationApi,
+ confirmed: wgRecord.wgInfo.bankInfo.timestampBankConfirmed
+ ? true
+ : false,
+ exchangeCreditAccountDetails: wgRecord.wgInfo.exchangeCreditAccounts,
+ reservePub: wgRecord.reservePub,
+ bankConfirmationUrl: wgRecord.wgInfo.bankInfo.confirmUrl,
+ reserveIsReady:
+ wgRecord.status === WithdrawalGroupStatus.Done ||
+ wgRecord.status === WithdrawalGroupStatus.PendingReady,
+ },
+ kycUrl: wgRecord.kycUrl,
+ exchangeBaseUrl: wgRecord.exchangeBaseUrl,
+ timestamp: timestampPreciseFromDb(wgRecord.timestampStart),
+ transactionId: ctx.transactionId,
+ };
+ } else if (
+ wgRecord.wgInfo.withdrawalType === WithdrawalRecordType.BankManual
+ ) {
+ const exchangeDetails = await getExchangeWireDetailsInTx(
+ tx,
+ wgRecord.exchangeBaseUrl,
+ );
+ const plainPaytoUris =
+ exchangeDetails?.wireInfo?.accounts.map((x) => x.payto_uri) ?? [];
+
+ const exchangePaytoUris = augmentPaytoUrisForWithdrawal(
+ plainPaytoUris,
+ wgRecord.reservePub,
+ wgRecord.instructedAmount,
+ );
+
+ const txState = computeWithdrawalTransactionStatus(wgRecord);
+
+ transactionItem = {
+ type: TransactionType.Withdrawal,
+ txState,
+ txActions: computeWithdrawalTransactionActions(wgRecord),
+ amountEffective: isUnsuccessfulTransaction(txState)
+ ? Amounts.stringify(Amounts.zeroOfAmount(wgRecord.instructedAmount))
+ : Amounts.stringify(wgRecord.denomsSel.totalCoinValue),
+ amountRaw: Amounts.stringify(wgRecord.instructedAmount),
+ withdrawalDetails: {
+ type: WithdrawalType.ManualTransfer,
+ reservePub: wgRecord.reservePub,
+ exchangePaytoUris,
+ exchangeCreditAccountDetails: wgRecord.wgInfo.exchangeCreditAccounts,
+ reserveIsReady:
+ wgRecord.status === WithdrawalGroupStatus.Done ||
+ wgRecord.status === WithdrawalGroupStatus.PendingReady,
+ },
+ kycUrl: wgRecord.kycUrl,
+ exchangeBaseUrl: wgRecord.exchangeBaseUrl,
+ timestamp: timestampPreciseFromDb(wgRecord.timestampStart),
+ transactionId: ctx.transactionId,
+ };
+ } else {
+ // FIXME: If this is an orphaned withdrawal for a p2p transaction, we
+ // still might want to report the withdrawal.
+ return;
+ }
+
+ if (retryRecord?.lastError) {
+ transactionItem.error = retryRecord.lastError;
+ }
+
+ await tx.transactions.put({
+ currency: Amounts.currencyOf(wgRecord.instructedAmount),
+ transactionItem,
+ exchanges: [wgRecord.exchangeBaseUrl],
+ });
+
+ // FIXME: Handle orphaned withdrawals where the p2p or recoup tx was deleted?
+}
+
+export class WithdrawTransactionContext implements TransactionContext {
+ readonly transactionId: TransactionIdStr;
+ readonly taskId: TaskIdStr;
+
+ constructor(
+ public wex: WalletExecutionContext,
+ public withdrawalGroupId: string,
+ ) {
+ this.transactionId = constructTransactionIdentifier({
+ tag: TransactionType.Withdrawal,
+ withdrawalGroupId,
+ });
+ this.taskId = constructTaskIdentifier({
+ tag: PendingTaskType.Withdraw,
+ withdrawalGroupId,
+ });
+ }
+
+ /**
+ * Transition a withdrawal transaction.
+ * Extra object stores may be accessed during the transition.
+ */
+ async transition<StoreNameArray extends WalletDbStoresArr = []>(
+ opts: { extraStores?: StoreNameArray; transactionLabel?: string },
+ f: (
+ rec: WithdrawalGroupRecord | undefined,
+ tx: WalletDbReadWriteTransaction<
+ [
+ "withdrawalGroups",
+ "transactions",
+ "operationRetries",
+ "exchanges",
+ "exchangeDetails",
+ ...StoreNameArray,
+ ]
+ >,
+ ) => Promise<TransitionResult<WithdrawalGroupRecord>>,
+ ): Promise<TransitionInfo | undefined> {
+ const baseStores = [
+ "withdrawalGroups" as const,
+ "transactions" as const,
+ "operationRetries" as const,
+ "exchanges" as const,
+ "exchangeDetails" as const,
+ ];
+ let stores = opts.extraStores
+ ? [...baseStores, ...opts.extraStores]
+ : baseStores;
+ const transitionInfo = await this.wex.db.runReadWriteTx(
+ { storeNames: stores },
+ async (tx) => {
+ const wgRec = await tx.withdrawalGroups.get(this.withdrawalGroupId);
+ let oldTxState: TransactionState;
+ if (wgRec) {
+ oldTxState = computeWithdrawalTransactionStatus(wgRec);
+ } else {
+ oldTxState = {
+ major: TransactionMajorState.None,
+ };
+ }
+ const res = await f(wgRec, tx);
+ switch (res.type) {
+ case TransitionResultType.Transition: {
+ await tx.withdrawalGroups.put(res.rec);
+ await updateWithdrawalTransaction(this, tx);
+ const newTxState = computeWithdrawalTransactionStatus(res.rec);
+ return {
+ oldTxState,
+ newTxState,
+ };
+ }
+ case TransitionResultType.Delete:
+ await tx.withdrawalGroups.delete(this.withdrawalGroupId);
+ await updateWithdrawalTransaction(this, tx);
+ return {
+ oldTxState,
+ newTxState: {
+ major: TransactionMajorState.None,
+ },
+ };
+ default:
+ return undefined;
+ }
+ },
+ );
+ notifyTransition(this.wex, this.transactionId, transitionInfo);
+ return transitionInfo;
+ }
+
+ async deleteTransaction(): Promise<void> {
+ await this.transition(
+ {
+ extraStores: ["tombstones"],
+ transactionLabel: "delete-transaction-withdraw",
+ },
+ async (rec, tx) => {
+ if (!rec) {
+ return TransitionResult.stay();
+ }
+ if (rec) {
+ await tx.tombstones.put({
+ id:
+ TombstoneTag.DeleteWithdrawalGroup + ":" + rec.withdrawalGroupId,
+ });
+ }
+ return TransitionResult.delete();
+ },
+ );
+ }
+
+ async suspendTransaction(): Promise<void> {
+ const { withdrawalGroupId } = this;
+ await this.transition(
+ {
+ transactionLabel: "suspend-transaction-withdraw",
+ },
+ async (wg, _tx) => {
+ if (!wg) {
+ logger.warn(`withdrawal group ${withdrawalGroupId} not found`);
+ return TransitionResult.stay();
+ }
+ 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}`,
+ );
+ return TransitionResult.stay();
+ }
+ wg.status = newStatus;
+ return TransitionResult.transition(wg);
+ },
+ );
+ }
+
+ async abortTransaction(): Promise<void> {
+ const { withdrawalGroupId } = this;
+ await this.transition(
+ {
+ transactionLabel: "abort-transaction-withdraw",
+ },
+ async (wg, _tx) => {
+ if (!wg) {
+ logger.warn(`withdrawal group ${withdrawalGroupId} not found`);
+ return TransitionResult.stay();
+ }
+ 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:
+ case WithdrawalGroupStatus.AbortedUserRefused:
+ // No transition needed, but not an error
+ return TransitionResult.stay();
+ case WithdrawalGroupStatus.DialogProposed:
+ newStatus = WithdrawalGroupStatus.AbortedUserRefused;
+ break;
+ case WithdrawalGroupStatus.Done:
+ case WithdrawalGroupStatus.FailedBankAborted:
+ case WithdrawalGroupStatus.AbortedExchange:
+ case WithdrawalGroupStatus.AbortedBank:
+ case WithdrawalGroupStatus.FailedAbortingBank:
+ case WithdrawalGroupStatus.AbortedOtherWallet:
+ // Not allowed
+ throw Error("abort not allowed in current state");
+ default:
+ assertUnreachable(wg.status);
+ }
+ wg.status = newStatus;
+ return TransitionResult.transition(wg);
+ },
+ );
+ }
+
+ async resumeTransaction(): Promise<void> {
+ const { withdrawalGroupId } = this;
+ await this.transition(
+ {
+ transactionLabel: "resume-transaction-withdraw",
+ },
+ async (wg, _tx) => {
+ if (!wg) {
+ logger.warn(`withdrawal group ${withdrawalGroupId} not found`);
+ return TransitionResult.stay();
+ }
+ 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}`,
+ );
+ return TransitionResult.stay();
+ }
+ wg.status = newStatus;
+ return TransitionResult.transition(wg);
+ },
+ );
+ }
+
+ async failTransaction(): Promise<void> {
+ const { withdrawalGroupId } = this;
+ await this.transition(
+ {
+ transactionLabel: "fail-transaction-withdraw",
+ },
+ async (wg, _tx) => {
+ if (!wg) {
+ logger.warn(`withdrawal group ${withdrawalGroupId} not found`);
+ return TransitionResult.stay();
+ }
+ let newStatus: WithdrawalGroupStatus | undefined = undefined;
+ switch (wg.status) {
+ case WithdrawalGroupStatus.SuspendedAbortingBank:
+ case WithdrawalGroupStatus.AbortingBank:
+ newStatus = WithdrawalGroupStatus.FailedAbortingBank;
+ break;
+ default:
+ return TransitionResult.stay();
+ }
+ wg.status = newStatus;
+ return TransitionResult.transition(wg);
+ },
+ );
+ }
+}
+
+/**
+ * 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,
+ };
+ case WithdrawalGroupStatus.AbortedUserRefused:
+ return {
+ major: TransactionMajorState.Aborted,
+ minor: TransactionMinorState.Refused,
+ };
+ case WithdrawalGroupStatus.DialogProposed:
+ return {
+ major: TransactionMajorState.Dialog,
+ minor: TransactionMinorState.Proposed,
+ };
+ case WithdrawalGroupStatus.AbortedOtherWallet:
+ return {
+ major: TransactionMajorState.Aborted,
+ minor: TransactionMinorState.CompletedByOtherWallet,
+ };
+ }
+}
+
+/**
+ * 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:
+ case WithdrawalGroupStatus.AbortedExchange:
+ case WithdrawalGroupStatus.AbortedBank:
+ case WithdrawalGroupStatus.AbortedOtherWallet:
+ case WithdrawalGroupStatus.AbortedUserRefused:
+ return [TransactionAction.Delete];
+ case WithdrawalGroupStatus.DialogProposed:
+ return [TransactionAction.Abort];
+ }
+}
+
+async function processWithdrawalGroupDialogProposed(
+ ctx: WithdrawTransactionContext,
+ withdrawalGroup: WithdrawalGroupRecord,
+): Promise<TaskRunResult> {
+ if (
+ withdrawalGroup.wgInfo.withdrawalType !==
+ WithdrawalRecordType.BankIntegrated
+ ) {
+ throw new Error(
+ "processWithdrawalGroupDialogProposed called in unexpected state",
+ );
+ }
+
+ const talerWithdrawUri = withdrawalGroup.wgInfo.bankInfo.talerWithdrawUri;
+
+ const parsedUri = parseWithdrawUri(talerWithdrawUri);
+
+ checkLogicInvariant(!!parsedUri);
+
+ const wopid = parsedUri.withdrawalOperationId;
+
+ const url = new URL(
+ `withdrawal-operation/${wopid}`,
+ parsedUri.bankIntegrationApiBaseUrl,
+ );
+
+ url.searchParams.set("old_state", "pending");
+ url.searchParams.set("long_poll_ms", "30000");
+
+ const resp = await ctx.wex.http.fetch(url.href, {
+ method: "GET",
+ cancellationToken: ctx.wex.cancellationToken,
+ });
+
+ // If the bank claims that the withdrawal operation is already
+ // pending, but we're still in DialogProposed, some other wallet
+ // must've completed the withdrawal, we're giving up.
+
+ switch (resp.status) {
+ case HttpStatusCode.Ok: {
+ const body = await readSuccessResponseJsonOrThrow(
+ resp,
+ codecForBankWithdrawalOperationStatus(),
+ );
+ if (body.status !== "pending") {
+ await ctx.transition({}, async (rec) => {
+ switch (rec?.status) {
+ case WithdrawalGroupStatus.DialogProposed: {
+ rec.status = WithdrawalGroupStatus.AbortedOtherWallet;
+ return TransitionResult.transition(rec);
+ }
+ }
+ return TransitionResult.stay();
+ });
+ }
+ break;
+ }
+ }
+
+ return TaskRunResult.longpollReturnedPending();
+}
+
+/**
+ * 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(
+ wex: WalletExecutionContext,
+ exchangeBaseUrl: string,
+ currency: string,
+): Promise<DenominationRecord[]> {
+ return await wex.db.runReadOnlyTx(
+ { storeNames: ["denominations"] },
+ async (tx) => {
+ return getCandidateWithdrawalDenomsTx(wex, tx, exchangeBaseUrl, currency);
+ },
+ );
+}
+
+export async function getCandidateWithdrawalDenomsTx(
+ wex: WalletExecutionContext,
+ tx: WalletDbReadOnlyTransaction<["denominations"]>,
+ exchangeBaseUrl: string,
+ currency: string,
+): Promise<DenominationRecord[]> {
+ // FIXME(https://bugs.taler.net/n/8446): 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, wex.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(
+ wex: WalletExecutionContext,
+ withdrawalGroup: WithdrawalGroupRecord,
+ coinIdx: number,
+): Promise<void> {
+ let planchet = await wex.db.runReadOnlyTx(
+ { storeNames: ["planchets"] },
+ async (tx) => {
+ return tx.planchets.indexes.byGroupAndIndex.get([
+ withdrawalGroup.withdrawalGroupId,
+ coinIdx,
+ ]);
+ },
+ );
+ if (planchet) {
+ return;
+ }
+ let ci = 0;
+ let isSkipped = false;
+ 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;
+ if (coinIdx >= ci + d.count - (d.skip ?? 0)) {
+ isSkipped = true;
+ }
+ break;
+ }
+ ci += d.count;
+ }
+ if (isSkipped) {
+ return;
+ }
+ if (!maybeDenomPubHash) {
+ throw Error("invariant violated");
+ }
+ const denomPubHash = maybeDenomPubHash;
+
+ const denom = await wex.db.runReadOnlyTx(
+ { storeNames: ["denominations"] },
+ async (tx) => {
+ return getDenomInfo(
+ wex,
+ tx,
+ withdrawalGroup.exchangeBaseUrl,
+ denomPubHash,
+ );
+ },
+ );
+ checkDbInvariant(!!denom);
+ const r = await wex.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 wex.db.runReadWriteTx({ storeNames: ["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;
+}
+
+// FIXME: Move to exchange API types
+enum ExchangeAmlStatus {
+ Normal = 0,
+ Pending = 1,
+ Frozen = 2,
+}
+
+async function handleKycRequired(
+ wex: WalletExecutionContext,
+ 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 ctx = new WithdrawTransactionContext(wex, 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 wex.http.fetch(url.href, {
+ method: "GET",
+ cancellationToken: wex.cancellationToken,
+ });
+ let kycUrl: string;
+ let amlStatus: ExchangeAmlStatus | 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})`);
+ }
+
+ await ctx.transition(
+ {
+ extraStores: ["planchets"],
+ },
+ async (wg2, tx) => {
+ if (!wg2) {
+ return TransitionResult.stay();
+ }
+ 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);
+ }
+ if (wg2.status !== WithdrawalGroupStatus.PendingReady) {
+ return TransitionResult.stay();
+ }
+ wg2.kycPending = {
+ paytoHash: uuidResp.h_payto,
+ requirementRow: uuidResp.requirement_row,
+ };
+ wg2.kycUrl = kycUrl;
+ wg2.status =
+ amlStatus === ExchangeAmlStatus.Normal || amlStatus === undefined
+ ? WithdrawalGroupStatus.PendingKyc
+ : amlStatus === ExchangeAmlStatus.Pending
+ ? WithdrawalGroupStatus.PendingAml
+ : amlStatus === ExchangeAmlStatus.Frozen
+ ? WithdrawalGroupStatus.SuspendedAml
+ : assertUnreachable(amlStatus);
+ return TransitionResult.transition(wg2);
+ },
+ );
+}
+
+/**
+ * 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(
+ wex: WalletExecutionContext,
+ wgContext: WithdrawalGroupStatusInfo,
+ 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 wex.db.runReadOnlyTx(
+ { storeNames: ["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;
+ }
+ if (planchet.planchetStatus === PlanchetStatus.AbortedReplaced) {
+ continue;
+ }
+ const denom = await getDenomInfo(
+ wex,
+ 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(
+ errDetail: TalerErrorDetail,
+ coinIdx: number,
+ ): Promise<void> {
+ logger.trace(`withdrawal request failed: ${j2s(errDetail)}`);
+ await wex.db.runReadWriteTx({ storeNames: ["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 wex.http.fetch(reqUrl, {
+ method: "POST",
+ body: batchReq,
+ cancellationToken: wex.cancellationToken,
+ timeout: Duration.fromSpec({ seconds: 40 }),
+ });
+ if (resp.status === HttpStatusCode.UnavailableForLegalReasons) {
+ await handleKycRequired(wex, withdrawalGroup, resp, 0, requestCoinIdxs);
+ return {
+ batchResp: { ev_sigs: [] },
+ coinIdxs: [],
+ };
+ }
+ if (resp.status === HttpStatusCode.Gone) {
+ const e = await readTalerErrorResponse(resp);
+ // FIXME: Store in place of the planchet that is actually affected!
+ await storeCoinError(e, requestCoinIdxs[0]);
+ return {
+ batchResp: { ev_sigs: [] },
+ coinIdxs: [],
+ };
+ }
+ const r = await readSuccessResponseJsonOrThrow(
+ resp,
+ codecForExchangeWithdrawBatchResponse(),
+ );
+ return {
+ coinIdxs: requestCoinIdxs,
+ batchResp: r,
+ };
+ } catch (e) {
+ const errDetail = getErrorDetailFromException(e);
+ // We don't know which coin is affected, so we store the error
+ // with the first coin of the batch.
+ await storeCoinError(errDetail, requestCoinIdxs[0]);
+ return {
+ batchResp: { ev_sigs: [] },
+ coinIdxs: [],
+ };
+ }
+}
+
+async function processPlanchetVerifyAndStoreCoin(
+ wex: WalletExecutionContext,
+ wgContext: WithdrawalGroupStatusInfo,
+ coinIdx: number,
+ resp: ExchangeWithdrawResponse,
+): Promise<void> {
+ const withdrawalGroup = wgContext.wgRecord;
+ logger.trace(`checking and storing planchet idx=${coinIdx}`);
+ const d = await wex.db.runReadOnlyTx(
+ { storeNames: ["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 getDenomInfo(
+ wex,
+ 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 wex.cryptoApi.rsaUnblind({
+ bk: planchet.blindingKey,
+ blindedSig: evSig.blinded_rsa_signature,
+ pk: planchetDenomPub.rsa_public_key,
+ });
+
+ const isValid = await wex.cryptoApi.rsaVerify({
+ hm: planchet.coinPub,
+ pk: planchetDenomPub.rsa_public_key,
+ sig: denomSigRsa.sig,
+ });
+
+ if (!isValid) {
+ await wex.db.runReadWriteTx({ storeNames: ["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 wex.db.runReadWriteTx(
+ { storeNames: ["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(wex, 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.
+ */
+export async function updateWithdrawalDenoms(
+ wex: WalletExecutionContext,
+ exchangeBaseUrl: string,
+): Promise<void> {
+ logger.trace(
+ `updating denominations used for withdrawal for ${exchangeBaseUrl}`,
+ );
+ const exchangeDetails = await wex.db.runReadOnlyTx(
+ { storeNames: ["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(
+ wex,
+ 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 (wex.ws.config.testing.insecureTrustExchange) {
+ valid = true;
+ } else {
+ const res = await wex.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 wex.db.runReadWriteTx(
+ { storeNames: ["denominations"] },
+ async (tx) => {
+ for (let i = 0; i < updatedDenominations.length; i++) {
+ const denom = updatedDenominations[i];
+ await tx.denominations.put(denom);
+ }
+ },
+ );
+ wex.ws.denomInfoCache.clear();
+ 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 processQueryReserve(
+ wex: WalletExecutionContext,
+ withdrawalGroupId: string,
+): Promise<TaskRunResult> {
+ const ctx = new WithdrawTransactionContext(wex, withdrawalGroupId);
+ const withdrawalGroup = await getWithdrawalGroupRecordTx(wex.db, {
+ withdrawalGroupId,
+ });
+ if (!withdrawalGroup) {
+ return TaskRunResult.finished();
+ }
+ 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 wex.http.fetch(reserveUrl.href, {
+ timeout: getReserveRequestTimeout(withdrawalGroup),
+ cancellationToken: wex.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.longpollReturnedPending();
+ } else {
+ throwUnexpectedRequestError(resp, result.talerErrorResponse);
+ }
+ }
+
+ logger.trace(`got reserve status ${j2s(result.response)}`);
+
+ const transitionResult = await ctx.transition({}, async (wg) => {
+ if (!wg) {
+ logger.warn(`withdrawal group ${withdrawalGroupId} not found`);
+ return TransitionResult.stay();
+ }
+ wg.status = WithdrawalGroupStatus.PendingReady;
+ wg.reserveBalanceAmount = Amounts.stringify(result.response.balance);
+ return TransitionResult.transition(wg);
+ });
+
+ if (transitionResult) {
+ return TaskRunResult.progress();
+ } else {
+ return TaskRunResult.backoff();
+ }
+}
+
+/**
+ * Withdrawal context that is kept in-memory.
+ *
+ * Used to store some cached info during a withdrawal operation.
+ */
+interface WithdrawalGroupStatusInfo {
+ numPlanchets: number;
+ planchetsFinished: Set<string>;
+
+ /**
+ * Cached withdrawal group record from the database.
+ */
+ wgRecord: WithdrawalGroupRecord;
+}
+
+async function processWithdrawalGroupAbortingBank(
+ wex: WalletExecutionContext,
+ withdrawalGroup: WithdrawalGroupRecord,
+): Promise<TaskRunResult> {
+ const { withdrawalGroupId } = withdrawalGroup;
+ const ctx = new WithdrawTransactionContext(wex, 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 wex.http.fetch(abortUrl, {
+ method: "POST",
+ body: {},
+ cancellationToken: wex.cancellationToken,
+ });
+ logger.info(`abort response status: ${abortResp.status}`);
+
+ await ctx.transition({}, async (wg) => {
+ if (!wg) {
+ return TransitionResult.stay();
+ }
+ wg.status = WithdrawalGroupStatus.AbortedBank;
+ wg.timestampFinish = timestampPreciseToDb(TalerPreciseTimestamp.now());
+ return TransitionResult.transition(wg);
+ });
+ return TaskRunResult.finished();
+}
+
+async function processWithdrawalGroupPendingKyc(
+ wex: WalletExecutionContext,
+ withdrawalGroup: WithdrawalGroupRecord,
+): Promise<TaskRunResult> {
+ const ctx = new WithdrawTransactionContext(
+ wex,
+ withdrawalGroup.withdrawalGroupId,
+ );
+ 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");
+ logger.info(`long-polling for withdrawal KYC status via ${url.href}`);
+ const kycStatusRes = await wex.http.fetch(url.href, {
+ method: "GET",
+ cancellationToken: wex.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 ctx.transition({}, async (rec) => {
+ if (!rec) {
+ return TransitionResult.stay();
+ }
+ switch (rec.status) {
+ case WithdrawalGroupStatus.PendingKyc: {
+ delete rec.kycPending;
+ delete rec.kycUrl;
+ rec.status = WithdrawalGroupStatus.PendingReady;
+ return TransitionResult.transition(rec);
+ }
+ default:
+ return TransitionResult.stay();
+ }
+ });
+ } 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 ctx.transition({}, async (rec) => {
+ if (!rec) {
+ return TransitionResult.stay();
+ }
+ switch (rec.status) {
+ case WithdrawalGroupStatus.PendingReady: {
+ rec.kycUrl = kycUrl;
+ return TransitionResult.transition(rec);
+ }
+ }
+ return TransitionResult.stay();
+ });
+ }
+ } 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();
+}
+
+/**
+ * Select new denominations for a withdrawal group.
+ * Necessary when denominations expired or got revoked
+ * before the withdrawal could complete.
+ */
+async function redenominateWithdrawal(
+ wex: WalletExecutionContext,
+ withdrawalGroupId: string,
+): Promise<void> {
+ logger.trace(`redenominating withdrawal group ${withdrawalGroupId}`);
+ await wex.db.runReadWriteTx(
+ { storeNames: ["withdrawalGroups", "planchets", "denominations"] },
+ async (tx) => {
+ const wg = await tx.withdrawalGroups.get(withdrawalGroupId);
+ if (!wg) {
+ return;
+ }
+ const currency = Amounts.currencyOf(wg.denomsSel.totalWithdrawCost);
+ const exchangeBaseUrl = wg.exchangeBaseUrl;
+
+ const candidates = await getCandidateWithdrawalDenomsTx(
+ wex,
+ tx,
+ exchangeBaseUrl,
+ currency,
+ );
+
+ const oldSel = wg.denomsSel;
+
+ if (logger.shouldLogTrace()) {
+ logger.trace(`old denom sel: ${j2s(oldSel)}`);
+ }
+
+ let zero = Amount.zeroOfCurrency(currency);
+ let amountRemaining = zero;
+ let prevTotalCoinValue = zero;
+ let prevTotalWithdrawalCost = zero;
+ let prevHasDenomWithAgeRestriction = false;
+ let prevEarliestDepositExpiration = AbsoluteTime.never();
+ let prevDenoms: DenomSelItem[] = [];
+ let coinIndex = 0;
+ for (let i = 0; i < oldSel.selectedDenoms.length; i++) {
+ const sel = wg.denomsSel.selectedDenoms[i];
+ const denom = await tx.denominations.get([
+ exchangeBaseUrl,
+ sel.denomPubHash,
+ ]);
+ if (!denom) {
+ throw Error("denom in use but not not found");
+ }
+ // FIXME: Also check planchet if there was a different error or planchet already withdrawn
+ let denomOkay = isWithdrawableDenom(
+ denom,
+ wex.ws.config.testing.denomselAllowLate,
+ );
+ const numCoins = sel.count - (sel.skip ?? 0);
+ const denomValue = Amount.from(denom.value).mult(numCoins);
+ const denomFeeWithdraw = Amount.from(denom.fees.feeWithdraw).mult(
+ numCoins,
+ );
+ if (denomOkay) {
+ prevTotalCoinValue = prevTotalCoinValue.add(denomValue);
+ prevTotalWithdrawalCost = prevTotalWithdrawalCost.add(
+ denomValue,
+ denomFeeWithdraw,
+ );
+ prevDenoms.push({
+ count: sel.count,
+ denomPubHash: sel.denomPubHash,
+ skip: sel.skip,
+ });
+ prevHasDenomWithAgeRestriction =
+ prevHasDenomWithAgeRestriction || denom.denomPub.age_mask > 0;
+ prevEarliestDepositExpiration = AbsoluteTime.min(
+ prevEarliestDepositExpiration,
+ timestampAbsoluteFromDb(denom.stampExpireDeposit),
+ );
+ } else {
+ amountRemaining = amountRemaining.add(denomValue, denomFeeWithdraw);
+ prevDenoms.push({
+ count: sel.count,
+ denomPubHash: sel.denomPubHash,
+ skip: (sel.skip ?? 0) + numCoins,
+ });
+
+ for (let j = 0; j < sel.count; j++) {
+ const ci = coinIndex + j;
+ const p = await tx.planchets.indexes.byGroupAndIndex.get([
+ withdrawalGroupId,
+ ci,
+ ]);
+ if (!p) {
+ // Maybe planchet wasn't yet generated.
+ // No problem!
+ logger.info(
+ `not aborting planchet #${coinIndex}, planchet not found`,
+ );
+ continue;
+ }
+ logger.info(`aborting planchet #${coinIndex}`);
+ p.planchetStatus = PlanchetStatus.AbortedReplaced;
+ await tx.planchets.put(p);
+ }
+ }
+
+ coinIndex += sel.count;
+ }
+
+ const newSel = selectWithdrawalDenominations(
+ amountRemaining.toJson(),
+ candidates,
+ );
+
+ if (logger.shouldLogTrace()) {
+ logger.trace(`new denom sel: ${j2s(newSel)}`);
+ }
+
+ const mergedSel: DenomSelectionState = {
+ selectedDenoms: [...prevDenoms, ...newSel.selectedDenoms],
+ totalCoinValue: zero
+ .add(prevTotalCoinValue, newSel.totalCoinValue)
+ .toString(),
+ totalWithdrawCost: zero
+ .add(prevTotalWithdrawalCost, newSel.totalWithdrawCost)
+ .toString(),
+ hasDenomWithAgeRestriction:
+ prevHasDenomWithAgeRestriction || newSel.hasDenomWithAgeRestriction,
+ earliestDepositExpiration: AbsoluteTime.toProtocolTimestamp(
+ AbsoluteTime.min(
+ prevEarliestDepositExpiration,
+ AbsoluteTime.fromProtocolTimestamp(
+ newSel.earliestDepositExpiration,
+ ),
+ ),
+ ),
+ };
+ wg.denomsSel = mergedSel;
+ if (logger.shouldLogTrace()) {
+ logger.trace(`merged denom sel: ${j2s(mergedSel)}`);
+ }
+ await tx.withdrawalGroups.put(wg);
+ },
+ );
+}
+
+async function processWithdrawalGroupPendingReady(
+ wex: WalletExecutionContext,
+ withdrawalGroup: WithdrawalGroupRecord,
+): Promise<TaskRunResult> {
+ const { withdrawalGroupId } = withdrawalGroup;
+ const ctx = new WithdrawTransactionContext(wex, withdrawalGroupId);
+
+ const exchangeBaseUrl = withdrawalGroup.exchangeBaseUrl;
+
+ await fetchFreshExchange(wex, withdrawalGroup.exchangeBaseUrl);
+
+ if (withdrawalGroup.denomsSel.selectedDenoms.length === 0) {
+ logger.warn("Finishing empty withdrawal group (no denoms)");
+ await ctx.transition({}, async (wg) => {
+ if (!wg) {
+ return TransitionResult.stay();
+ }
+ wg.status = WithdrawalGroupStatus.Done;
+ wg.timestampFinish = timestampPreciseToDb(TalerPreciseTimestamp.now());
+ return TransitionResult.transition(wg);
+ });
+ return TaskRunResult.finished();
+ }
+
+ const numTotalCoins = withdrawalGroup.denomsSel.selectedDenoms
+ .map((x) => x.count)
+ .reduce((a, b) => a + b);
+
+ const wgContext: WithdrawalGroupStatusInfo = {
+ numPlanchets: numTotalCoins,
+ planchetsFinished: new Set<string>(),
+ wgRecord: withdrawalGroup,
+ };
+
+ await wex.db.runReadOnlyTx({ storeNames: ["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(wex, withdrawalGroup, i);
+ }
+
+ const maxBatchSize = 100;
+
+ for (let i = 0; i < numTotalCoins; i += maxBatchSize) {
+ const resp = await processPlanchetExchangeBatchRequest(wex, 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(
+ wex,
+ wgContext,
+ resp.coinIdxs[j],
+ resp.batchResp.ev_sigs[j],
+ ),
+ );
+ }
+ await Promise.all(work);
+ }
+
+ let redenomRequired = false;
+
+ await wex.db.runReadOnlyTx({ storeNames: ["planchets"] }, async (tx) => {
+ const planchets =
+ await tx.planchets.indexes.byGroup.getAll(withdrawalGroupId);
+ for (const p of planchets) {
+ if (p.planchetStatus !== PlanchetStatus.Pending) {
+ continue;
+ }
+ if (!p.lastError) {
+ continue;
+ }
+ switch (p.lastError.code) {
+ case TalerErrorCode.EXCHANGE_GENERIC_DENOMINATION_EXPIRED:
+ case TalerErrorCode.EXCHANGE_GENERIC_DENOMINATION_REVOKED:
+ redenomRequired = true;
+ return;
+ }
+ }
+ });
+
+ if (redenomRequired) {
+ logger.warn(`withdrawal ${withdrawalGroupId} requires redenomination`);
+ await fetchFreshExchange(wex, exchangeBaseUrl, {
+ forceUpdate: true,
+ });
+ await updateWithdrawalDenoms(wex, exchangeBaseUrl);
+ await redenominateWithdrawal(wex, withdrawalGroupId);
+ return TaskRunResult.backoff();
+ }
+
+ const errorsPerCoin: Record<number, TalerErrorDetail> = {};
+ let numPlanchetErrors = 0;
+ let numActive = 0;
+ let numDone = 0;
+ const maxReportedErrors = 5;
+
+ const res = await ctx.transition(
+ {
+ extraStores: ["coins", "coinAvailability", "planchets"],
+ },
+ async (wg, tx) => {
+ if (!wg) {
+ return TransitionResult.stay();
+ }
+
+ const groupPlanchets =
+ await tx.planchets.indexes.byGroup.getAll(withdrawalGroupId);
+ for (const x of groupPlanchets) {
+ switch (x.planchetStatus) {
+ case PlanchetStatus.KycRequired:
+ case PlanchetStatus.Pending:
+ numActive++;
+ break;
+ case PlanchetStatus.WithdrawalDone:
+ numDone++;
+ break;
+ }
+ if (x.lastError) {
+ numPlanchetErrors++;
+ if (numPlanchetErrors < maxReportedErrors) {
+ errorsPerCoin[x.coinIdx] = x.lastError;
+ }
+ }
+ }
+
+ if (wg.timestampFinish === undefined && numActive === 0) {
+ wg.timestampFinish = timestampPreciseToDb(TalerPreciseTimestamp.now());
+ wg.status = WithdrawalGroupStatus.Done;
+ await makeCoinsVisible(wex, tx, ctx.transactionId);
+ }
+ return TransitionResult.transition(wg);
+ },
+ );
+
+ if (!res) {
+ throw Error("withdrawal group does not exist anymore");
+ }
+
+ wex.ws.notify({
+ type: NotificationType.BalanceChange,
+ hintTransactionId: ctx.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(
+ wex: WalletExecutionContext,
+ withdrawalGroupId: string,
+): Promise<TaskRunResult> {
+ logger.trace("processing withdrawal group", withdrawalGroupId);
+ const withdrawalGroup = await wex.db.runReadOnlyTx(
+ { storeNames: ["withdrawalGroups"] },
+ async (tx) => {
+ return tx.withdrawalGroups.get(withdrawalGroupId);
+ },
+ );
+
+ if (!withdrawalGroup) {
+ throw Error(`withdrawal group ${withdrawalGroupId} not found`);
+ }
+
+ const ctx = new WithdrawTransactionContext(wex, withdrawalGroupId);
+
+ switch (withdrawalGroup.status) {
+ case WithdrawalGroupStatus.PendingRegisteringBank:
+ return await processBankRegisterReserve(wex, withdrawalGroupId);
+ case WithdrawalGroupStatus.PendingQueryingStatus:
+ return processQueryReserve(wex, withdrawalGroupId);
+ case WithdrawalGroupStatus.PendingWaitConfirmBank:
+ return await processReserveBankStatus(wex, withdrawalGroupId);
+ case WithdrawalGroupStatus.PendingAml:
+ // FIXME: Handle this case, withdrawal doesn't support AML yet.
+ return TaskRunResult.backoff();
+ case WithdrawalGroupStatus.PendingKyc:
+ return processWithdrawalGroupPendingKyc(wex, withdrawalGroup);
+ case WithdrawalGroupStatus.PendingReady:
+ // Continue with the actual withdrawal!
+ return await processWithdrawalGroupPendingReady(wex, withdrawalGroup);
+ case WithdrawalGroupStatus.AbortingBank:
+ return await processWithdrawalGroupAbortingBank(wex, withdrawalGroup);
+ case WithdrawalGroupStatus.DialogProposed:
+ return await processWithdrawalGroupDialogProposed(ctx, 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:
+ case WithdrawalGroupStatus.AbortedUserRefused:
+ case WithdrawalGroupStatus.AbortedOtherWallet:
+ // 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(
+ wex: WalletExecutionContext,
+ exchangeBaseUrl: string,
+ instructedAmount: AmountJson,
+ ageRestricted: number | undefined,
+): Promise<ExchangeWithdrawalDetails> {
+ logger.trace("updating exchange");
+ const exchange = await fetchFreshExchange(wex, exchangeBaseUrl, {
+ cancellationToken: wex.cancellationToken,
+ });
+
+ wex.cancellationToken.throwIfCancelled();
+
+ 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(
+ wex,
+ {
+ exchange,
+ instructedAmount,
+ },
+ wex.cancellationToken,
+ );
+
+ logger.trace("updating withdrawal denoms");
+ await updateWithdrawalDenoms(wex, exchangeBaseUrl);
+
+ wex.cancellationToken.throwIfCancelled();
+
+ logger.trace("getting candidate denoms");
+ const candidateDenoms = await getCandidateWithdrawalDenoms(
+ wex,
+ exchangeBaseUrl,
+ instructedAmount.currency,
+ );
+
+ wex.cancellationToken.throwIfCancelled();
+
+ logger.trace("selecting withdrawal denoms");
+ // FIXME: Why not in a transaction?
+ const selectedDenoms = selectWithdrawalDenominations(
+ instructedAmount,
+ candidateDenoms,
+ wex.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 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: selectedDenoms.earliestDepositExpiration,
+ exchangePaytoUris: paytoUris,
+ exchangeWireAccounts,
+ exchangeCreditAccountDetails: withdrawalAccountsList,
+ exchangeVersion: exchange.protocolVersionRange || "unknown",
+ selectedDenoms,
+ 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: selectedDenoms.hasDenomWithAgeRestriction
+ ? AGE_MASK_GROUPS
+ : undefined,
+ scopeInfo: exchange.scopeInfo,
+ };
+ return ret;
+}
+
+export interface GetWithdrawalDetailsForUriOpts {
+ restrictAge?: number;
+ notifyChangeFromPendingTimeoutMs?: 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 ephemerally added
+ * to the wallet's list of known exchanges.
+ */
+export async function getWithdrawalDetailsForUri(
+ wex: WalletExecutionContext,
+ talerWithdrawUri: string,
+ opts: GetWithdrawalDetailsForUriOpts = {},
+): Promise<WithdrawUriInfoResponse> {
+ logger.trace(`getting withdrawal details for URI ${talerWithdrawUri}`);
+ const info = await getBankWithdrawalInfo(wex.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(wex, 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(wex);
+ const possibleExchanges = listExchangesResp.exchanges.filter((x) => {
+ return (
+ x.currency === currency &&
+ (x.exchangeUpdateStatus === ExchangeUpdateStatus.Ready ||
+ x.exchangeUpdateStatus === ExchangeUpdateStatus.ReadyUpdate)
+ );
+ });
+
+ 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(
+ { storeNames: ["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(
+ wex: WalletExecutionContext,
+ withdrawalGroupId: string,
+): Promise<void> {
+ const withdrawalGroup = await wex.db.runReadOnlyTx(
+ { storeNames: ["withdrawalGroups"] },
+ async (tx) => {
+ return await tx.withdrawalGroups.get(withdrawalGroupId);
+ },
+ );
+ const ctx = new WithdrawTransactionContext(wex, withdrawalGroupId);
+ switch (withdrawalGroup?.status) {
+ case WithdrawalGroupStatus.PendingWaitConfirmBank:
+ case WithdrawalGroupStatus.PendingRegisteringBank:
+ break;
+ default:
+ return;
+ }
+ if (
+ withdrawalGroup.wgInfo.withdrawalType != WithdrawalRecordType.BankIntegrated
+ ) {
+ throw Error("expecting withdrawal 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 wex.http.fetch(bankStatusUrl, {
+ method: "POST",
+ body: reqBody,
+ timeout: getReserveRequestTimeout(withdrawalGroup),
+ cancellationToken: wex.cancellationToken,
+ });
+ const status = await readSuccessResponseJsonOrThrow(
+ httpResp,
+ codeForBankWithdrawalOperationPostResponse(),
+ );
+
+ await ctx.transition({}, async (r) => {
+ if (!r) {
+ return TransitionResult.stay();
+ }
+ switch (r.status) {
+ case WithdrawalGroupStatus.PendingRegisteringBank:
+ case WithdrawalGroupStatus.PendingWaitConfirmBank:
+ break;
+ default:
+ return TransitionResult.stay();
+ }
+ if (r.wgInfo.withdrawalType !== WithdrawalRecordType.BankIntegrated) {
+ throw Error("invariant failed");
+ }
+ r.wgInfo.bankInfo.timestampReserveInfoPosted = timestampPreciseToDb(
+ AbsoluteTime.toPreciseTimestamp(AbsoluteTime.now()),
+ );
+ r.status = WithdrawalGroupStatus.PendingWaitConfirmBank;
+ r.wgInfo.bankInfo.confirmUrl = status.confirm_transfer_url;
+ return TransitionResult.transition(r);
+ });
+}
+
+async function transitionBankAborted(
+ ctx: WithdrawTransactionContext,
+): Promise<TaskRunResult> {
+ logger.info("bank aborted the withdrawal");
+ await ctx.transition({}, async (r) => {
+ if (!r) {
+ return TransitionResult.stay();
+ }
+ switch (r.status) {
+ case WithdrawalGroupStatus.PendingRegisteringBank:
+ case WithdrawalGroupStatus.PendingWaitConfirmBank:
+ break;
+ default:
+ return TransitionResult.stay();
+ }
+ if (r.wgInfo.withdrawalType !== WithdrawalRecordType.BankIntegrated) {
+ throw Error("invariant failed");
+ }
+ const now = AbsoluteTime.toPreciseTimestamp(AbsoluteTime.now());
+ r.wgInfo.bankInfo.timestampBankConfirmed = timestampPreciseToDb(now);
+ r.status = WithdrawalGroupStatus.FailedBankAborted;
+ return TransitionResult.transition(r);
+ });
+ return TaskRunResult.progress();
+}
+
+async function processBankRegisterReserve(
+ wex: WalletExecutionContext,
+ withdrawalGroupId: string,
+): Promise<TaskRunResult> {
+ const ctx = new WithdrawTransactionContext(wex, withdrawalGroupId);
+ const withdrawalGroup = await getWithdrawalGroupRecordTx(wex.db, {
+ withdrawalGroupId,
+ });
+ if (!withdrawalGroup) {
+ return TaskRunResult.finished();
+ }
+
+ if (
+ withdrawalGroup.wgInfo.withdrawalType != WithdrawalRecordType.BankIntegrated
+ ) {
+ throw Error("wrong withdrawal record type");
+ }
+ const bankInfo = withdrawalGroup.wgInfo.bankInfo;
+ if (!bankInfo) {
+ throw Error("no bank info in bank-integrated withdrawal");
+ }
+
+ const uriResult = parseWithdrawUri(bankInfo.talerWithdrawUri);
+ if (!uriResult) {
+ throw Error(`can't parse withdrawal URL ${bankInfo.talerWithdrawUri}`);
+ }
+ const url = new URL(
+ `withdrawal-operation/${uriResult.withdrawalOperationId}`,
+ uriResult.bankIntegrationApiBaseUrl,
+ );
+
+ const statusResp = await wex.http.fetch(url.href, {
+ timeout: getReserveRequestTimeout(withdrawalGroup),
+ cancellationToken: wex.cancellationToken,
+ });
+
+ const status = await readSuccessResponseJsonOrThrow(
+ statusResp,
+ codecForWithdrawOperationStatusResponse(),
+ );
+
+ if (status.aborted) {
+ return transitionBankAborted(ctx);
+ }
+
+ // FIXME: Put confirm transfer URL in the DB!
+
+ await registerReserveWithBank(wex, withdrawalGroupId);
+ return TaskRunResult.progress();
+}
+
+async function processReserveBankStatus(
+ wex: WalletExecutionContext,
+ withdrawalGroupId: string,
+): Promise<TaskRunResult> {
+ const withdrawalGroup = await getWithdrawalGroupRecordTx(wex.db, {
+ withdrawalGroupId,
+ });
+
+ if (!withdrawalGroup) {
+ return TaskRunResult.finished();
+ }
+
+ const ctx = new WithdrawTransactionContext(wex, withdrawalGroupId);
+
+ if (
+ withdrawalGroup.wgInfo.withdrawalType != WithdrawalRecordType.BankIntegrated
+ ) {
+ throw Error("wrong withdrawal record type");
+ }
+ const bankInfo = withdrawalGroup.wgInfo.bankInfo;
+ if (!bankInfo) {
+ throw Error("no bank info in bank-integrated withdrawal");
+ }
+
+ const uriResult = parseWithdrawUri(bankInfo.talerWithdrawUri);
+ if (!uriResult) {
+ throw Error(`can't parse withdrawal URL ${bankInfo.talerWithdrawUri}`);
+ }
+ const bankStatusUrl = new URL(
+ `withdrawal-operation/${uriResult.withdrawalOperationId}`,
+ uriResult.bankIntegrationApiBaseUrl,
+ );
+ bankStatusUrl.searchParams.set("long_poll_ms", "30000");
+
+ logger.info(`long-polling for withdrawal operation at ${bankStatusUrl.href}`);
+ const statusResp = await wex.http.fetch(bankStatusUrl.href, {
+ timeout: getReserveRequestTimeout(withdrawalGroup),
+ cancellationToken: wex.cancellationToken,
+ });
+ logger.info(
+ `long-polling for withdrawal operation returned status ${statusResp.status}`,
+ );
+
+ const status = await readSuccessResponseJsonOrThrow(
+ statusResp,
+ codecForWithdrawOperationStatusResponse(),
+ );
+
+ if (logger.shouldLogTrace()) {
+ logger.trace(`response body: ${j2s(status)}`);
+ }
+
+ if (status.aborted) {
+ return transitionBankAborted(ctx);
+ }
+
+ if (!status.transfer_done) {
+ return TaskRunResult.longpollReturnedPending();
+ }
+
+ const transitionInfo = await ctx.transition({}, async (r) => {
+ if (!r) {
+ return TransitionResult.stay();
+ }
+ // Re-check reserve status within transaction
+ switch (r.status) {
+ case WithdrawalGroupStatus.PendingWaitConfirmBank:
+ break;
+ default:
+ return TransitionResult.stay();
+ }
+ if (r.wgInfo.withdrawalType !== WithdrawalRecordType.BankIntegrated) {
+ throw Error("invariant failed");
+ }
+ 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;
+ return TransitionResult.transition(r);
+ } else {
+ return TransitionResult.stay();
+ }
+ });
+
+ 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(
+ wex: WalletExecutionContext,
+ args: {
+ reserveStatus: WithdrawalGroupStatus;
+ amount: AmountJson;
+ exchangeBaseUrl: string;
+ forcedWithdrawalGroupId?: string;
+ forcedDenomSel?: ForcedDenomSel;
+ reserveKeyPair?: EddsaKeypair;
+ restrictAge?: number;
+ wgInfo: WgInfo;
+ },
+): Promise<PrepareCreateWithdrawalGroupResult> {
+ const reserveKeyPair =
+ args.reserveKeyPair ?? (await wex.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 wex.db.runReadOnlyTx(
+ { storeNames: ["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(wex, canonExchange);
+ const denoms = await getCandidateWithdrawalDenoms(
+ wex,
+ 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,
+ wex.ws.config.testing.denomselAllowLate,
+ );
+ } else {
+ initialDenomSel = selectWithdrawalDenominations(
+ amount,
+ denoms,
+ wex.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(wex, 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(
+ wex: WalletExecutionContext,
+ 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(
+ wex,
+ tx,
+ prep.withdrawalGroup.exchangeBaseUrl,
+ );
+
+ const ctx = new WithdrawTransactionContext(
+ wex,
+ withdrawalGroup.withdrawalGroupId,
+ );
+
+ wex.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(
+ wex: WalletExecutionContext,
+ args: {
+ reserveStatus: WithdrawalGroupStatus;
+ amount: AmountJson;
+ exchangeBaseUrl: string;
+ forcedWithdrawalGroupId?: string;
+ forcedDenomSel?: ForcedDenomSel;
+ reserveKeyPair?: EddsaKeypair;
+ restrictAge?: number;
+ wgInfo: WgInfo;
+ },
+): Promise<WithdrawalGroupRecord> {
+ const prep = await internalPrepareCreateWithdrawalGroup(wex, args);
+ const transactionId = constructTransactionIdentifier({
+ tag: TransactionType.Withdrawal,
+ withdrawalGroupId: prep.withdrawalGroup.withdrawalGroupId,
+ });
+ const ctx = new WithdrawTransactionContext(
+ wex,
+ prep.withdrawalGroup.withdrawalGroupId,
+ );
+ const res = await wex.db.runReadWriteTx(
+ {
+ storeNames: [
+ "withdrawalGroups",
+ "reserves",
+ "exchanges",
+ "exchangeDetails",
+ "transactions",
+ "operationRetries",
+ ],
+ },
+ async (tx) => {
+ const res = await internalPerformCreateWithdrawalGroup(wex, tx, prep);
+ await updateWithdrawalTransaction(ctx, tx);
+ return res;
+ },
+ );
+ if (res.exchangeNotif) {
+ wex.ws.notify(res.exchangeNotif);
+ }
+ notifyTransition(wex, transactionId, res.transitionInfo);
+ return res.withdrawalGroup;
+}
+
+export async function prepareBankIntegratedWithdrawal(
+ wex: WalletExecutionContext,
+ req: {
+ talerWithdrawUri: string;
+ selectedExchange: string;
+ forcedDenomSel?: ForcedDenomSel;
+ restrictAge?: number;
+ },
+): Promise<PrepareBankIntegratedWithdrawalResponse> {
+ const existingWithdrawalGroup = await wex.db.runReadOnlyTx(
+ { storeNames: ["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 {
+ transactionId: constructTransactionIdentifier({
+ tag: TransactionType.Withdrawal,
+ withdrawalGroupId: existingWithdrawalGroup.withdrawalGroupId,
+ }),
+ };
+ }
+
+ const selectedExchange = canonicalizeBaseUrl(req.selectedExchange);
+ const exchange = await fetchFreshExchange(wex, selectedExchange);
+
+ const withdrawInfo = await getBankWithdrawalInfo(
+ wex.http,
+ req.talerWithdrawUri,
+ );
+ const exchangePaytoUri = await getExchangePaytoUri(
+ wex,
+ selectedExchange,
+ withdrawInfo.wireTypes,
+ );
+
+ const withdrawalAccountList = await fetchWithdrawalAccountInfo(
+ wex,
+ {
+ exchange,
+ instructedAmount: withdrawInfo.amount,
+ },
+ wex.cancellationToken,
+ );
+
+ const withdrawalGroup = await internalCreateWithdrawalGroup(wex, {
+ 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.DialogProposed,
+ });
+
+ const withdrawalGroupId = withdrawalGroup.withdrawalGroupId;
+
+ const ctx = new WithdrawTransactionContext(wex, withdrawalGroupId);
+
+ wex.taskScheduler.startShepherdTask(ctx.taskId);
+
+ return {
+ transactionId: ctx.transactionId,
+ };
+}
+
+export async function confirmWithdrawal(
+ wex: WalletExecutionContext,
+ transactionId: string,
+): Promise<void> {
+ const parsedTx = parseTransactionIdentifier(transactionId);
+ if (parsedTx?.tag !== TransactionType.Withdrawal) {
+ throw Error("invalid withdrawal transaction ID");
+ }
+ const withdrawalGroup = await wex.db.runReadOnlyTx(
+ { storeNames: ["withdrawalGroups"] },
+ async (tx) => {
+ return await tx.withdrawalGroups.get(parsedTx.withdrawalGroupId);
+ },
+ );
+
+ if (!withdrawalGroup) {
+ throw Error("withdrawal group not found");
+ }
+
+ const ctx = new WithdrawTransactionContext(
+ wex,
+ withdrawalGroup.withdrawalGroupId,
+ );
+ ctx.transition({}, async (rec) => {
+ if (!rec) {
+ return TransitionResult.stay();
+ }
+ switch (rec.status) {
+ case WithdrawalGroupStatus.DialogProposed: {
+ rec.status = WithdrawalGroupStatus.PendingRegisteringBank;
+ return TransitionResult.transition(rec);
+ }
+ default:
+ throw Error("unable to confirm withdrawal in current state");
+ }
+ });
+
+ await wex.taskScheduler.resetTaskRetries(ctx.taskId);
+ wex.taskScheduler.startShepherdTask(ctx.taskId);
+}
+
+/**
+ * Accept a bank-integrated withdrawal.
+ *
+ * Before returning, the wallet tries to register the reserve with the bank.
+ *
+ * Thus after this call returns, the withdrawal operation can be confirmed
+ * with the bank.
+ *
+ * @deprecated in favor of prepare/accept
+ */
+export async function acceptWithdrawalFromUri(
+ wex: WalletExecutionContext,
+ 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 wex.db.runReadOnlyTx(
+ { storeNames: ["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,
+ }),
+ };
+ }
+
+ const exchange = await fetchFreshExchange(wex, selectedExchange);
+ const withdrawInfo = await getBankWithdrawalInfo(
+ wex.http,
+ req.talerWithdrawUri,
+ );
+ const exchangePaytoUri = await getExchangePaytoUri(
+ wex,
+ selectedExchange,
+ withdrawInfo.wireTypes,
+ );
+
+ const withdrawalAccountList = await fetchWithdrawalAccountInfo(
+ wex,
+ {
+ exchange,
+ instructedAmount: withdrawInfo.amount,
+ },
+ CancellationToken.CONTINUE,
+ );
+
+ const withdrawalGroup = await internalCreateWithdrawalGroup(wex, {
+ 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(wex, withdrawalGroupId);
+
+ wex.ws.notify({
+ type: NotificationType.BalanceChange,
+ hintTransactionId: ctx.transactionId,
+ });
+
+ await waitWithdrawalRegistered(wex, ctx);
+
+ wex.taskScheduler.startShepherdTask(ctx.taskId);
+
+ return {
+ reservePub: withdrawalGroup.reservePub,
+ confirmTransferUrl: withdrawInfo.confirmTransferUrl,
+ transactionId: ctx.transactionId,
+ };
+}
+
+async function internalWaitWithdrawalRegistered(
+ wex: WalletExecutionContext,
+ ctx: WithdrawTransactionContext,
+ withdrawalNotifFlag: AsyncFlag,
+): Promise<void> {
+ while (true) {
+ const { withdrawalRec, retryRec } = await wex.db.runReadOnlyTx(
+ { storeNames: ["withdrawalGroups", "operationRetries"] },
+ async (tx) => {
+ return {
+ withdrawalRec: await tx.withdrawalGroups.get(ctx.withdrawalGroupId),
+ retryRec: await tx.operationRetries.get(ctx.taskId),
+ };
+ },
+ );
+
+ if (!withdrawalRec) {
+ throw Error("withdrawal not found anymore");
+ }
+
+ switch (withdrawalRec.status) {
+ case WithdrawalGroupStatus.FailedBankAborted:
+ throw TalerError.fromDetail(
+ TalerErrorCode.WALLET_WITHDRAWAL_OPERATION_ABORTED_BY_BANK,
+ {},
+ );
+ case WithdrawalGroupStatus.PendingKyc:
+ case WithdrawalGroupStatus.PendingAml:
+ case WithdrawalGroupStatus.PendingQueryingStatus:
+ case WithdrawalGroupStatus.PendingReady:
+ case WithdrawalGroupStatus.Done:
+ case WithdrawalGroupStatus.PendingWaitConfirmBank:
+ return;
+ case WithdrawalGroupStatus.PendingRegisteringBank:
+ break;
+ default: {
+ if (retryRec) {
+ if (retryRec.lastError) {
+ throw TalerError.fromUncheckedDetail(retryRec.lastError);
+ } else {
+ throw Error("withdrawal unexpectedly pending");
+ }
+ }
+ }
+ }
+
+ await withdrawalNotifFlag.wait();
+ withdrawalNotifFlag.reset();
+ }
+}
+
+async function waitWithdrawalRegistered(
+ wex: WalletExecutionContext,
+ ctx: WithdrawTransactionContext,
+): Promise<void> {
+ // FIXME: Doesn't support cancellation yet
+ // FIXME: We should use Symbol.dispose magic here for cleanup!
+
+ const withdrawalNotifFlag = new AsyncFlag();
+ // Raise exchangeNotifFlag whenever we get a notification
+ // about our exchange.
+ const cancelNotif = wex.ws.addNotificationListener((notif) => {
+ if (
+ notif.type === NotificationType.TransactionStateTransition &&
+ notif.transactionId === ctx.transactionId
+ ) {
+ logger.info(`raising update notification: ${j2s(notif)}`);
+ withdrawalNotifFlag.raise();
+ }
+ });
+
+ try {
+ const res = await internalWaitWithdrawalRegistered(
+ wex,
+ ctx,
+ withdrawalNotifFlag,
+ );
+ logger.info("done waiting for ready exchange");
+ return res;
+ } finally {
+ cancelNotif();
+ }
+}
+
+async function fetchAccount(
+ wex: WalletExecutionContext,
+ instructedAmount: AmountJson,
+ acct: ExchangeWireAccount,
+ reservePub: string | undefined,
+ cancellationToken: CancellationToken,
+): 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 wex.http.fetch(reqUrl.href, {
+ cancellationToken,
+ });
+ 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 wex.http.fetch(configUrl.href, {
+ cancellationToken,
+ });
+ 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,
+ bankLabel: acct.bank_label,
+ priority: acct.priority,
+ 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(
+ wex: WalletExecutionContext,
+ req: {
+ exchange: ReadyExchangeSummary;
+ instructedAmount: AmountJson;
+ reservePub?: string;
+ },
+ cancellationToken: CancellationToken,
+): Promise<WithdrawalExchangeAccountDetails[]> {
+ const { exchange } = req;
+ const withdrawalAccounts: WithdrawalExchangeAccountDetails[] = [];
+ for (let acct of exchange.wireInfo.accounts) {
+ const acctInfo = await fetchAccount(
+ wex,
+ req.instructedAmount,
+ acct,
+ req.reservePub,
+ cancellationToken,
+ );
+ withdrawalAccounts.push(acctInfo);
+ }
+ withdrawalAccounts.sort((x1, x2) => {
+ // Accounts without explicit priority have prio 0.
+ const n1 = x1.priority ?? 0;
+ const n2 = x2.priority ?? 0;
+ return Math.sign(n2 - n1);
+ });
+ 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(
+ wex: WalletExecutionContext,
+ req: {
+ exchangeBaseUrl: string;
+ amount: AmountLike;
+ restrictAge?: number;
+ forcedDenomSel?: ForcedDenomSel;
+ forceReservePriv?: EddsaPrivateKeyString;
+ },
+): Promise<AcceptManualWithdrawalResult> {
+ const { exchangeBaseUrl } = req;
+ const amount = Amounts.parseOrThrow(req.amount);
+ const exchange = await fetchFreshExchange(wex, exchangeBaseUrl);
+
+ if (exchange.currency != amount.currency) {
+ throw Error(
+ "manual withdrawal with conversion from foreign currency is not yet supported",
+ );
+ }
+
+ let reserveKeyPair: EddsaKeypair;
+ if (req.forceReservePriv) {
+ const pubResp = await wex.cryptoApi.eddsaGetPublic({
+ priv: req.forceReservePriv,
+ });
+
+ reserveKeyPair = {
+ priv: req.forceReservePriv,
+ pub: pubResp.pub,
+ };
+ } else {
+ reserveKeyPair = await wex.cryptoApi.createEddsaKeypair({});
+ }
+
+ const withdrawalAccountsList = await fetchWithdrawalAccountInfo(
+ wex,
+ {
+ exchange,
+ instructedAmount: amount,
+ reservePub: reserveKeyPair.pub,
+ },
+ CancellationToken.CONTINUE,
+ );
+
+ const withdrawalGroup = await internalCreateWithdrawalGroup(wex, {
+ 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 ctx = new WithdrawTransactionContext(
+ wex,
+ withdrawalGroup.withdrawalGroupId,
+ );
+
+ const exchangePaytoUris = await wex.db.runReadOnlyTx(
+ { storeNames: ["withdrawalGroups", "exchanges", "exchangeDetails"] },
+ async (tx) => {
+ return await getFundingPaytoUris(tx, withdrawalGroup.withdrawalGroupId);
+ },
+ );
+
+ wex.ws.notify({
+ type: NotificationType.BalanceChange,
+ hintTransactionId: ctx.transactionId,
+ });
+
+ wex.taskScheduler.startShepherdTask(ctx.taskId);
+
+ return {
+ reservePub: withdrawalGroup.reservePub,
+ exchangePaytoUris: exchangePaytoUris,
+ withdrawalAccountsList: withdrawalAccountsList,
+ transactionId: ctx.transactionId,
+ };
+}
+
+/**
+ * Wait until a refresh operation is final.
+ */
+export async function waitWithdrawalFinal(
+ wex: WalletExecutionContext,
+ withdrawalGroupId: string,
+): Promise<void> {
+ const ctx = new WithdrawTransactionContext(wex, withdrawalGroupId);
+ wex.taskScheduler.startShepherdTask(ctx.taskId);
+
+ // FIXME: Clean up using the new JS "using" / Symbol.dispose syntax.
+ const withdrawalNotifFlag = new AsyncFlag();
+ // Raise purchaseNotifFlag whenever we get a notification
+ // about our refresh.
+ const cancelNotif = wex.ws.addNotificationListener((notif) => {
+ if (
+ notif.type === NotificationType.TransactionStateTransition &&
+ notif.transactionId === ctx.transactionId
+ ) {
+ withdrawalNotifFlag.raise();
+ }
+ });
+ const unregisterOnCancelled = wex.cancellationToken.onCancelled(() => {
+ cancelNotif();
+ withdrawalNotifFlag.raise();
+ });
+
+ try {
+ await internalWaitWithdrawalFinal(ctx, withdrawalNotifFlag);
+ } catch (e) {
+ unregisterOnCancelled();
+ cancelNotif();
+ }
+}
+
+async function internalWaitWithdrawalFinal(
+ ctx: WithdrawTransactionContext,
+ flag: AsyncFlag,
+): Promise<void> {
+ while (true) {
+ if (ctx.wex.cancellationToken.isCancelled) {
+ throw Error("cancelled");
+ }
+
+ // Check if refresh is final
+ const res = await ctx.wex.db.runReadOnlyTx(
+ { storeNames: ["withdrawalGroups", "operationRetries"] },
+ async (tx) => {
+ return {
+ wg: await tx.withdrawalGroups.get(ctx.withdrawalGroupId),
+ };
+ },
+ );
+ const { wg } = res;
+ if (!wg) {
+ // Must've been deleted, we consider that final.
+ return;
+ }
+ switch (wg.status) {
+ case WithdrawalGroupStatus.AbortedBank:
+ case WithdrawalGroupStatus.AbortedExchange:
+ case WithdrawalGroupStatus.Done:
+ case WithdrawalGroupStatus.FailedAbortingBank:
+ case WithdrawalGroupStatus.FailedBankAborted:
+ // Transaction is final
+ return;
+ }
+
+ // Wait for the next transition
+ await flag.wait();
+ flag.reset();
+ }
+}
+
+export async function getWithdrawalDetailsForAmount(
+ wex: WalletExecutionContext,
+ cts: CancellationToken.Source,
+ req: GetWithdrawalDetailsForAmountRequest,
+): Promise<WithdrawalDetailsForAmount> {
+ const clientCancelKey = req.clientCancellationId
+ ? `ccid:getWithdrawalDetailsForAmount:${req.clientCancellationId}`
+ : undefined;
+ if (clientCancelKey) {
+ const prevCts = wex.ws.clientCancellationMap.get(clientCancelKey);
+ if (prevCts) {
+ wex.oc.observe({
+ type: ObservabilityEventType.Message,
+ contents: `Cancelling previous key ${clientCancelKey}`,
+ });
+ prevCts.cancel();
+ } else {
+ wex.oc.observe({
+ type: ObservabilityEventType.Message,
+ contents: `No previous key ${clientCancelKey}`,
+ });
+ }
+ wex.oc.observe({
+ type: ObservabilityEventType.Message,
+ contents: `Setting clientCancelKey ${clientCancelKey} to ${cts}`,
+ });
+ wex.ws.clientCancellationMap.set(clientCancelKey, cts);
+ }
+ try {
+ return await internalGetWithdrawalDetailsForAmount(wex, req);
+ } finally {
+ wex.oc.observe({
+ type: ObservabilityEventType.Message,
+ contents: `Deleting clientCancelKey ${clientCancelKey} to ${cts}`,
+ });
+ if (clientCancelKey && !cts.token.isCancelled) {
+ wex.ws.clientCancellationMap.delete(clientCancelKey);
+ }
+ }
+}
+
+async function internalGetWithdrawalDetailsForAmount(
+ wex: WalletExecutionContext,
+ req: GetWithdrawalDetailsForAmountRequest,
+): Promise<WithdrawalDetailsForAmount> {
+ const wi = await getExchangeWithdrawalInfo(
+ wex,
+ req.exchangeBaseUrl,
+ Amounts.parseOrThrow(req.amount),
+ req.restrictAge,
+ );
+ let numCoins = 0;
+ for (const x of wi.selectedDenoms.selectedDenoms) {
+ numCoins += x.count;
+ }
+ const resp: WithdrawalDetailsForAmount = {
+ amountRaw: req.amount,
+ amountEffective: Amounts.stringify(wi.selectedDenoms.totalCoinValue),
+ paytoUris: wi.exchangePaytoUris,
+ tosAccepted: wi.termsOfServiceAccepted,
+ ageRestrictionOptions: wi.ageRestrictionOptions,
+ withdrawalAccountsList: wi.exchangeCreditAccountDetails,
+ numCoins,
+ scopeInfo: wi.scopeInfo,
+ };
+ return resp;
+}