summaryrefslogtreecommitdiff
path: root/packages/taler-wallet-core/src/operations/withdraw.ts
diff options
context:
space:
mode:
Diffstat (limited to 'packages/taler-wallet-core/src/operations/withdraw.ts')
-rw-r--r--packages/taler-wallet-core/src/operations/withdraw.ts2754
1 files changed, 0 insertions, 2754 deletions
diff --git a/packages/taler-wallet-core/src/operations/withdraw.ts b/packages/taler-wallet-core/src/operations/withdraw.ts
deleted file mode 100644
index 542868de0..000000000
--- a/packages/taler-wallet-core/src/operations/withdraw.ts
+++ /dev/null
@@ -1,2754 +0,0 @@
-/*
- This file is part of GNU Taler
- (C) 2019-2024 Taler Systems SA
-
- GNU Taler is free software; you can redistribute it and/or modify it under the
- terms of the GNU General Public License as published by the Free Software
- Foundation; either version 3, or (at your option) any later version.
-
- GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
- WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
- A PARTICULAR PURPOSE. See the GNU General Public License for more details.
-
- You should have received a copy of the GNU General Public License along with
- GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
- */
-
-/**
- * Imports.
- */
-import {
- AbsoluteTime,
- AcceptManualWithdrawalResult,
- AcceptWithdrawalResponse,
- AgeRestriction,
- AmountJson,
- AmountLike,
- AmountString,
- Amounts,
- BankWithdrawDetails,
- CancellationToken,
- CoinStatus,
- CurrencySpecification,
- DenomKeyType,
- DenomSelectionState,
- Duration,
- ExchangeBatchWithdrawRequest,
- ExchangeUpdateStatus,
- ExchangeWireAccount,
- ExchangeWithdrawBatchResponse,
- ExchangeWithdrawRequest,
- ExchangeWithdrawResponse,
- ExchangeWithdrawalDetails,
- ForcedDenomSel,
- HttpStatusCode,
- LibtoolVersion,
- Logger,
- NotificationType,
- TalerBankIntegrationHttpClient,
- TalerError,
- TalerErrorCode,
- TalerErrorDetail,
- TalerPreciseTimestamp,
- TalerProtocolTimestamp,
- TransactionAction,
- TransactionIdStr,
- TransactionMajorState,
- TransactionMinorState,
- TransactionState,
- TransactionType,
- URL,
- UnblindedSignature,
- WalletNotification,
- WithdrawUriInfoResponse,
- WithdrawalExchangeAccountDetails,
- addPaytoQueryParams,
- canonicalizeBaseUrl,
- codecForAny,
- codecForCashinConversionResponse,
- codecForConversionBankConfig,
- codecForExchangeWithdrawBatchResponse,
- codecForReserveStatus,
- codecForWalletKycUuid,
- codecForWithdrawOperationStatusResponse,
- encodeCrock,
- getErrorDetailFromException,
- getRandomBytes,
- j2s,
- makeErrorDetail,
- parseWithdrawUri,
-} from "@gnu-taler/taler-util";
-import {
- HttpRequestLibrary,
- HttpResponse,
- readSuccessResponseJsonOrErrorCode,
- readSuccessResponseJsonOrThrow,
- throwUnexpectedRequestError,
-} from "@gnu-taler/taler-util/http";
-import { EddsaKeypair } from "../crypto/cryptoImplementation.js";
-import {
- CoinRecord,
- CoinSourceType,
- DenominationRecord,
- DenominationVerificationStatus,
- KycPendingInfo,
- PlanchetRecord,
- PlanchetStatus,
- WalletStoresV1,
- WgInfo,
- WithdrawalGroupRecord,
- WithdrawalGroupStatus,
- WithdrawalRecordType,
-} from "../db.js";
-import {
- WalletDbReadOnlyTransaction,
- WalletDbReadWriteTransaction,
- isWithdrawableDenom,
- timestampPreciseToDb,
-} from "../index.js";
-import { InternalWalletState } from "../internal-wallet-state.js";
-import {
- TaskRunResult,
- TaskRunResultType,
- TombstoneTag,
- TransactionContext,
- constructTaskIdentifier,
- makeCoinAvailable,
- makeCoinsVisible,
-} from "../operations/common.js";
-import { PendingTaskType, TaskId } from "../pending-types.js";
-import { assertUnreachable } from "../util/assertUnreachable.js";
-import {
- selectForcedWithdrawalDenominations,
- selectWithdrawalDenominations,
-} from "../util/coinSelection.js";
-import { checkDbInvariant, checkLogicInvariant } from "../util/invariants.js";
-import { DbAccess } from "../util/query.js";
-import {
- WALLET_BANK_INTEGRATION_PROTOCOL_VERSION,
- WALLET_EXCHANGE_PROTOCOL_VERSION,
-} from "../versions.js";
-import {
- ReadyExchangeSummary,
- fetchFreshExchange,
- getExchangePaytoUri,
- getExchangeWireDetailsInTx,
- listExchanges,
- markExchangeUsed,
-} from "./exchanges.js";
-import {
- TransitionInfo,
- constructTransactionIdentifier,
- notifyTransition,
-} from "./transactions.js";
-
-/**
- * Logger for this file.
- */
-const logger = new Logger("operations/withdraw.ts");
-
-export class WithdrawTransactionContext implements TransactionContext {
- readonly transactionId: TransactionIdStr;
- readonly taskId: TaskId;
-
- constructor(
- public ws: InternalWalletState,
- public withdrawalGroupId: string,
- ) {
- this.transactionId = constructTransactionIdentifier({
- tag: TransactionType.Withdrawal,
- withdrawalGroupId,
- });
- this.taskId = constructTaskIdentifier({
- tag: PendingTaskType.Withdraw,
- withdrawalGroupId,
- });
- }
-
- async deleteTransaction(): Promise<void> {
- const { ws, withdrawalGroupId } = this;
- await ws.db.runReadWriteTx(
- ["withdrawalGroups", "tombstones"],
- async (tx) => {
- const withdrawalGroupRecord =
- await tx.withdrawalGroups.get(withdrawalGroupId);
- if (withdrawalGroupRecord) {
- await tx.withdrawalGroups.delete(withdrawalGroupId);
- await tx.tombstones.put({
- id: TombstoneTag.DeleteWithdrawalGroup + ":" + withdrawalGroupId,
- });
- return;
- }
- },
- );
- }
-
- async suspendTransaction(): Promise<void> {
- const { ws, withdrawalGroupId, transactionId, taskId } = this;
- const transitionInfo = await ws.db.runReadWriteTx(
- ["withdrawalGroups"],
- async (tx) => {
- const wg = await tx.withdrawalGroups.get(withdrawalGroupId);
- if (!wg) {
- logger.warn(`withdrawal group ${withdrawalGroupId} not found`);
- return;
- }
- let newStatus: WithdrawalGroupStatus | undefined = undefined;
- switch (wg.status) {
- case WithdrawalGroupStatus.PendingReady:
- newStatus = WithdrawalGroupStatus.SuspendedReady;
- break;
- case WithdrawalGroupStatus.AbortingBank:
- newStatus = WithdrawalGroupStatus.SuspendedAbortingBank;
- break;
- case WithdrawalGroupStatus.PendingWaitConfirmBank:
- newStatus = WithdrawalGroupStatus.SuspendedWaitConfirmBank;
- break;
- case WithdrawalGroupStatus.PendingRegisteringBank:
- newStatus = WithdrawalGroupStatus.SuspendedRegisteringBank;
- break;
- case WithdrawalGroupStatus.PendingQueryingStatus:
- newStatus = WithdrawalGroupStatus.SuspendedQueryingStatus;
- break;
- case WithdrawalGroupStatus.PendingKyc:
- newStatus = WithdrawalGroupStatus.SuspendedKyc;
- break;
- case WithdrawalGroupStatus.PendingAml:
- newStatus = WithdrawalGroupStatus.SuspendedAml;
- break;
- default:
- logger.warn(
- `Unsupported 'suspend' on withdrawal transaction in status ${wg.status}`,
- );
- }
- if (newStatus != null) {
- const oldTxState = computeWithdrawalTransactionStatus(wg);
- wg.status = newStatus;
- const newTxState = computeWithdrawalTransactionStatus(wg);
- await tx.withdrawalGroups.put(wg);
- return {
- oldTxState,
- newTxState,
- };
- }
- return undefined;
- },
- );
- ws.taskScheduler.stopShepherdTask(taskId);
- notifyTransition(ws, transactionId, transitionInfo);
- }
-
- async abortTransaction(): Promise<void> {
- const { ws, withdrawalGroupId, transactionId, taskId } = this;
- const transitionInfo = await ws.db.runReadWriteTx(
- ["withdrawalGroups"],
- async (tx) => {
- const wg = await tx.withdrawalGroups.get(withdrawalGroupId);
- if (!wg) {
- logger.warn(`withdrawal group ${withdrawalGroupId} not found`);
- return;
- }
- let newStatus: WithdrawalGroupStatus | undefined = undefined;
- switch (wg.status) {
- case WithdrawalGroupStatus.SuspendedRegisteringBank:
- case WithdrawalGroupStatus.SuspendedWaitConfirmBank:
- case WithdrawalGroupStatus.PendingWaitConfirmBank:
- case WithdrawalGroupStatus.PendingRegisteringBank:
- newStatus = WithdrawalGroupStatus.AbortingBank;
- break;
- case WithdrawalGroupStatus.SuspendedAml:
- case WithdrawalGroupStatus.SuspendedKyc:
- case WithdrawalGroupStatus.SuspendedQueryingStatus:
- case WithdrawalGroupStatus.SuspendedReady:
- case WithdrawalGroupStatus.PendingAml:
- case WithdrawalGroupStatus.PendingKyc:
- case WithdrawalGroupStatus.PendingQueryingStatus:
- newStatus = WithdrawalGroupStatus.AbortedExchange;
- break;
- case WithdrawalGroupStatus.PendingReady:
- newStatus = WithdrawalGroupStatus.SuspendedReady;
- break;
- case WithdrawalGroupStatus.SuspendedAbortingBank:
- case WithdrawalGroupStatus.AbortingBank:
- // No transition needed, but not an error
- break;
- case WithdrawalGroupStatus.Done:
- case WithdrawalGroupStatus.FailedBankAborted:
- case WithdrawalGroupStatus.AbortedExchange:
- case WithdrawalGroupStatus.AbortedBank:
- case WithdrawalGroupStatus.FailedAbortingBank:
- // Not allowed
- throw Error("abort not allowed in current state");
- default:
- assertUnreachable(wg.status);
- }
- if (newStatus != null) {
- const oldTxState = computeWithdrawalTransactionStatus(wg);
- wg.status = newStatus;
- const newTxState = computeWithdrawalTransactionStatus(wg);
- await tx.withdrawalGroups.put(wg);
- return {
- oldTxState,
- newTxState,
- };
- }
- return undefined;
- },
- );
- ws.taskScheduler.stopShepherdTask(taskId);
- notifyTransition(ws, transactionId, transitionInfo);
- ws.taskScheduler.startShepherdTask(taskId);
- }
-
- async resumeTransaction(): Promise<void> {
- const { ws, withdrawalGroupId, transactionId, taskId: retryTag } = this;
- const transitionInfo = await ws.db.runReadWriteTx(
- ["withdrawalGroups"],
- async (tx) => {
- const wg = await tx.withdrawalGroups.get(withdrawalGroupId);
- if (!wg) {
- logger.warn(`withdrawal group ${withdrawalGroupId} not found`);
- return;
- }
- let newStatus: WithdrawalGroupStatus | undefined = undefined;
- switch (wg.status) {
- case WithdrawalGroupStatus.SuspendedReady:
- newStatus = WithdrawalGroupStatus.PendingReady;
- break;
- case WithdrawalGroupStatus.SuspendedAbortingBank:
- newStatus = WithdrawalGroupStatus.AbortingBank;
- break;
- case WithdrawalGroupStatus.SuspendedWaitConfirmBank:
- newStatus = WithdrawalGroupStatus.PendingWaitConfirmBank;
- break;
- case WithdrawalGroupStatus.SuspendedQueryingStatus:
- newStatus = WithdrawalGroupStatus.PendingQueryingStatus;
- break;
- case WithdrawalGroupStatus.SuspendedRegisteringBank:
- newStatus = WithdrawalGroupStatus.PendingRegisteringBank;
- break;
- case WithdrawalGroupStatus.SuspendedAml:
- newStatus = WithdrawalGroupStatus.PendingAml;
- break;
- case WithdrawalGroupStatus.SuspendedKyc:
- newStatus = WithdrawalGroupStatus.PendingKyc;
- break;
- default:
- logger.warn(
- `Unsupported 'resume' on withdrawal transaction in status ${wg.status}`,
- );
- }
- if (newStatus != null) {
- const oldTxState = computeWithdrawalTransactionStatus(wg);
- wg.status = newStatus;
- const newTxState = computeWithdrawalTransactionStatus(wg);
- await tx.withdrawalGroups.put(wg);
- return {
- oldTxState,
- newTxState,
- };
- }
- return undefined;
- },
- );
- notifyTransition(ws, transactionId, transitionInfo);
- ws.taskScheduler.startShepherdTask(retryTag);
- }
-
- async failTransaction(): Promise<void> {
- const { ws, withdrawalGroupId, transactionId, taskId: retryTag } = this;
- const stateUpdate = await ws.db.runReadWriteTx(
- ["withdrawalGroups"],
- async (tx) => {
- const wg = await tx.withdrawalGroups.get(withdrawalGroupId);
- if (!wg) {
- logger.warn(`withdrawal group ${withdrawalGroupId} not found`);
- return;
- }
- let newStatus: WithdrawalGroupStatus | undefined = undefined;
- switch (wg.status) {
- case WithdrawalGroupStatus.SuspendedAbortingBank:
- case WithdrawalGroupStatus.AbortingBank:
- newStatus = WithdrawalGroupStatus.FailedAbortingBank;
- break;
- default:
- break;
- }
- if (newStatus != null) {
- const oldTxState = computeWithdrawalTransactionStatus(wg);
- wg.status = newStatus;
- const newTxState = computeWithdrawalTransactionStatus(wg);
- await tx.withdrawalGroups.put(wg);
- return {
- oldTxState,
- newTxState,
- };
- }
- return undefined;
- },
- );
- ws.taskScheduler.stopShepherdTask(retryTag);
- notifyTransition(ws, transactionId, stateUpdate);
- ws.taskScheduler.startShepherdTask(retryTag);
- }
-}
-
-/**
- * Compute the DD37 transaction state of a withdrawal transaction
- * from the database's withdrawal group record.
- */
-export function computeWithdrawalTransactionStatus(
- wgRecord: WithdrawalGroupRecord,
-): TransactionState {
- switch (wgRecord.status) {
- case WithdrawalGroupStatus.FailedBankAborted:
- return {
- major: TransactionMajorState.Aborted,
- };
- case WithdrawalGroupStatus.Done:
- return {
- major: TransactionMajorState.Done,
- };
- case WithdrawalGroupStatus.PendingRegisteringBank:
- return {
- major: TransactionMajorState.Pending,
- minor: TransactionMinorState.BankRegisterReserve,
- };
- case WithdrawalGroupStatus.PendingReady:
- return {
- major: TransactionMajorState.Pending,
- minor: TransactionMinorState.WithdrawCoins,
- };
- case WithdrawalGroupStatus.PendingQueryingStatus:
- return {
- major: TransactionMajorState.Pending,
- minor: TransactionMinorState.ExchangeWaitReserve,
- };
- case WithdrawalGroupStatus.PendingWaitConfirmBank:
- return {
- major: TransactionMajorState.Pending,
- minor: TransactionMinorState.BankConfirmTransfer,
- };
- case WithdrawalGroupStatus.AbortingBank:
- return {
- major: TransactionMajorState.Aborting,
- minor: TransactionMinorState.Bank,
- };
- case WithdrawalGroupStatus.SuspendedAbortingBank:
- return {
- major: TransactionMajorState.SuspendedAborting,
- minor: TransactionMinorState.Bank,
- };
- case WithdrawalGroupStatus.SuspendedQueryingStatus:
- return {
- major: TransactionMajorState.Suspended,
- minor: TransactionMinorState.ExchangeWaitReserve,
- };
- case WithdrawalGroupStatus.SuspendedRegisteringBank:
- return {
- major: TransactionMajorState.Suspended,
- minor: TransactionMinorState.BankRegisterReserve,
- };
- case WithdrawalGroupStatus.SuspendedWaitConfirmBank:
- return {
- major: TransactionMajorState.Suspended,
- minor: TransactionMinorState.BankConfirmTransfer,
- };
- case WithdrawalGroupStatus.SuspendedReady: {
- return {
- major: TransactionMajorState.Suspended,
- minor: TransactionMinorState.WithdrawCoins,
- };
- }
- case WithdrawalGroupStatus.PendingAml: {
- return {
- major: TransactionMajorState.Pending,
- minor: TransactionMinorState.AmlRequired,
- };
- }
- case WithdrawalGroupStatus.PendingKyc: {
- return {
- major: TransactionMajorState.Pending,
- minor: TransactionMinorState.KycRequired,
- };
- }
- case WithdrawalGroupStatus.SuspendedAml: {
- return {
- major: TransactionMajorState.Suspended,
- minor: TransactionMinorState.AmlRequired,
- };
- }
- case WithdrawalGroupStatus.SuspendedKyc: {
- return {
- major: TransactionMajorState.Suspended,
- minor: TransactionMinorState.KycRequired,
- };
- }
- case WithdrawalGroupStatus.FailedAbortingBank:
- return {
- major: TransactionMajorState.Failed,
- minor: TransactionMinorState.AbortingBank,
- };
- case WithdrawalGroupStatus.AbortedExchange:
- return {
- major: TransactionMajorState.Aborted,
- minor: TransactionMinorState.Exchange,
- };
-
- case WithdrawalGroupStatus.AbortedBank:
- return {
- major: TransactionMajorState.Aborted,
- minor: TransactionMinorState.Bank,
- };
- }
-}
-
-/**
- * Compute DD37 transaction actions for a withdrawal transaction
- * based on the database's withdrawal group record.
- */
-export function computeWithdrawalTransactionActions(
- wgRecord: WithdrawalGroupRecord,
-): TransactionAction[] {
- switch (wgRecord.status) {
- case WithdrawalGroupStatus.FailedBankAborted:
- return [TransactionAction.Delete];
- case WithdrawalGroupStatus.Done:
- return [TransactionAction.Delete];
- case WithdrawalGroupStatus.PendingRegisteringBank:
- return [TransactionAction.Suspend, TransactionAction.Abort];
- case WithdrawalGroupStatus.PendingReady:
- return [TransactionAction.Suspend, TransactionAction.Abort];
- case WithdrawalGroupStatus.PendingQueryingStatus:
- return [TransactionAction.Suspend, TransactionAction.Abort];
- case WithdrawalGroupStatus.PendingWaitConfirmBank:
- return [TransactionAction.Suspend, TransactionAction.Abort];
- case WithdrawalGroupStatus.AbortingBank:
- return [TransactionAction.Suspend, TransactionAction.Fail];
- case WithdrawalGroupStatus.SuspendedAbortingBank:
- return [TransactionAction.Resume, TransactionAction.Fail];
- case WithdrawalGroupStatus.SuspendedQueryingStatus:
- return [TransactionAction.Resume, TransactionAction.Abort];
- case WithdrawalGroupStatus.SuspendedRegisteringBank:
- return [TransactionAction.Resume, TransactionAction.Abort];
- case WithdrawalGroupStatus.SuspendedWaitConfirmBank:
- return [TransactionAction.Resume, TransactionAction.Abort];
- case WithdrawalGroupStatus.SuspendedReady:
- return [TransactionAction.Resume, TransactionAction.Abort];
- case WithdrawalGroupStatus.PendingAml:
- return [TransactionAction.Resume, TransactionAction.Abort];
- case WithdrawalGroupStatus.PendingKyc:
- return [TransactionAction.Resume, TransactionAction.Abort];
- case WithdrawalGroupStatus.SuspendedAml:
- return [TransactionAction.Resume, TransactionAction.Abort];
- case WithdrawalGroupStatus.SuspendedKyc:
- return [TransactionAction.Resume, TransactionAction.Abort];
- case WithdrawalGroupStatus.FailedAbortingBank:
- return [TransactionAction.Delete];
- case WithdrawalGroupStatus.AbortedExchange:
- return [TransactionAction.Delete];
- case WithdrawalGroupStatus.AbortedBank:
- return [TransactionAction.Delete];
- }
-}
-
-/**
- * Get information about a withdrawal from
- * a taler://withdraw URI by asking the bank.
- *
- * FIXME: Move into bank client.
- */
-export async function getBankWithdrawalInfo(
- http: HttpRequestLibrary,
- talerWithdrawUri: string,
-): Promise<BankWithdrawDetails> {
- const uriResult = parseWithdrawUri(talerWithdrawUri);
- if (!uriResult) {
- throw Error(`can't parse URL ${talerWithdrawUri}`);
- }
-
- const bankApi = new TalerBankIntegrationHttpClient(
- uriResult.bankIntegrationApiBaseUrl,
- http,
- );
-
- const { body: config } = await bankApi.getConfig();
-
- if (!bankApi.isCompatible(config.version)) {
- throw TalerError.fromDetail(
- TalerErrorCode.WALLET_BANK_INTEGRATION_PROTOCOL_VERSION_INCOMPATIBLE,
- {
- bankProtocolVersion: config.version,
- walletProtocolVersion: WALLET_BANK_INTEGRATION_PROTOCOL_VERSION,
- },
- "bank integration protocol version not compatible with wallet",
- );
- }
-
- const resp = await bankApi.getWithdrawalOperationById(
- uriResult.withdrawalOperationId,
- );
-
- if (resp.type === "fail") {
- throw TalerError.fromUncheckedDetail(resp.detail);
- }
- const { body: status } = resp;
-
- logger.info(`bank withdrawal operation status: ${j2s(status)}`);
-
- return {
- operationId: uriResult.withdrawalOperationId,
- apiBaseUrl: uriResult.bankIntegrationApiBaseUrl,
- amount: Amounts.parseOrThrow(status.amount),
- confirmTransferUrl: status.confirm_transfer_url,
- senderWire: status.sender_wire,
- suggestedExchange: status.suggested_exchange,
- wireTypes: status.wire_types,
- status: status.status,
- };
-}
-
-/**
- * Return denominations that can potentially used for a withdrawal.
- */
-async function getCandidateWithdrawalDenoms(
- ws: InternalWalletState,
- exchangeBaseUrl: string,
- currency: string,
-): Promise<DenominationRecord[]> {
- return await ws.db.runReadOnlyTx(["denominations"], async (tx) => {
- return getCandidateWithdrawalDenomsTx(ws, tx, exchangeBaseUrl, currency);
- });
-}
-
-export async function getCandidateWithdrawalDenomsTx(
- ws: InternalWalletState,
- tx: WalletDbReadOnlyTransaction<["denominations"]>,
- exchangeBaseUrl: string,
- currency: string,
-): Promise<DenominationRecord[]> {
- // FIXME: Use denom groups instead of querying all denominations!
- const allDenoms =
- await tx.denominations.indexes.byExchangeBaseUrl.getAll(exchangeBaseUrl);
- return allDenoms
- .filter((d) => d.currency === currency)
- .filter((d) => isWithdrawableDenom(d, ws.config.testing.denomselAllowLate));
-}
-
-/**
- * Generate a planchet for a coin index in a withdrawal group.
- * Does not actually withdraw the coin yet.
- *
- * Split up so that we can parallelize the crypto, but serialize
- * the exchange requests per reserve.
- */
-async function processPlanchetGenerate(
- ws: InternalWalletState,
- withdrawalGroup: WithdrawalGroupRecord,
- coinIdx: number,
-): Promise<void> {
- let planchet = await ws.db.runReadOnlyTx(["planchets"], async (tx) => {
- return tx.planchets.indexes.byGroupAndIndex.get([
- withdrawalGroup.withdrawalGroupId,
- coinIdx,
- ]);
- });
- if (planchet) {
- return;
- }
- let ci = 0;
- let maybeDenomPubHash: string | undefined;
- for (let di = 0; di < withdrawalGroup.denomsSel.selectedDenoms.length; di++) {
- const d = withdrawalGroup.denomsSel.selectedDenoms[di];
- if (coinIdx >= ci && coinIdx < ci + d.count) {
- maybeDenomPubHash = d.denomPubHash;
- break;
- }
- ci += d.count;
- }
- if (!maybeDenomPubHash) {
- throw Error("invariant violated");
- }
- const denomPubHash = maybeDenomPubHash;
-
- const denom = await ws.db.runReadOnlyTx(["denominations"], async (tx) => {
- return ws.getDenomInfo(
- ws,
- tx,
- withdrawalGroup.exchangeBaseUrl,
- denomPubHash,
- );
- });
- checkDbInvariant(!!denom);
- const r = await ws.cryptoApi.createPlanchet({
- denomPub: denom.denomPub,
- feeWithdraw: Amounts.parseOrThrow(denom.feeWithdraw),
- reservePriv: withdrawalGroup.reservePriv,
- reservePub: withdrawalGroup.reservePub,
- value: Amounts.parseOrThrow(denom.value),
- coinIndex: coinIdx,
- secretSeed: withdrawalGroup.secretSeed,
- restrictAge: withdrawalGroup.restrictAge,
- });
- const newPlanchet: PlanchetRecord = {
- blindingKey: r.blindingKey,
- coinEv: r.coinEv,
- coinEvHash: r.coinEvHash,
- coinIdx,
- coinPriv: r.coinPriv,
- coinPub: r.coinPub,
- denomPubHash: r.denomPubHash,
- planchetStatus: PlanchetStatus.Pending,
- withdrawSig: r.withdrawSig,
- withdrawalGroupId: withdrawalGroup.withdrawalGroupId,
- ageCommitmentProof: r.ageCommitmentProof,
- lastError: undefined,
- };
- await ws.db.runReadWriteTx(["planchets"], async (tx) => {
- const p = await tx.planchets.indexes.byGroupAndIndex.get([
- withdrawalGroup.withdrawalGroupId,
- coinIdx,
- ]);
- if (p) {
- planchet = p;
- return;
- }
- await tx.planchets.put(newPlanchet);
- planchet = newPlanchet;
- });
-}
-
-interface WithdrawalRequestBatchArgs {
- coinStartIndex: number;
-
- batchSize: number;
-}
-
-interface WithdrawalBatchResult {
- coinIdxs: number[];
- batchResp: ExchangeWithdrawBatchResponse;
-}
-
-enum AmlStatus {
- normal = 0,
- pending = 1,
- fronzen = 2,
-}
-
-/**
- * Transition a withdrawal transaction with a (new) KYC URL.
- *
- * Emit a notification for the (self-)transition.
- */
-async function transitionKycUrlUpdate(
- ws: InternalWalletState,
- withdrawalGroupId: string,
- kycUrl: string,
-): Promise<void> {
- let notificationKycUrl: string | undefined = undefined;
- const ctx = new WithdrawTransactionContext(ws, withdrawalGroupId);
- const transactionId = ctx.transactionId;
-
- const transitionInfo = await ws.db.runReadWriteTx(
- ["withdrawalGroups"],
- async (tx) => {
- const wg2 = await tx.withdrawalGroups.get(withdrawalGroupId);
- if (!wg2) {
- return;
- }
- const oldTxState = computeWithdrawalTransactionStatus(wg2);
- switch (wg2.status) {
- case WithdrawalGroupStatus.PendingReady: {
- wg2.kycUrl = kycUrl;
- notificationKycUrl = kycUrl;
- await tx.withdrawalGroups.put(wg2);
- const newTxState = computeWithdrawalTransactionStatus(wg2);
- return {
- oldTxState,
- newTxState,
- };
- }
- default:
- return undefined;
- }
- },
- );
- if (transitionInfo) {
- // Always notify, even on self-transition, as the KYC URL might have changed.
- ws.notify({
- type: NotificationType.TransactionStateTransition,
- oldTxState: transitionInfo.oldTxState,
- newTxState: transitionInfo.newTxState,
- transactionId,
- experimentalUserData: notificationKycUrl,
- });
- }
- ws.taskScheduler.startShepherdTask(ctx.taskId);
-}
-
-async function handleKycRequired(
- ws: InternalWalletState,
- withdrawalGroup: WithdrawalGroupRecord,
- resp: HttpResponse,
- startIdx: number,
- requestCoinIdxs: number[],
-): Promise<void> {
- logger.info("withdrawal requires KYC");
- const respJson = await resp.json();
- const uuidResp = codecForWalletKycUuid().decode(respJson);
- const withdrawalGroupId = withdrawalGroup.withdrawalGroupId;
- const transactionId = constructTransactionIdentifier({
- tag: TransactionType.Withdrawal,
- withdrawalGroupId,
- });
- logger.info(`kyc uuid response: ${j2s(uuidResp)}`);
- const exchangeUrl = withdrawalGroup.exchangeBaseUrl;
- const userType = "individual";
- const kycInfo: KycPendingInfo = {
- paytoHash: uuidResp.h_payto,
- requirementRow: uuidResp.requirement_row,
- };
- const url = new URL(
- `kyc-check/${kycInfo.requirementRow}/${kycInfo.paytoHash}/${userType}`,
- exchangeUrl,
- );
- logger.info(`kyc url ${url.href}`);
- const kycStatusRes = await ws.http.fetch(url.href, {
- method: "GET",
- });
- let kycUrl: string;
- let amlStatus: AmlStatus | undefined;
- if (
- kycStatusRes.status === HttpStatusCode.Ok ||
- // FIXME: NoContent is not expected https://docs.taler.net/core/api-exchange.html#post--purses-$PURSE_PUB-merge
- // remove after the exchange is fixed or clarified
- kycStatusRes.status === HttpStatusCode.NoContent
- ) {
- logger.warn("kyc requested, but already fulfilled");
- return;
- } else if (kycStatusRes.status === HttpStatusCode.Accepted) {
- const kycStatus = await kycStatusRes.json();
- logger.info(`kyc status: ${j2s(kycStatus)}`);
- kycUrl = kycStatus.kyc_url;
- } else if (
- kycStatusRes.status === HttpStatusCode.UnavailableForLegalReasons
- ) {
- const kycStatus = await kycStatusRes.json();
- logger.info(`aml status: ${j2s(kycStatus)}`);
- amlStatus = kycStatus.aml_status;
- } else {
- throw Error(`unexpected response from kyc-check (${kycStatusRes.status})`);
- }
-
- let notificationKycUrl: string | undefined = undefined;
-
- const transitionInfo = await ws.db.runReadWriteTx(
- ["planchets", "withdrawalGroups"],
- async (tx) => {
- for (let i = startIdx; i < requestCoinIdxs.length; i++) {
- let planchet = await tx.planchets.indexes.byGroupAndIndex.get([
- withdrawalGroup.withdrawalGroupId,
- requestCoinIdxs[i],
- ]);
- if (!planchet) {
- continue;
- }
- planchet.planchetStatus = PlanchetStatus.KycRequired;
- await tx.planchets.put(planchet);
- }
- const wg2 = await tx.withdrawalGroups.get(
- withdrawalGroup.withdrawalGroupId,
- );
- if (!wg2) {
- return;
- }
- const oldTxState = computeWithdrawalTransactionStatus(wg2);
- switch (wg2.status) {
- case WithdrawalGroupStatus.PendingReady: {
- wg2.kycPending = {
- paytoHash: uuidResp.h_payto,
- requirementRow: uuidResp.requirement_row,
- };
- wg2.kycUrl = kycUrl;
- wg2.status =
- amlStatus === AmlStatus.normal || amlStatus === undefined
- ? WithdrawalGroupStatus.PendingKyc
- : amlStatus === AmlStatus.pending
- ? WithdrawalGroupStatus.PendingAml
- : amlStatus === AmlStatus.fronzen
- ? WithdrawalGroupStatus.SuspendedAml
- : assertUnreachable(amlStatus);
-
- notificationKycUrl = kycUrl;
-
- await tx.withdrawalGroups.put(wg2);
- const newTxState = computeWithdrawalTransactionStatus(wg2);
- return {
- oldTxState,
- newTxState,
- };
- }
- default:
- return undefined;
- }
- },
- );
- notifyTransition(ws, transactionId, transitionInfo, notificationKycUrl);
-}
-
-/**
- * Send the withdrawal request for a generated planchet to the exchange.
- *
- * The verification of the response is done asynchronously to enable parallelism.
- */
-async function processPlanchetExchangeBatchRequest(
- ws: InternalWalletState,
- wgContext: WithdrawalGroupContext,
- args: WithdrawalRequestBatchArgs,
-): Promise<WithdrawalBatchResult> {
- const withdrawalGroup: WithdrawalGroupRecord = wgContext.wgRecord;
- logger.info(
- `processing planchet exchange batch request ${withdrawalGroup.withdrawalGroupId}, start=${args.coinStartIndex}, len=${args.batchSize}`,
- );
-
- const batchReq: ExchangeBatchWithdrawRequest = { planchets: [] };
- // Indices of coins that are included in the batch request
- const requestCoinIdxs: number[] = [];
-
- await ws.db.runReadOnlyTx(["planchets", "denominations"], async (tx) => {
- for (
- let coinIdx = args.coinStartIndex;
- coinIdx < args.coinStartIndex + args.batchSize &&
- coinIdx < wgContext.numPlanchets;
- coinIdx++
- ) {
- let planchet = await tx.planchets.indexes.byGroupAndIndex.get([
- withdrawalGroup.withdrawalGroupId,
- coinIdx,
- ]);
- if (!planchet) {
- continue;
- }
- if (planchet.planchetStatus === PlanchetStatus.WithdrawalDone) {
- logger.warn("processPlanchet: planchet already withdrawn");
- continue;
- }
- const denom = await ws.getDenomInfo(
- ws,
- tx,
- withdrawalGroup.exchangeBaseUrl,
- planchet.denomPubHash,
- );
-
- if (!denom) {
- logger.error("db inconsistent: denom for planchet not found");
- continue;
- }
-
- const planchetReq: ExchangeWithdrawRequest = {
- denom_pub_hash: planchet.denomPubHash,
- reserve_sig: planchet.withdrawSig,
- coin_ev: planchet.coinEv,
- };
- batchReq.planchets.push(planchetReq);
- requestCoinIdxs.push(coinIdx);
- }
- });
-
- if (batchReq.planchets.length == 0) {
- logger.warn("empty withdrawal batch");
- return {
- batchResp: { ev_sigs: [] },
- coinIdxs: [],
- };
- }
-
- async function storeCoinError(e: any, coinIdx: number): Promise<void> {
- const errDetail = getErrorDetailFromException(e);
- logger.trace("withdrawal request failed", e);
- logger.trace(String(e));
- await ws.db.runReadWriteTx(["planchets"], async (tx) => {
- let planchet = await tx.planchets.indexes.byGroupAndIndex.get([
- withdrawalGroup.withdrawalGroupId,
- coinIdx,
- ]);
- if (!planchet) {
- return;
- }
- planchet.lastError = errDetail;
- await tx.planchets.put(planchet);
- });
- }
-
- // FIXME: handle individual error codes better!
-
- const reqUrl = new URL(
- `reserves/${withdrawalGroup.reservePub}/batch-withdraw`,
- withdrawalGroup.exchangeBaseUrl,
- ).href;
-
- try {
- const resp = await ws.http.fetch(reqUrl, {
- method: "POST",
- body: batchReq,
- });
- if (resp.status === HttpStatusCode.UnavailableForLegalReasons) {
- await handleKycRequired(ws, withdrawalGroup, resp, 0, requestCoinIdxs);
- return {
- batchResp: { ev_sigs: [] },
- coinIdxs: [],
- };
- }
- const r = await readSuccessResponseJsonOrThrow(
- resp,
- codecForExchangeWithdrawBatchResponse(),
- );
- return {
- coinIdxs: requestCoinIdxs,
- batchResp: r,
- };
- } catch (e) {
- await storeCoinError(e, requestCoinIdxs[0]);
- return {
- batchResp: { ev_sigs: [] },
- coinIdxs: [],
- };
- }
-}
-
-async function processPlanchetVerifyAndStoreCoin(
- ws: InternalWalletState,
- wgContext: WithdrawalGroupContext,
- coinIdx: number,
- resp: ExchangeWithdrawResponse,
-): Promise<void> {
- const withdrawalGroup = wgContext.wgRecord;
- logger.trace(`checking and storing planchet idx=${coinIdx}`);
- const d = await ws.db.runReadOnlyTx(
- ["planchets", "denominations"],
- async (tx) => {
- let planchet = await tx.planchets.indexes.byGroupAndIndex.get([
- withdrawalGroup.withdrawalGroupId,
- coinIdx,
- ]);
- if (!planchet) {
- return;
- }
- if (planchet.planchetStatus === PlanchetStatus.WithdrawalDone) {
- logger.warn("processPlanchet: planchet already withdrawn");
- return;
- }
- const denomInfo = await ws.getDenomInfo(
- ws,
- tx,
- withdrawalGroup.exchangeBaseUrl,
- planchet.denomPubHash,
- );
- if (!denomInfo) {
- return;
- }
- return {
- planchet,
- denomInfo,
- exchangeBaseUrl: withdrawalGroup.exchangeBaseUrl,
- };
- },
- );
-
- if (!d) {
- return;
- }
-
- const transactionId = constructTransactionIdentifier({
- tag: TransactionType.Withdrawal,
- withdrawalGroupId: wgContext.wgRecord.withdrawalGroupId,
- });
-
- const { planchet, denomInfo } = d;
-
- const planchetDenomPub = denomInfo.denomPub;
- if (planchetDenomPub.cipher !== DenomKeyType.Rsa) {
- throw Error(`cipher (${planchetDenomPub.cipher}) not supported`);
- }
-
- let evSig = resp.ev_sig;
- if (!(evSig.cipher === DenomKeyType.Rsa)) {
- throw Error("unsupported cipher");
- }
-
- const denomSigRsa = await ws.cryptoApi.rsaUnblind({
- bk: planchet.blindingKey,
- blindedSig: evSig.blinded_rsa_signature,
- pk: planchetDenomPub.rsa_public_key,
- });
-
- const isValid = await ws.cryptoApi.rsaVerify({
- hm: planchet.coinPub,
- pk: planchetDenomPub.rsa_public_key,
- sig: denomSigRsa.sig,
- });
-
- if (!isValid) {
- await ws.db.runReadWriteTx(["planchets"], async (tx) => {
- let planchet = await tx.planchets.indexes.byGroupAndIndex.get([
- withdrawalGroup.withdrawalGroupId,
- coinIdx,
- ]);
- if (!planchet) {
- return;
- }
- planchet.lastError = makeErrorDetail(
- TalerErrorCode.WALLET_EXCHANGE_COIN_SIGNATURE_INVALID,
- {},
- "invalid signature from the exchange after unblinding",
- );
- await tx.planchets.put(planchet);
- });
- return;
- }
-
- let denomSig: UnblindedSignature;
- if (planchetDenomPub.cipher === DenomKeyType.Rsa) {
- denomSig = {
- cipher: planchetDenomPub.cipher,
- rsa_signature: denomSigRsa.sig,
- };
- } else {
- throw Error("unsupported cipher");
- }
-
- const coin: CoinRecord = {
- blindingKey: planchet.blindingKey,
- coinPriv: planchet.coinPriv,
- coinPub: planchet.coinPub,
- denomPubHash: planchet.denomPubHash,
- denomSig,
- coinEvHash: planchet.coinEvHash,
- exchangeBaseUrl: d.exchangeBaseUrl,
- status: CoinStatus.Fresh,
- coinSource: {
- type: CoinSourceType.Withdraw,
- coinIndex: coinIdx,
- reservePub: withdrawalGroup.reservePub,
- withdrawalGroupId: withdrawalGroup.withdrawalGroupId,
- },
- sourceTransactionId: transactionId,
- maxAge: withdrawalGroup.restrictAge ?? AgeRestriction.AGE_UNRESTRICTED,
- ageCommitmentProof: planchet.ageCommitmentProof,
- spendAllocation: undefined,
- };
-
- const planchetCoinPub = planchet.coinPub;
-
- wgContext.planchetsFinished.add(planchet.coinPub);
-
- await ws.db.runReadWriteTx(
- ["planchets", "coins", "coinAvailability", "denominations"],
- async (tx) => {
- const p = await tx.planchets.get(planchetCoinPub);
- if (!p || p.planchetStatus === PlanchetStatus.WithdrawalDone) {
- return;
- }
- p.planchetStatus = PlanchetStatus.WithdrawalDone;
- p.lastError = undefined;
- await tx.planchets.put(p);
- await makeCoinAvailable(ws, tx, coin);
- },
- );
-}
-
-/**
- * Make sure that denominations that currently can be used for withdrawal
- * are validated, and the result of validation is stored in the database.
- */
-async function updateWithdrawalDenoms(
- ws: InternalWalletState,
- exchangeBaseUrl: string,
-): Promise<void> {
- logger.trace(
- `updating denominations used for withdrawal for ${exchangeBaseUrl}`,
- );
- const exchangeDetails = await ws.db.runReadOnlyTx(
- ["exchanges", "exchangeDetails"],
- async (tx) => {
- return getExchangeWireDetailsInTx(tx, exchangeBaseUrl);
- },
- );
- if (!exchangeDetails) {
- logger.error("exchange details not available");
- throw Error(`exchange ${exchangeBaseUrl} details not available`);
- }
- // First do a pass where the validity of candidate denominations
- // is checked and the result is stored in the database.
- logger.trace("getting candidate denominations");
- const denominations = await getCandidateWithdrawalDenoms(
- ws,
- exchangeBaseUrl,
- exchangeDetails.currency,
- );
- logger.trace(`got ${denominations.length} candidate denominations`);
- const batchSize = 500;
- let current = 0;
-
- while (current < denominations.length) {
- const updatedDenominations: DenominationRecord[] = [];
- // Do a batch of batchSize
- for (
- let batchIdx = 0;
- batchIdx < batchSize && current < denominations.length;
- batchIdx++, current++
- ) {
- const denom = denominations[current];
- if (
- denom.verificationStatus === DenominationVerificationStatus.Unverified
- ) {
- logger.trace(
- `Validating denomination (${current + 1}/${
- denominations.length
- }) signature of ${denom.denomPubHash}`,
- );
- let valid = false;
- if (ws.config.testing.insecureTrustExchange) {
- valid = true;
- } else {
- const res = await ws.cryptoApi.isValidDenom({
- denom,
- masterPub: exchangeDetails.masterPublicKey,
- });
- valid = res.valid;
- }
- logger.trace(`Done validating ${denom.denomPubHash}`);
- if (!valid) {
- logger.warn(
- `Signature check for denomination h=${denom.denomPubHash} failed`,
- );
- denom.verificationStatus = DenominationVerificationStatus.VerifiedBad;
- } else {
- denom.verificationStatus =
- DenominationVerificationStatus.VerifiedGood;
- }
- updatedDenominations.push(denom);
- }
- }
- if (updatedDenominations.length > 0) {
- logger.trace("writing denomination batch to db");
- await ws.db.runReadWriteTx(["denominations"], async (tx) => {
- for (let i = 0; i < updatedDenominations.length; i++) {
- const denom = updatedDenominations[i];
- await tx.denominations.put(denom);
- }
- });
- logger.trace("done with DB write");
- }
- }
-}
-
-/**
- * Update the information about a reserve that is stored in the wallet
- * by querying the reserve's exchange.
- *
- * If the reserve have funds that are not allocated in a withdrawal group yet
- * and are big enough to withdraw with available denominations,
- * create a new withdrawal group for the remaining amount.
- */
-async function queryReserve(
- ws: InternalWalletState,
- withdrawalGroupId: string,
- cancellationToken: CancellationToken,
-): Promise<TaskRunResult> {
- const transactionId = constructTransactionIdentifier({
- tag: TransactionType.Withdrawal,
- withdrawalGroupId,
- });
- const withdrawalGroup = await getWithdrawalGroupRecordTx(ws.db, {
- withdrawalGroupId,
- });
- checkDbInvariant(!!withdrawalGroup);
- if (withdrawalGroup.status !== WithdrawalGroupStatus.PendingQueryingStatus) {
- return TaskRunResult.backoff();
- }
- const reservePub = withdrawalGroup.reservePub;
-
- const reserveUrl = new URL(
- `reserves/${reservePub}`,
- withdrawalGroup.exchangeBaseUrl,
- );
- reserveUrl.searchParams.set("timeout_ms", "30000");
-
- logger.trace(`querying reserve status via ${reserveUrl.href}`);
-
- const resp = await ws.http.fetch(reserveUrl.href, {
- timeout: getReserveRequestTimeout(withdrawalGroup),
- cancellationToken,
- });
-
- logger.trace(`reserve status code: HTTP ${resp.status}`);
-
- const result = await readSuccessResponseJsonOrErrorCode(
- resp,
- codecForReserveStatus(),
- );
-
- if (result.isError) {
- logger.trace(
- `got reserve status error, EC=${result.talerErrorResponse.code}`,
- );
- if (resp.status === HttpStatusCode.NotFound) {
- return TaskRunResult.backoff();
- } else {
- throwUnexpectedRequestError(resp, result.talerErrorResponse);
- }
- }
-
- logger.trace(`got reserve status ${j2s(result.response)}`);
-
- const transitionResult = await ws.db.runReadWriteTx(
- ["withdrawalGroups"],
- async (tx) => {
- const wg = await tx.withdrawalGroups.get(withdrawalGroupId);
- if (!wg) {
- logger.warn(`withdrawal group ${withdrawalGroupId} not found`);
- return undefined;
- }
- const txStateOld = computeWithdrawalTransactionStatus(wg);
- wg.status = WithdrawalGroupStatus.PendingReady;
- const txStateNew = computeWithdrawalTransactionStatus(wg);
- wg.reserveBalanceAmount = Amounts.stringify(result.response.balance);
- await tx.withdrawalGroups.put(wg);
- return {
- oldTxState: txStateOld,
- newTxState: txStateNew,
- };
- },
- );
-
- notifyTransition(ws, transactionId, transitionResult);
-
- return TaskRunResult.backoff();
-}
-
-/**
- * Withdrawal context that is kept in-memory.
- *
- * Used to store some cached info during a withdrawal operation.
- */
-export interface WithdrawalGroupContext {
- numPlanchets: number;
- planchetsFinished: Set<string>;
-
- /**
- * Cached withdrawal group record from the database.
- */
- wgRecord: WithdrawalGroupRecord;
-}
-
-async function processWithdrawalGroupAbortingBank(
- ws: InternalWalletState,
- withdrawalGroup: WithdrawalGroupRecord,
-): Promise<TaskRunResult> {
- const { withdrawalGroupId } = withdrawalGroup;
- const transactionId = constructTransactionIdentifier({
- tag: TransactionType.Withdrawal,
- withdrawalGroupId,
- });
-
- const wgInfo = withdrawalGroup.wgInfo;
- if (wgInfo.withdrawalType != WithdrawalRecordType.BankIntegrated) {
- throw Error("invalid state (aborting(bank) without bank info");
- }
- const abortUrl = getBankAbortUrl(wgInfo.bankInfo.talerWithdrawUri);
- logger.info(`aborting withdrawal at ${abortUrl}`);
- const abortResp = await ws.http.fetch(abortUrl, {
- method: "POST",
- body: {},
- });
- logger.info(`abort response status: ${abortResp.status}`);
-
- const transitionInfo = await ws.db.runReadWriteTx(
- ["withdrawalGroups"],
- async (tx) => {
- const wg = await tx.withdrawalGroups.get(withdrawalGroupId);
- if (!wg) {
- return undefined;
- }
- const txStatusOld = computeWithdrawalTransactionStatus(wg);
- wg.status = WithdrawalGroupStatus.AbortedBank;
- wg.timestampFinish = timestampPreciseToDb(TalerPreciseTimestamp.now());
- const txStatusNew = computeWithdrawalTransactionStatus(wg);
- await tx.withdrawalGroups.put(wg);
- return {
- oldTxState: txStatusOld,
- newTxState: txStatusNew,
- };
- },
- );
- notifyTransition(ws, transactionId, transitionInfo);
- return TaskRunResult.finished();
-}
-
-/**
- * Store in the database that the KYC for a withdrawal is now
- * satisfied.
- */
-async function transitionKycSatisfied(
- ws: InternalWalletState,
- withdrawalGroup: WithdrawalGroupRecord,
-): Promise<void> {
- const transactionId = constructTransactionIdentifier({
- tag: TransactionType.Withdrawal,
- withdrawalGroupId: withdrawalGroup.withdrawalGroupId,
- });
- const transitionInfo = await ws.db.runReadWriteTx(
- ["withdrawalGroups"],
- async (tx) => {
- const wg2 = await tx.withdrawalGroups.get(
- withdrawalGroup.withdrawalGroupId,
- );
- if (!wg2) {
- return;
- }
- const oldTxState = computeWithdrawalTransactionStatus(wg2);
- switch (wg2.status) {
- case WithdrawalGroupStatus.PendingKyc: {
- delete wg2.kycPending;
- delete wg2.kycUrl;
- wg2.status = WithdrawalGroupStatus.PendingReady;
- await tx.withdrawalGroups.put(wg2);
- const newTxState = computeWithdrawalTransactionStatus(wg2);
- return {
- oldTxState,
- newTxState,
- };
- }
- default:
- return undefined;
- }
- },
- );
- notifyTransition(ws, transactionId, transitionInfo);
-}
-
-async function processWithdrawalGroupPendingKyc(
- ws: InternalWalletState,
- withdrawalGroup: WithdrawalGroupRecord,
- cancellationToken: CancellationToken,
-): Promise<TaskRunResult> {
- const userType = "individual";
- const kycInfo = withdrawalGroup.kycPending;
- if (!kycInfo) {
- throw Error("no kyc info available in pending(kyc)");
- }
- const exchangeUrl = withdrawalGroup.exchangeBaseUrl;
- const url = new URL(
- `kyc-check/${kycInfo.requirementRow}/${kycInfo.paytoHash}/${userType}`,
- exchangeUrl,
- );
- url.searchParams.set("timeout_ms", "30000");
-
- const withdrawalGroupId = withdrawalGroup.withdrawalGroupId;
-
- logger.info(`long-polling for withdrawal KYC status via ${url.href}`);
- const kycStatusRes = await ws.http.fetch(url.href, {
- method: "GET",
- cancellationToken,
- });
- logger.info(`kyc long-polling response status: HTTP ${kycStatusRes.status}`);
- if (
- kycStatusRes.status === HttpStatusCode.Ok ||
- //FIXME: NoContent is not expected https://docs.taler.net/core/api-exchange.html#post--purses-$PURSE_PUB-merge
- // remove after the exchange is fixed or clarified
- kycStatusRes.status === HttpStatusCode.NoContent
- ) {
- await transitionKycSatisfied(ws, withdrawalGroup);
- } else if (kycStatusRes.status === HttpStatusCode.Accepted) {
- const kycStatus = await kycStatusRes.json();
- logger.info(`kyc status: ${j2s(kycStatus)}`);
- const kycUrl = kycStatus.kyc_url;
- if (typeof kycUrl === "string") {
- await transitionKycUrlUpdate(ws, withdrawalGroupId, kycUrl);
- }
- } else if (
- kycStatusRes.status === HttpStatusCode.UnavailableForLegalReasons
- ) {
- const kycStatus = await kycStatusRes.json();
- logger.info(`aml status: ${j2s(kycStatus)}`);
- } else {
- throw Error(`unexpected response from kyc-check (${kycStatusRes.status})`);
- }
- return TaskRunResult.backoff();
-}
-
-async function processWithdrawalGroupPendingReady(
- ws: InternalWalletState,
- withdrawalGroup: WithdrawalGroupRecord,
-): Promise<TaskRunResult> {
- const { withdrawalGroupId } = withdrawalGroup;
- const transactionId = constructTransactionIdentifier({
- tag: TransactionType.Withdrawal,
- withdrawalGroupId,
- });
-
- await fetchFreshExchange(ws, withdrawalGroup.exchangeBaseUrl);
-
- if (withdrawalGroup.denomsSel.selectedDenoms.length === 0) {
- logger.warn("Finishing empty withdrawal group (no denoms)");
- const transitionInfo = await ws.db.runReadWriteTx(
- ["withdrawalGroups"],
- async (tx) => {
- const wg = await tx.withdrawalGroups.get(withdrawalGroupId);
- if (!wg) {
- return undefined;
- }
- const txStatusOld = computeWithdrawalTransactionStatus(wg);
- wg.status = WithdrawalGroupStatus.Done;
- wg.timestampFinish = timestampPreciseToDb(TalerPreciseTimestamp.now());
- const txStatusNew = computeWithdrawalTransactionStatus(wg);
- await tx.withdrawalGroups.put(wg);
- return {
- oldTxState: txStatusOld,
- newTxState: txStatusNew,
- };
- },
- );
- notifyTransition(ws, transactionId, transitionInfo);
- return TaskRunResult.finished();
- }
-
- const numTotalCoins = withdrawalGroup.denomsSel.selectedDenoms
- .map((x) => x.count)
- .reduce((a, b) => a + b);
-
- const wgContext: WithdrawalGroupContext = {
- numPlanchets: numTotalCoins,
- planchetsFinished: new Set<string>(),
- wgRecord: withdrawalGroup,
- };
-
- await ws.db.runReadOnlyTx(["planchets"], async (tx) => {
- const planchets =
- await tx.planchets.indexes.byGroup.getAll(withdrawalGroupId);
- for (const p of planchets) {
- if (p.planchetStatus === PlanchetStatus.WithdrawalDone) {
- wgContext.planchetsFinished.add(p.coinPub);
- }
- }
- });
-
- // We sequentially generate planchets, so that
- // large withdrawal groups don't make the wallet unresponsive.
- for (let i = 0; i < numTotalCoins; i++) {
- await processPlanchetGenerate(ws, withdrawalGroup, i);
- }
-
- const maxBatchSize = 100;
-
- for (let i = 0; i < numTotalCoins; i += maxBatchSize) {
- const resp = await processPlanchetExchangeBatchRequest(ws, wgContext, {
- batchSize: maxBatchSize,
- coinStartIndex: i,
- });
- let work: Promise<void>[] = [];
- work = [];
- for (let j = 0; j < resp.coinIdxs.length; j++) {
- if (!resp.batchResp.ev_sigs[j]) {
- // response may not be available when there is kyc needed
- continue;
- }
- work.push(
- processPlanchetVerifyAndStoreCoin(
- ws,
- wgContext,
- resp.coinIdxs[j],
- resp.batchResp.ev_sigs[j],
- ),
- );
- }
- await Promise.all(work);
- }
-
- let numFinished = 0;
- const errorsPerCoin: Record<number, TalerErrorDetail> = {};
- let numPlanchetErrors = 0;
- const maxReportedErrors = 5;
-
- const res = await ws.db.runReadWriteTx(
- ["coins", "coinAvailability", "withdrawalGroups", "planchets"],
- async (tx) => {
- const wg = await tx.withdrawalGroups.get(withdrawalGroupId);
- if (!wg) {
- return;
- }
-
- await tx.planchets.indexes.byGroup
- .iter(withdrawalGroupId)
- .forEach((x) => {
- if (x.planchetStatus === PlanchetStatus.WithdrawalDone) {
- numFinished++;
- }
- if (x.lastError) {
- numPlanchetErrors++;
- if (numPlanchetErrors < maxReportedErrors) {
- errorsPerCoin[x.coinIdx] = x.lastError;
- }
- }
- });
- const oldTxState = computeWithdrawalTransactionStatus(wg);
- logger.info(`now withdrawn ${numFinished} of ${numTotalCoins} coins`);
- if (wg.timestampFinish === undefined && numFinished === numTotalCoins) {
- wg.timestampFinish = timestampPreciseToDb(TalerPreciseTimestamp.now());
- wg.status = WithdrawalGroupStatus.Done;
- await makeCoinsVisible(ws, tx, transactionId);
- }
-
- const newTxState = computeWithdrawalTransactionStatus(wg);
- await tx.withdrawalGroups.put(wg);
-
- return {
- kycInfo: wg.kycPending,
- transitionInfo: {
- oldTxState,
- newTxState,
- },
- };
- },
- );
-
- if (!res) {
- throw Error("withdrawal group does not exist anymore");
- }
-
- notifyTransition(ws, transactionId, res.transitionInfo);
- ws.notify({
- type: NotificationType.BalanceChange,
- hintTransactionId: transactionId,
- });
-
- if (numPlanchetErrors > 0) {
- return {
- type: TaskRunResultType.Error,
- errorDetail: makeErrorDetail(
- TalerErrorCode.WALLET_WITHDRAWAL_GROUP_INCOMPLETE,
- {
- errorsPerCoin,
- numErrors: numPlanchetErrors,
- },
- ),
- };
- }
-
- return TaskRunResult.backoff();
-}
-
-export async function processWithdrawalGroup(
- ws: InternalWalletState,
- withdrawalGroupId: string,
- cancellationToken: CancellationToken,
-): Promise<TaskRunResult> {
- logger.trace("processing withdrawal group", withdrawalGroupId);
- const withdrawalGroup = await ws.db.runReadOnlyTx(
- ["withdrawalGroups"],
- async (tx) => {
- return tx.withdrawalGroups.get(withdrawalGroupId);
- },
- );
-
- if (!withdrawalGroup) {
- throw Error(`withdrawal group ${withdrawalGroupId} not found`);
- }
-
- switch (withdrawalGroup.status) {
- case WithdrawalGroupStatus.PendingRegisteringBank:
- await processReserveBankStatus(ws, withdrawalGroupId);
- // FIXME: This will get called by the main task loop, why call it here?!
- return await processWithdrawalGroup(
- ws,
- withdrawalGroupId,
- cancellationToken,
- );
- case WithdrawalGroupStatus.PendingQueryingStatus: {
- return queryReserve(ws, withdrawalGroupId, cancellationToken);
- }
- case WithdrawalGroupStatus.PendingWaitConfirmBank: {
- return await processReserveBankStatus(ws, withdrawalGroupId);
- }
- case WithdrawalGroupStatus.PendingAml:
- // FIXME: Handle this case, withdrawal doesn't support AML yet.
- return TaskRunResult.backoff();
- case WithdrawalGroupStatus.PendingKyc:
- return processWithdrawalGroupPendingKyc(
- ws,
- withdrawalGroup,
- cancellationToken,
- );
- case WithdrawalGroupStatus.PendingReady:
- // Continue with the actual withdrawal!
- return await processWithdrawalGroupPendingReady(ws, withdrawalGroup);
- case WithdrawalGroupStatus.AbortingBank:
- return await processWithdrawalGroupAbortingBank(ws, withdrawalGroup);
- case WithdrawalGroupStatus.AbortedBank:
- case WithdrawalGroupStatus.AbortedExchange:
- case WithdrawalGroupStatus.FailedAbortingBank:
- case WithdrawalGroupStatus.SuspendedAbortingBank:
- case WithdrawalGroupStatus.SuspendedAml:
- case WithdrawalGroupStatus.SuspendedKyc:
- case WithdrawalGroupStatus.SuspendedQueryingStatus:
- case WithdrawalGroupStatus.SuspendedReady:
- case WithdrawalGroupStatus.SuspendedRegisteringBank:
- case WithdrawalGroupStatus.SuspendedWaitConfirmBank:
- case WithdrawalGroupStatus.Done:
- case WithdrawalGroupStatus.FailedBankAborted:
- // Nothing to do.
- return TaskRunResult.finished();
- default:
- assertUnreachable(withdrawalGroup.status);
- }
-}
-
-const AGE_MASK_GROUPS = "8:10:12:14:16:18"
- .split(":")
- .map((n) => parseInt(n, 10));
-
-export async function getExchangeWithdrawalInfo(
- ws: InternalWalletState,
- exchangeBaseUrl: string,
- instructedAmount: AmountJson,
- ageRestricted: number | undefined,
-): Promise<ExchangeWithdrawalDetails> {
- logger.trace("updating exchange");
- const exchange = await fetchFreshExchange(ws, exchangeBaseUrl);
-
- if (exchange.currency != instructedAmount.currency) {
- // Specifying the amount in the conversion input currency is not yet supported.
- // We might add support for it later.
- throw new Error(
- `withdrawal only supported when specifying target currency ${exchange.currency}`,
- );
- }
-
- const withdrawalAccountsList = await fetchWithdrawalAccountInfo(ws, {
- exchange,
- instructedAmount,
- });
-
- logger.trace("updating withdrawal denoms");
- await updateWithdrawalDenoms(ws, exchangeBaseUrl);
-
- logger.trace("getting candidate denoms");
- const denoms = await getCandidateWithdrawalDenoms(
- ws,
- exchangeBaseUrl,
- instructedAmount.currency,
- );
- logger.trace("selecting withdrawal denoms");
- const selectedDenoms = selectWithdrawalDenominations(
- instructedAmount,
- denoms,
- ws.config.testing.denomselAllowLate,
- );
-
- logger.trace("selection done");
-
- if (selectedDenoms.selectedDenoms.length === 0) {
- throw Error(
- `unable to withdraw from ${exchangeBaseUrl}, can't select denominations for instructed amount (${Amounts.stringify(
- instructedAmount,
- )}`,
- );
- }
-
- const exchangeWireAccounts: string[] = [];
-
- for (const account of exchange.wireInfo.accounts) {
- exchangeWireAccounts.push(account.payto_uri);
- }
-
- let hasDenomWithAgeRestriction = false;
-
- logger.trace("computing earliest deposit expiration");
-
- let earliestDepositExpiration: TalerProtocolTimestamp | undefined;
- for (let i = 0; i < selectedDenoms.selectedDenoms.length; i++) {
- const ds = selectedDenoms.selectedDenoms[i];
- // FIXME: Do in one transaction!
- const denom = await ws.db.runReadOnlyTx(["denominations"], async (tx) => {
- return ws.getDenomInfo(ws, tx, exchangeBaseUrl, ds.denomPubHash);
- });
- checkDbInvariant(!!denom);
- hasDenomWithAgeRestriction =
- hasDenomWithAgeRestriction || denom.denomPub.age_mask > 0;
- const expireDeposit = denom.stampExpireDeposit;
- if (!earliestDepositExpiration) {
- earliestDepositExpiration = expireDeposit;
- continue;
- }
- if (
- AbsoluteTime.cmp(
- AbsoluteTime.fromProtocolTimestamp(expireDeposit),
- AbsoluteTime.fromProtocolTimestamp(earliestDepositExpiration),
- ) < 0
- ) {
- earliestDepositExpiration = expireDeposit;
- }
- }
-
- checkLogicInvariant(!!earliestDepositExpiration);
-
- const possibleDenoms = await getCandidateWithdrawalDenoms(
- ws,
- exchangeBaseUrl,
- instructedAmount.currency,
- );
-
- let versionMatch;
- if (exchange.protocolVersionRange) {
- versionMatch = LibtoolVersion.compare(
- WALLET_EXCHANGE_PROTOCOL_VERSION,
- exchange.protocolVersionRange,
- );
-
- if (
- versionMatch &&
- !versionMatch.compatible &&
- versionMatch.currentCmp === -1
- ) {
- logger.warn(
- `wallet's support for exchange protocol version ${WALLET_EXCHANGE_PROTOCOL_VERSION} might be outdated ` +
- `(exchange has ${exchange.protocolVersionRange}), checking for updates`,
- );
- }
- }
-
- let tosAccepted = false;
- if (exchange.tosAcceptedTimestamp) {
- if (exchange.tosAcceptedEtag === exchange.tosCurrentEtag) {
- tosAccepted = true;
- }
- }
-
- const paytoUris = exchange.wireInfo.accounts.map((x) => x.payto_uri);
- if (!paytoUris) {
- throw Error("exchange is in invalid state");
- }
-
- const ret: ExchangeWithdrawalDetails = {
- earliestDepositExpiration,
- exchangePaytoUris: paytoUris,
- exchangeWireAccounts,
- exchangeCreditAccountDetails: withdrawalAccountsList,
- exchangeVersion: exchange.protocolVersionRange || "unknown",
- numOfferedDenoms: possibleDenoms.length,
- selectedDenoms,
- // FIXME: delete this field / replace by something we can display to the user
- trustedAuditorPubs: [],
- versionMatch,
- walletVersion: WALLET_EXCHANGE_PROTOCOL_VERSION,
- termsOfServiceAccepted: tosAccepted,
- withdrawalAmountEffective: Amounts.stringify(selectedDenoms.totalCoinValue),
- withdrawalAmountRaw: Amounts.stringify(instructedAmount),
- // TODO: remove hardcoding, this should be calculated from the denominations info
- // force enabled for testing
- ageRestrictionOptions: hasDenomWithAgeRestriction
- ? AGE_MASK_GROUPS
- : undefined,
- scopeInfo: exchange.scopeInfo,
- };
- return ret;
-}
-
-export interface GetWithdrawalDetailsForUriOpts {
- restrictAge?: number;
- notifyChangeFromPendingTimeoutMs?: number;
-}
-
-type WithdrawalOperationMemoryMap = {
- [uri: string]: boolean | undefined;
-};
-const ongoingChecks: WithdrawalOperationMemoryMap = {};
-/**
- * Get more information about a taler://withdraw URI.
- *
- * As side effects, the bank (via the bank integration API) is queried
- * and the exchange suggested by the bank is ephemerally added
- * to the wallet's list of known exchanges.
- */
-export async function getWithdrawalDetailsForUri(
- ws: InternalWalletState,
- talerWithdrawUri: string,
- opts: GetWithdrawalDetailsForUriOpts = {},
-): Promise<WithdrawUriInfoResponse> {
- logger.trace(`getting withdrawal details for URI ${talerWithdrawUri}`);
- const info = await getBankWithdrawalInfo(ws.http, talerWithdrawUri);
- logger.trace(`got bank info`);
- if (info.suggestedExchange) {
- try {
- // If the exchange entry doesn't exist yet,
- // it'll be created as an ephemeral entry.
- await fetchFreshExchange(ws, info.suggestedExchange);
- } catch (e) {
- // We still continued if it failed, as other exchanges might be available.
- // We don't want to fail if the bank-suggested exchange is broken/offline.
- logger.trace(
- `querying bank-suggested exchange (${info.suggestedExchange}) failed`,
- );
- }
- }
-
- const currency = Amounts.currencyOf(info.amount);
-
- const listExchangesResp = await listExchanges(ws);
- const possibleExchanges = listExchangesResp.exchanges.filter((x) => {
- return (
- x.currency === currency &&
- (x.exchangeUpdateStatus === ExchangeUpdateStatus.Ready ||
- x.exchangeUpdateStatus === ExchangeUpdateStatus.ReadyUpdate)
- );
- });
-
- // FIXME: this should be removed after the extended version of
- // withdrawal state machine. issue #8099
- if (
- info.status === "pending" &&
- opts.notifyChangeFromPendingTimeoutMs !== undefined &&
- !ongoingChecks[talerWithdrawUri]
- ) {
- ongoingChecks[talerWithdrawUri] = true;
- const bankApi = new TalerBankIntegrationHttpClient(
- info.apiBaseUrl,
- ws.http,
- );
- console.log(
- `waiting operation (${info.operationId}) to change from pending`,
- );
- bankApi
- .getWithdrawalOperationById(info.operationId, {
- old_state: "pending",
- timeoutMs: opts.notifyChangeFromPendingTimeoutMs,
- })
- .then((resp) => {
- console.log(
- `operation (${info.operationId}) to change to ${JSON.stringify(
- resp,
- undefined,
- 2,
- )}`,
- );
- ws.notify({
- type: NotificationType.WithdrawalOperationTransition,
- operationId: info.operationId,
- state: resp.type === "fail" ? info.status : resp.body.status,
- });
- ongoingChecks[talerWithdrawUri] = false;
- });
- }
-
- return {
- operationId: info.operationId,
- confirmTransferUrl: info.confirmTransferUrl,
- status: info.status,
- amount: Amounts.stringify(info.amount),
- defaultExchangeBaseUrl: info.suggestedExchange,
- possibleExchanges,
- };
-}
-
-export function augmentPaytoUrisForWithdrawal(
- plainPaytoUris: string[],
- reservePub: string,
- instructedAmount: AmountLike,
-): string[] {
- return plainPaytoUris.map((x) =>
- addPaytoQueryParams(x, {
- amount: Amounts.stringify(instructedAmount),
- message: `Taler Withdrawal ${reservePub}`,
- }),
- );
-}
-
-/**
- * Get payto URIs that can be used to fund a withdrawal operation.
- */
-export async function getFundingPaytoUris(
- tx: WalletDbReadOnlyTransaction<
- ["withdrawalGroups", "exchanges", "exchangeDetails"]
- >,
- withdrawalGroupId: string,
-): Promise<string[]> {
- const withdrawalGroup = await tx.withdrawalGroups.get(withdrawalGroupId);
- checkDbInvariant(!!withdrawalGroup);
- const exchangeDetails = await getExchangeWireDetailsInTx(
- tx,
- withdrawalGroup.exchangeBaseUrl,
- );
- if (!exchangeDetails) {
- logger.error(`exchange ${withdrawalGroup.exchangeBaseUrl} not found`);
- return [];
- }
- const plainPaytoUris =
- exchangeDetails.wireInfo?.accounts.map((x) => x.payto_uri) ?? [];
- if (!plainPaytoUris) {
- logger.error(
- `exchange ${withdrawalGroup.exchangeBaseUrl} has no wire info`,
- );
- return [];
- }
- return augmentPaytoUrisForWithdrawal(
- plainPaytoUris,
- withdrawalGroup.reservePub,
- withdrawalGroup.instructedAmount,
- );
-}
-
-async function getWithdrawalGroupRecordTx(
- db: DbAccess<typeof WalletStoresV1>,
- req: {
- withdrawalGroupId: string;
- },
-): Promise<WithdrawalGroupRecord | undefined> {
- return await db.runReadOnlyTx(["withdrawalGroups"], async (tx) => {
- return tx.withdrawalGroups.get(req.withdrawalGroupId);
- });
-}
-
-export function getReserveRequestTimeout(r: WithdrawalGroupRecord): Duration {
- return { d_ms: 60000 };
-}
-
-export function getBankStatusUrl(talerWithdrawUri: string): string {
- const uriResult = parseWithdrawUri(talerWithdrawUri);
- if (!uriResult) {
- throw Error(`can't parse withdrawal URL ${talerWithdrawUri}`);
- }
- const url = new URL(
- `withdrawal-operation/${uriResult.withdrawalOperationId}`,
- uriResult.bankIntegrationApiBaseUrl,
- );
- return url.href;
-}
-
-export function getBankAbortUrl(talerWithdrawUri: string): string {
- const uriResult = parseWithdrawUri(talerWithdrawUri);
- if (!uriResult) {
- throw Error(`can't parse withdrawal URL ${talerWithdrawUri}`);
- }
- const url = new URL(
- `withdrawal-operation/${uriResult.withdrawalOperationId}/abort`,
- uriResult.bankIntegrationApiBaseUrl,
- );
- return url.href;
-}
-
-async function registerReserveWithBank(
- ws: InternalWalletState,
- withdrawalGroupId: string,
-): Promise<void> {
- const withdrawalGroup = await ws.db.runReadOnlyTx(
- ["withdrawalGroups"],
- async (tx) => {
- return await tx.withdrawalGroups.get(withdrawalGroupId);
- },
- );
- const transactionId = constructTransactionIdentifier({
- tag: TransactionType.Withdrawal,
- withdrawalGroupId,
- });
- switch (withdrawalGroup?.status) {
- case WithdrawalGroupStatus.PendingWaitConfirmBank:
- case WithdrawalGroupStatus.PendingRegisteringBank:
- break;
- default:
- return;
- }
- if (
- withdrawalGroup.wgInfo.withdrawalType != WithdrawalRecordType.BankIntegrated
- ) {
- throw Error("expecting withdrarwal type = bank integrated");
- }
- const bankInfo = withdrawalGroup.wgInfo.bankInfo;
- if (!bankInfo) {
- return;
- }
- const bankStatusUrl = getBankStatusUrl(bankInfo.talerWithdrawUri);
- const reqBody = {
- reserve_pub: withdrawalGroup.reservePub,
- selected_exchange: bankInfo.exchangePaytoUri,
- };
- logger.info(`registering reserve with bank: ${j2s(reqBody)}`);
- const httpResp = await ws.http.fetch(bankStatusUrl, {
- method: "POST",
- body: reqBody,
- timeout: getReserveRequestTimeout(withdrawalGroup),
- });
- // FIXME: libeufin-bank currently doesn't return a response in the right format, so we don't validate at all.
- await readSuccessResponseJsonOrThrow(httpResp, codecForAny());
- const transitionInfo = await ws.db.runReadWriteTx(
- ["withdrawalGroups"],
- async (tx) => {
- const r = await tx.withdrawalGroups.get(withdrawalGroupId);
- if (!r) {
- return undefined;
- }
- switch (r.status) {
- case WithdrawalGroupStatus.PendingRegisteringBank:
- case WithdrawalGroupStatus.PendingWaitConfirmBank:
- break;
- default:
- return;
- }
- if (r.wgInfo.withdrawalType !== WithdrawalRecordType.BankIntegrated) {
- throw Error("invariant failed");
- }
- r.wgInfo.bankInfo.timestampReserveInfoPosted = timestampPreciseToDb(
- AbsoluteTime.toPreciseTimestamp(AbsoluteTime.now()),
- );
- const oldTxState = computeWithdrawalTransactionStatus(r);
- r.status = WithdrawalGroupStatus.PendingWaitConfirmBank;
- const newTxState = computeWithdrawalTransactionStatus(r);
- await tx.withdrawalGroups.put(r);
- return {
- oldTxState,
- newTxState,
- };
- },
- );
-
- notifyTransition(ws, transactionId, transitionInfo);
-}
-
-async function processReserveBankStatus(
- ws: InternalWalletState,
- withdrawalGroupId: string,
-): Promise<TaskRunResult> {
- const withdrawalGroup = await getWithdrawalGroupRecordTx(ws.db, {
- withdrawalGroupId,
- });
- const transactionId = constructTransactionIdentifier({
- tag: TransactionType.Withdrawal,
- withdrawalGroupId,
- });
- switch (withdrawalGroup?.status) {
- case WithdrawalGroupStatus.PendingWaitConfirmBank:
- case WithdrawalGroupStatus.PendingRegisteringBank:
- break;
- default:
- return TaskRunResult.backoff();
- }
-
- if (
- withdrawalGroup.wgInfo.withdrawalType != WithdrawalRecordType.BankIntegrated
- ) {
- throw Error("wrong withdrawal record type");
- }
- const bankInfo = withdrawalGroup.wgInfo.bankInfo;
- if (!bankInfo) {
- return TaskRunResult.backoff();
- }
-
- const bankStatusUrl = getBankStatusUrl(bankInfo.talerWithdrawUri);
-
- const statusResp = await ws.http.fetch(bankStatusUrl, {
- timeout: getReserveRequestTimeout(withdrawalGroup),
- });
- const status = await readSuccessResponseJsonOrThrow(
- statusResp,
- codecForWithdrawOperationStatusResponse(),
- );
-
- if (status.aborted) {
- logger.info("bank aborted the withdrawal");
- const transitionInfo = await ws.db.runReadWriteTx(
- ["withdrawalGroups"],
- async (tx) => {
- const r = await tx.withdrawalGroups.get(withdrawalGroupId);
- if (!r) {
- return;
- }
- switch (r.status) {
- case WithdrawalGroupStatus.PendingRegisteringBank:
- case WithdrawalGroupStatus.PendingWaitConfirmBank:
- break;
- default:
- return;
- }
- if (r.wgInfo.withdrawalType !== WithdrawalRecordType.BankIntegrated) {
- throw Error("invariant failed");
- }
- const now = AbsoluteTime.toPreciseTimestamp(AbsoluteTime.now());
- const oldTxState = computeWithdrawalTransactionStatus(r);
- r.wgInfo.bankInfo.timestampBankConfirmed = timestampPreciseToDb(now);
- r.status = WithdrawalGroupStatus.FailedBankAborted;
- const newTxState = computeWithdrawalTransactionStatus(r);
- await tx.withdrawalGroups.put(r);
- return {
- oldTxState,
- newTxState,
- };
- },
- );
- notifyTransition(ws, transactionId, transitionInfo);
- return TaskRunResult.finished();
- }
-
- // Bank still needs to know our reserve info
- if (!status.selection_done) {
- await registerReserveWithBank(ws, withdrawalGroupId);
- return await processReserveBankStatus(ws, withdrawalGroupId);
- }
-
- // FIXME: Why do we do this?!
- if (withdrawalGroup.status === WithdrawalGroupStatus.PendingRegisteringBank) {
- await registerReserveWithBank(ws, withdrawalGroupId);
- return await processReserveBankStatus(ws, withdrawalGroupId);
- }
-
- const transitionInfo = await ws.db.runReadWriteTx(
- ["withdrawalGroups"],
- async (tx) => {
- const r = await tx.withdrawalGroups.get(withdrawalGroupId);
- if (!r) {
- return undefined;
- }
- // Re-check reserve status within transaction
- switch (r.status) {
- case WithdrawalGroupStatus.PendingRegisteringBank:
- case WithdrawalGroupStatus.PendingWaitConfirmBank:
- break;
- default:
- return undefined;
- }
- if (r.wgInfo.withdrawalType !== WithdrawalRecordType.BankIntegrated) {
- throw Error("invariant failed");
- }
- const oldTxState = computeWithdrawalTransactionStatus(r);
- if (status.transfer_done) {
- logger.info("withdrawal: transfer confirmed by bank.");
- const now = AbsoluteTime.toPreciseTimestamp(AbsoluteTime.now());
- r.wgInfo.bankInfo.timestampBankConfirmed = timestampPreciseToDb(now);
- r.status = WithdrawalGroupStatus.PendingQueryingStatus;
- } else {
- logger.trace("withdrawal: transfer not yet confirmed by bank");
- r.wgInfo.bankInfo.confirmUrl = status.confirm_transfer_url;
- r.senderWire = status.sender_wire;
- }
- const newTxState = computeWithdrawalTransactionStatus(r);
- await tx.withdrawalGroups.put(r);
- return {
- oldTxState,
- newTxState,
- };
- },
- );
-
- notifyTransition(ws, transactionId, transitionInfo);
-
- if (transitionInfo) {
- return TaskRunResult.progress();
- } else {
- return TaskRunResult.backoff();
- }
-}
-
-export interface PrepareCreateWithdrawalGroupResult {
- withdrawalGroup: WithdrawalGroupRecord;
- transactionId: string;
- creationInfo?: {
- amount: AmountJson;
- canonExchange: string;
- };
-}
-
-export async function internalPrepareCreateWithdrawalGroup(
- ws: InternalWalletState,
- args: {
- reserveStatus: WithdrawalGroupStatus;
- amount: AmountJson;
- exchangeBaseUrl: string;
- forcedWithdrawalGroupId?: string;
- forcedDenomSel?: ForcedDenomSel;
- reserveKeyPair?: EddsaKeypair;
- restrictAge?: number;
- wgInfo: WgInfo;
- },
-): Promise<PrepareCreateWithdrawalGroupResult> {
- const reserveKeyPair =
- args.reserveKeyPair ?? (await ws.cryptoApi.createEddsaKeypair({}));
- const now = AbsoluteTime.toPreciseTimestamp(AbsoluteTime.now());
- const secretSeed = encodeCrock(getRandomBytes(32));
- const canonExchange = canonicalizeBaseUrl(args.exchangeBaseUrl);
- const amount = args.amount;
- const currency = Amounts.currencyOf(amount);
-
- let withdrawalGroupId;
-
- if (args.forcedWithdrawalGroupId) {
- withdrawalGroupId = args.forcedWithdrawalGroupId;
- const wgId = withdrawalGroupId;
- const existingWg = await ws.db.runReadOnlyTx(
- ["withdrawalGroups"],
- async (tx) => {
- return tx.withdrawalGroups.get(wgId);
- },
- );
-
- if (existingWg) {
- const transactionId = constructTransactionIdentifier({
- tag: TransactionType.Withdrawal,
- withdrawalGroupId: existingWg.withdrawalGroupId,
- });
- return { withdrawalGroup: existingWg, transactionId };
- }
- } else {
- withdrawalGroupId = encodeCrock(getRandomBytes(32));
- }
-
- await updateWithdrawalDenoms(ws, canonExchange);
- const denoms = await getCandidateWithdrawalDenoms(
- ws,
- canonExchange,
- currency,
- );
-
- let initialDenomSel: DenomSelectionState;
- const denomSelUid = encodeCrock(getRandomBytes(16));
- if (args.forcedDenomSel) {
- logger.warn("using forced denom selection");
- initialDenomSel = selectForcedWithdrawalDenominations(
- amount,
- denoms,
- args.forcedDenomSel,
- ws.config.testing.denomselAllowLate,
- );
- } else {
- initialDenomSel = selectWithdrawalDenominations(
- amount,
- denoms,
- ws.config.testing.denomselAllowLate,
- );
- }
-
- const withdrawalGroup: WithdrawalGroupRecord = {
- denomSelUid,
- denomsSel: initialDenomSel,
- exchangeBaseUrl: canonExchange,
- instructedAmount: Amounts.stringify(amount),
- timestampStart: timestampPreciseToDb(now),
- rawWithdrawalAmount: initialDenomSel.totalWithdrawCost,
- effectiveWithdrawalAmount: initialDenomSel.totalCoinValue,
- secretSeed,
- reservePriv: reserveKeyPair.priv,
- reservePub: reserveKeyPair.pub,
- status: args.reserveStatus,
- withdrawalGroupId,
- restrictAge: args.restrictAge,
- senderWire: undefined,
- timestampFinish: undefined,
- wgInfo: args.wgInfo,
- };
-
- await fetchFreshExchange(ws, canonExchange);
- const transactionId = constructTransactionIdentifier({
- tag: TransactionType.Withdrawal,
- withdrawalGroupId: withdrawalGroup.withdrawalGroupId,
- });
-
- return {
- withdrawalGroup,
- transactionId,
- creationInfo: {
- canonExchange,
- amount,
- },
- };
-}
-
-export interface PerformCreateWithdrawalGroupResult {
- withdrawalGroup: WithdrawalGroupRecord;
- transitionInfo: TransitionInfo | undefined;
-
- /**
- * Notification for the exchange state transition.
- *
- * Should be emitted after the transaction has succeeded.
- */
- exchangeNotif: WalletNotification | undefined;
-}
-
-export async function internalPerformCreateWithdrawalGroup(
- ws: InternalWalletState,
- tx: WalletDbReadWriteTransaction<
- ["withdrawalGroups", "reserves", "exchanges"]
- >,
- prep: PrepareCreateWithdrawalGroupResult,
-): Promise<PerformCreateWithdrawalGroupResult> {
- const { withdrawalGroup } = prep;
- if (!prep.creationInfo) {
- return {
- withdrawalGroup,
- transitionInfo: undefined,
- exchangeNotif: undefined,
- };
- }
- const existingWg = await tx.withdrawalGroups.get(
- withdrawalGroup.withdrawalGroupId,
- );
- if (existingWg) {
- return {
- withdrawalGroup: existingWg,
- exchangeNotif: undefined,
- transitionInfo: undefined,
- };
- }
- await tx.withdrawalGroups.add(withdrawalGroup);
- await tx.reserves.put({
- reservePub: withdrawalGroup.reservePub,
- reservePriv: withdrawalGroup.reservePriv,
- });
-
- const exchange = await tx.exchanges.get(withdrawalGroup.exchangeBaseUrl);
- if (exchange) {
- exchange.lastWithdrawal = timestampPreciseToDb(TalerPreciseTimestamp.now());
- await tx.exchanges.put(exchange);
- }
-
- const oldTxState = {
- major: TransactionMajorState.None,
- minor: undefined,
- };
- const newTxState = computeWithdrawalTransactionStatus(withdrawalGroup);
- const transitionInfo = {
- oldTxState,
- newTxState,
- };
-
- const exchangeUsedRes = await markExchangeUsed(
- ws,
- tx,
- prep.withdrawalGroup.exchangeBaseUrl,
- );
-
- const ctx = new WithdrawTransactionContext(
- ws,
- withdrawalGroup.withdrawalGroupId,
- );
-
- ws.taskScheduler.startShepherdTask(ctx.taskId);
-
- return {
- withdrawalGroup,
- transitionInfo,
- exchangeNotif: exchangeUsedRes.notif,
- };
-}
-
-/**
- * Create a withdrawal group.
- *
- * If a forcedWithdrawalGroupId is given and a
- * withdrawal group with this ID already exists,
- * the existing one is returned. No conflict checking
- * of the other arguments is done in that case.
- */
-export async function internalCreateWithdrawalGroup(
- ws: InternalWalletState,
- args: {
- reserveStatus: WithdrawalGroupStatus;
- amount: AmountJson;
- exchangeBaseUrl: string;
- forcedWithdrawalGroupId?: string;
- forcedDenomSel?: ForcedDenomSel;
- reserveKeyPair?: EddsaKeypair;
- restrictAge?: number;
- wgInfo: WgInfo;
- },
-): Promise<WithdrawalGroupRecord> {
- const prep = await internalPrepareCreateWithdrawalGroup(ws, args);
- const transactionId = constructTransactionIdentifier({
- tag: TransactionType.Withdrawal,
- withdrawalGroupId: prep.withdrawalGroup.withdrawalGroupId,
- });
- const res = await ws.db.runReadWriteTx(
- ["withdrawalGroups", "reserves", "exchanges", "exchangeDetails"],
- async (tx) => {
- return await internalPerformCreateWithdrawalGroup(ws, tx, prep);
- },
- );
- if (res.exchangeNotif) {
- ws.notify(res.exchangeNotif);
- }
- notifyTransition(ws, transactionId, res.transitionInfo);
- return res.withdrawalGroup;
-}
-
-export async function acceptWithdrawalFromUri(
- ws: InternalWalletState,
- req: {
- talerWithdrawUri: string;
- selectedExchange: string;
- forcedDenomSel?: ForcedDenomSel;
- restrictAge?: number;
- },
-): Promise<AcceptWithdrawalResponse> {
- const selectedExchange = canonicalizeBaseUrl(req.selectedExchange);
- logger.info(
- `accepting withdrawal via ${req.talerWithdrawUri}, canonicalized selected exchange ${selectedExchange}`,
- );
- const existingWithdrawalGroup = await ws.db.runReadOnlyTx(
- ["withdrawalGroups"],
- async (tx) => {
- return await tx.withdrawalGroups.indexes.byTalerWithdrawUri.get(
- req.talerWithdrawUri,
- );
- },
- );
-
- if (existingWithdrawalGroup) {
- let url: string | undefined;
- if (
- existingWithdrawalGroup.wgInfo.withdrawalType ===
- WithdrawalRecordType.BankIntegrated
- ) {
- url = existingWithdrawalGroup.wgInfo.bankInfo.confirmUrl;
- }
- return {
- reservePub: existingWithdrawalGroup.reservePub,
- confirmTransferUrl: url,
- transactionId: constructTransactionIdentifier({
- tag: TransactionType.Withdrawal,
- withdrawalGroupId: existingWithdrawalGroup.withdrawalGroupId,
- }),
- };
- }
-
- await fetchFreshExchange(ws, selectedExchange);
- const withdrawInfo = await getBankWithdrawalInfo(
- ws.http,
- req.talerWithdrawUri,
- );
- const exchangePaytoUri = await getExchangePaytoUri(
- ws,
- selectedExchange,
- withdrawInfo.wireTypes,
- );
-
- const exchange = await fetchFreshExchange(ws, selectedExchange);
-
- const withdrawalAccountList = await fetchWithdrawalAccountInfo(ws, {
- exchange,
- instructedAmount: withdrawInfo.amount,
- });
-
- const withdrawalGroup = await internalCreateWithdrawalGroup(ws, {
- amount: withdrawInfo.amount,
- exchangeBaseUrl: req.selectedExchange,
- wgInfo: {
- withdrawalType: WithdrawalRecordType.BankIntegrated,
- exchangeCreditAccounts: withdrawalAccountList,
- bankInfo: {
- exchangePaytoUri,
- talerWithdrawUri: req.talerWithdrawUri,
- confirmUrl: withdrawInfo.confirmTransferUrl,
- timestampBankConfirmed: undefined,
- timestampReserveInfoPosted: undefined,
- },
- },
- restrictAge: req.restrictAge,
- forcedDenomSel: req.forcedDenomSel,
- reserveStatus: WithdrawalGroupStatus.PendingRegisteringBank,
- });
-
- const withdrawalGroupId = withdrawalGroup.withdrawalGroupId;
-
- const ctx = new WithdrawTransactionContext(ws, withdrawalGroupId);
-
- const transactionId = ctx.transactionId;
-
- // We do this here, as the reserve should be registered before we return,
- // so that we can redirect the user to the bank's status page.
- await processReserveBankStatus(ws, withdrawalGroupId);
- const processedWithdrawalGroup = await getWithdrawalGroupRecordTx(ws.db, {
- withdrawalGroupId,
- });
- if (
- processedWithdrawalGroup?.status === WithdrawalGroupStatus.FailedBankAborted
- ) {
- throw TalerError.fromDetail(
- TalerErrorCode.WALLET_WITHDRAWAL_OPERATION_ABORTED_BY_BANK,
- {},
- );
- }
-
- ws.taskScheduler.startShepherdTask(ctx.taskId);
-
- return {
- reservePub: withdrawalGroup.reservePub,
- confirmTransferUrl: withdrawInfo.confirmTransferUrl,
- transactionId,
- };
-}
-
-async function fetchAccount(
- ws: InternalWalletState,
- instructedAmount: AmountJson,
- acct: ExchangeWireAccount,
- reservePub?: string,
-): Promise<WithdrawalExchangeAccountDetails> {
- let paytoUri: string;
- let transferAmount: AmountString | undefined = undefined;
- let currencySpecification: CurrencySpecification | undefined = undefined;
- if (acct.conversion_url != null) {
- const reqUrl = new URL("cashin-rate", acct.conversion_url);
- reqUrl.searchParams.set(
- "amount_credit",
- Amounts.stringify(instructedAmount),
- );
- const httpResp = await ws.http.fetch(reqUrl.href);
- const respOrErr = await readSuccessResponseJsonOrErrorCode(
- httpResp,
- codecForCashinConversionResponse(),
- );
- if (respOrErr.isError) {
- return {
- status: "error",
- paytoUri: acct.payto_uri,
- conversionError: respOrErr.talerErrorResponse,
- };
- }
- const resp = respOrErr.response;
- paytoUri = acct.payto_uri;
- transferAmount = resp.amount_debit;
- const configUrl = new URL("config", acct.conversion_url);
- const configResp = await ws.http.fetch(configUrl.href);
- const configRespOrError = await readSuccessResponseJsonOrErrorCode(
- configResp,
- codecForConversionBankConfig(),
- );
- if (configRespOrError.isError) {
- return {
- status: "error",
- paytoUri: acct.payto_uri,
- conversionError: configRespOrError.talerErrorResponse,
- };
- }
- const configParsed = configRespOrError.response;
- currencySpecification = configParsed.fiat_currency_specification;
- } else {
- paytoUri = acct.payto_uri;
- transferAmount = Amounts.stringify(instructedAmount);
- }
- paytoUri = addPaytoQueryParams(paytoUri, {
- amount: Amounts.stringify(transferAmount),
- });
- if (reservePub != null) {
- paytoUri = addPaytoQueryParams(paytoUri, {
- message: `Taler Withdrawal ${reservePub}`,
- });
- }
- const acctInfo: WithdrawalExchangeAccountDetails = {
- status: "ok",
- paytoUri,
- transferAmount,
- currencySpecification,
- creditRestrictions: acct.credit_restrictions,
- };
- if (transferAmount != null) {
- acctInfo.transferAmount = transferAmount;
- }
- return acctInfo;
-}
-
-/**
- * Gather information about bank accounts that can be used for
- * withdrawals. This includes accounts that are in a different
- * currency and require conversion.
- */
-async function fetchWithdrawalAccountInfo(
- ws: InternalWalletState,
- req: {
- exchange: ReadyExchangeSummary;
- instructedAmount: AmountJson;
- reservePub?: string;
- },
-): Promise<WithdrawalExchangeAccountDetails[]> {
- const { exchange, instructedAmount } = req;
- const withdrawalAccounts: WithdrawalExchangeAccountDetails[] = [];
- for (let acct of exchange.wireInfo.accounts) {
- const acctInfo = await fetchAccount(
- ws,
- req.instructedAmount,
- acct,
- req.reservePub,
- );
- withdrawalAccounts.push(acctInfo);
- }
- return withdrawalAccounts;
-}
-
-/**
- * Create a manual withdrawal operation.
- *
- * Adds the corresponding exchange as a trusted exchange if it is neither
- * audited nor trusted already.
- *
- * Asynchronously starts the withdrawal.
- */
-export async function createManualWithdrawal(
- ws: InternalWalletState,
- req: {
- exchangeBaseUrl: string;
- amount: AmountLike;
- restrictAge?: number;
- forcedDenomSel?: ForcedDenomSel;
- },
-): Promise<AcceptManualWithdrawalResult> {
- const { exchangeBaseUrl } = req;
- const amount = Amounts.parseOrThrow(req.amount);
- const exchange = await fetchFreshExchange(ws, exchangeBaseUrl);
-
- if (exchange.currency != amount.currency) {
- throw Error(
- "manual withdrawal with conversion from foreign currency is not yet supported",
- );
- }
- const reserveKeyPair: EddsaKeypair = await ws.cryptoApi.createEddsaKeypair(
- {},
- );
-
- const withdrawalAccountsList = await fetchWithdrawalAccountInfo(ws, {
- exchange,
- instructedAmount: amount,
- reservePub: reserveKeyPair.pub,
- });
-
- const withdrawalGroup = await internalCreateWithdrawalGroup(ws, {
- amount: Amounts.jsonifyAmount(req.amount),
- wgInfo: {
- withdrawalType: WithdrawalRecordType.BankManual,
- exchangeCreditAccounts: withdrawalAccountsList,
- },
- exchangeBaseUrl: req.exchangeBaseUrl,
- forcedDenomSel: req.forcedDenomSel,
- restrictAge: req.restrictAge,
- reserveStatus: WithdrawalGroupStatus.PendingQueryingStatus,
- reserveKeyPair,
- });
-
- const withdrawalGroupId = withdrawalGroup.withdrawalGroupId;
- const ctx = new WithdrawTransactionContext(ws, withdrawalGroupId);
-
- const transactionId = ctx.transactionId;
-
- const exchangePaytoUris = await ws.db.runReadOnlyTx(
- ["withdrawalGroups", "exchanges", "exchangeDetails"],
- async (tx) => {
- return await getFundingPaytoUris(tx, withdrawalGroup.withdrawalGroupId);
- },
- );
-
- ws.taskScheduler.startShepherdTask(ctx.taskId);
-
- return {
- reservePub: withdrawalGroup.reservePub,
- exchangePaytoUris: exchangePaytoUris,
- withdrawalAccountsList: withdrawalAccountsList,
- transactionId,
- };
-}