diff options
Diffstat (limited to 'packages/taler-wallet-core/src/operations/withdraw.ts')
-rw-r--r-- | packages/taler-wallet-core/src/operations/withdraw.ts | 2754 |
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, - }; -} |