diff options
Diffstat (limited to 'packages/taler-wallet-core/src/withdraw.ts')
-rw-r--r-- | packages/taler-wallet-core/src/withdraw.ts | 3455 |
1 files changed, 3455 insertions, 0 deletions
diff --git a/packages/taler-wallet-core/src/withdraw.ts b/packages/taler-wallet-core/src/withdraw.ts new file mode 100644 index 000000000..106bd93a4 --- /dev/null +++ b/packages/taler-wallet-core/src/withdraw.ts @@ -0,0 +1,3455 @@ +/* + This file is part of GNU Taler + (C) 2019-2024 Taler Systems SA + + GNU Taler is free software; you can redistribute it and/or modify it under the + terms of the GNU General Public License as published by the Free Software + Foundation; either version 3, or (at your option) any later version. + + GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY + WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR + A PARTICULAR PURPOSE. See the GNU General Public License for more details. + + You should have received a copy of the GNU General Public License along with + GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> + */ + +/** + * @fileoverview Implementation of Taler withdrawals, both + * bank-integrated and manual. + */ + +/** + * Imports. + */ +import { + AbsoluteTime, + AcceptManualWithdrawalResult, + AcceptWithdrawalResponse, + AgeRestriction, + Amount, + AmountJson, + AmountLike, + AmountString, + Amounts, + AsyncFlag, + BankWithdrawDetails, + CancellationToken, + CoinStatus, + CurrencySpecification, + DenomKeyType, + DenomSelItem, + DenomSelectionState, + Duration, + EddsaPrivateKeyString, + ExchangeBatchWithdrawRequest, + ExchangeUpdateStatus, + ExchangeWireAccount, + ExchangeWithdrawBatchResponse, + ExchangeWithdrawRequest, + ExchangeWithdrawResponse, + ExchangeWithdrawalDetails, + ForcedDenomSel, + GetWithdrawalDetailsForAmountRequest, + HttpStatusCode, + LibtoolVersion, + Logger, + NotificationType, + ObservabilityEventType, + PrepareBankIntegratedWithdrawalResponse, + TalerBankIntegrationHttpClient, + TalerError, + TalerErrorCode, + TalerErrorDetail, + TalerPreciseTimestamp, + Transaction, + TransactionAction, + TransactionIdStr, + TransactionMajorState, + TransactionMinorState, + TransactionState, + TransactionType, + URL, + UnblindedSignature, + WalletNotification, + WithdrawUriInfoResponse, + WithdrawalDetailsForAmount, + WithdrawalExchangeAccountDetails, + WithdrawalType, + addPaytoQueryParams, + assertUnreachable, + canonicalizeBaseUrl, + checkDbInvariant, + checkLogicInvariant, + codeForBankWithdrawalOperationPostResponse, + codecForBankWithdrawalOperationStatus, + codecForCashinConversionResponse, + codecForConversionBankConfig, + codecForExchangeWithdrawBatchResponse, + codecForReserveStatus, + codecForWalletKycUuid, + codecForWithdrawOperationStatusResponse, + encodeCrock, + getErrorDetailFromException, + getRandomBytes, + j2s, + makeErrorDetail, + parseWithdrawUri, +} from "@gnu-taler/taler-util"; +import { + HttpRequestLibrary, + HttpResponse, + readSuccessResponseJsonOrErrorCode, + readSuccessResponseJsonOrThrow, + readTalerErrorResponse, + throwUnexpectedRequestError, +} from "@gnu-taler/taler-util/http"; +import { + PendingTaskType, + TaskIdStr, + TaskRunResult, + TaskRunResultType, + TombstoneTag, + TransactionContext, + TransitionResult, + TransitionResultType, + constructTaskIdentifier, + makeCoinAvailable, + makeCoinsVisible, +} from "./common.js"; +import { EddsaKeypair } from "./crypto/cryptoImplementation.js"; +import { + CoinRecord, + CoinSourceType, + DenominationRecord, + DenominationVerificationStatus, + KycPendingInfo, + PlanchetRecord, + PlanchetStatus, + WalletDbReadOnlyTransaction, + WalletDbReadWriteTransaction, + WalletDbStoresArr, + WalletStoresV1, + WgInfo, + WithdrawalGroupRecord, + WithdrawalGroupStatus, + WithdrawalRecordType, + timestampAbsoluteFromDb, + timestampPreciseFromDb, + timestampPreciseToDb, +} from "./db.js"; +import { + selectForcedWithdrawalDenominations, + selectWithdrawalDenominations, +} from "./denomSelection.js"; +import { isWithdrawableDenom } from "./denominations.js"; +import { + ReadyExchangeSummary, + fetchFreshExchange, + getExchangePaytoUri, + getExchangeWireDetailsInTx, + listExchanges, + markExchangeUsed, +} from "./exchanges.js"; +import { DbAccess } from "./query.js"; +import { + TransitionInfo, + constructTransactionIdentifier, + isUnsuccessfulTransaction, + notifyTransition, + parseTransactionIdentifier, +} from "./transactions.js"; +import { + WALLET_BANK_INTEGRATION_PROTOCOL_VERSION, + WALLET_EXCHANGE_PROTOCOL_VERSION, +} from "./versions.js"; +import { WalletExecutionContext, getDenomInfo } from "./wallet.js"; + +/** + * Logger for this file. + */ +const logger = new Logger("withdraw.ts"); + +/** + * Update the materialized withdrawal transaction based + * on the withdrawal group record. + */ +async function updateWithdrawalTransaction( + ctx: WithdrawTransactionContext, + tx: WalletDbReadWriteTransaction< + [ + "withdrawalGroups", + "transactions", + "operationRetries", + "exchanges", + "exchangeDetails", + ] + >, +): Promise<void> { + const wgRecord = await tx.withdrawalGroups.get(ctx.withdrawalGroupId); + if (!wgRecord) { + await tx.transactions.delete(ctx.transactionId); + return; + } + const retryRecord = await tx.operationRetries.get(ctx.taskId); + + let transactionItem: Transaction; + + if (wgRecord.wgInfo.withdrawalType === WithdrawalRecordType.BankIntegrated) { + const txState = computeWithdrawalTransactionStatus(wgRecord); + transactionItem = { + type: TransactionType.Withdrawal, + txState, + txActions: computeWithdrawalTransactionActions(wgRecord), + amountEffective: isUnsuccessfulTransaction(txState) + ? Amounts.stringify(Amounts.zeroOfAmount(wgRecord.instructedAmount)) + : Amounts.stringify(wgRecord.denomsSel.totalCoinValue), + amountRaw: Amounts.stringify(wgRecord.instructedAmount), + withdrawalDetails: { + type: WithdrawalType.TalerBankIntegrationApi, + confirmed: wgRecord.wgInfo.bankInfo.timestampBankConfirmed + ? true + : false, + exchangeCreditAccountDetails: wgRecord.wgInfo.exchangeCreditAccounts, + reservePub: wgRecord.reservePub, + bankConfirmationUrl: wgRecord.wgInfo.bankInfo.confirmUrl, + reserveIsReady: + wgRecord.status === WithdrawalGroupStatus.Done || + wgRecord.status === WithdrawalGroupStatus.PendingReady, + }, + kycUrl: wgRecord.kycUrl, + exchangeBaseUrl: wgRecord.exchangeBaseUrl, + timestamp: timestampPreciseFromDb(wgRecord.timestampStart), + transactionId: ctx.transactionId, + }; + } else if ( + wgRecord.wgInfo.withdrawalType === WithdrawalRecordType.BankManual + ) { + const exchangeDetails = await getExchangeWireDetailsInTx( + tx, + wgRecord.exchangeBaseUrl, + ); + const plainPaytoUris = + exchangeDetails?.wireInfo?.accounts.map((x) => x.payto_uri) ?? []; + + const exchangePaytoUris = augmentPaytoUrisForWithdrawal( + plainPaytoUris, + wgRecord.reservePub, + wgRecord.instructedAmount, + ); + + const txState = computeWithdrawalTransactionStatus(wgRecord); + + transactionItem = { + type: TransactionType.Withdrawal, + txState, + txActions: computeWithdrawalTransactionActions(wgRecord), + amountEffective: isUnsuccessfulTransaction(txState) + ? Amounts.stringify(Amounts.zeroOfAmount(wgRecord.instructedAmount)) + : Amounts.stringify(wgRecord.denomsSel.totalCoinValue), + amountRaw: Amounts.stringify(wgRecord.instructedAmount), + withdrawalDetails: { + type: WithdrawalType.ManualTransfer, + reservePub: wgRecord.reservePub, + exchangePaytoUris, + exchangeCreditAccountDetails: wgRecord.wgInfo.exchangeCreditAccounts, + reserveIsReady: + wgRecord.status === WithdrawalGroupStatus.Done || + wgRecord.status === WithdrawalGroupStatus.PendingReady, + }, + kycUrl: wgRecord.kycUrl, + exchangeBaseUrl: wgRecord.exchangeBaseUrl, + timestamp: timestampPreciseFromDb(wgRecord.timestampStart), + transactionId: ctx.transactionId, + }; + } else { + // FIXME: If this is an orphaned withdrawal for a p2p transaction, we + // still might want to report the withdrawal. + return; + } + + if (retryRecord?.lastError) { + transactionItem.error = retryRecord.lastError; + } + + await tx.transactions.put({ + currency: Amounts.currencyOf(wgRecord.instructedAmount), + transactionItem, + exchanges: [wgRecord.exchangeBaseUrl], + }); + + // FIXME: Handle orphaned withdrawals where the p2p or recoup tx was deleted? +} + +export class WithdrawTransactionContext implements TransactionContext { + readonly transactionId: TransactionIdStr; + readonly taskId: TaskIdStr; + + constructor( + public wex: WalletExecutionContext, + public withdrawalGroupId: string, + ) { + this.transactionId = constructTransactionIdentifier({ + tag: TransactionType.Withdrawal, + withdrawalGroupId, + }); + this.taskId = constructTaskIdentifier({ + tag: PendingTaskType.Withdraw, + withdrawalGroupId, + }); + } + + /** + * Transition a withdrawal transaction. + * Extra object stores may be accessed during the transition. + */ + async transition<StoreNameArray extends WalletDbStoresArr = []>( + opts: { extraStores?: StoreNameArray; transactionLabel?: string }, + f: ( + rec: WithdrawalGroupRecord | undefined, + tx: WalletDbReadWriteTransaction< + [ + "withdrawalGroups", + "transactions", + "operationRetries", + "exchanges", + "exchangeDetails", + ...StoreNameArray, + ] + >, + ) => Promise<TransitionResult<WithdrawalGroupRecord>>, + ): Promise<TransitionInfo | undefined> { + const baseStores = [ + "withdrawalGroups" as const, + "transactions" as const, + "operationRetries" as const, + "exchanges" as const, + "exchangeDetails" as const, + ]; + let stores = opts.extraStores + ? [...baseStores, ...opts.extraStores] + : baseStores; + const transitionInfo = await this.wex.db.runReadWriteTx( + { storeNames: stores }, + async (tx) => { + const wgRec = await tx.withdrawalGroups.get(this.withdrawalGroupId); + let oldTxState: TransactionState; + if (wgRec) { + oldTxState = computeWithdrawalTransactionStatus(wgRec); + } else { + oldTxState = { + major: TransactionMajorState.None, + }; + } + const res = await f(wgRec, tx); + switch (res.type) { + case TransitionResultType.Transition: { + await tx.withdrawalGroups.put(res.rec); + await updateWithdrawalTransaction(this, tx); + const newTxState = computeWithdrawalTransactionStatus(res.rec); + return { + oldTxState, + newTxState, + }; + } + case TransitionResultType.Delete: + await tx.withdrawalGroups.delete(this.withdrawalGroupId); + await updateWithdrawalTransaction(this, tx); + return { + oldTxState, + newTxState: { + major: TransactionMajorState.None, + }, + }; + default: + return undefined; + } + }, + ); + notifyTransition(this.wex, this.transactionId, transitionInfo); + return transitionInfo; + } + + async deleteTransaction(): Promise<void> { + await this.transition( + { + extraStores: ["tombstones"], + transactionLabel: "delete-transaction-withdraw", + }, + async (rec, tx) => { + if (!rec) { + return TransitionResult.stay(); + } + if (rec) { + await tx.tombstones.put({ + id: + TombstoneTag.DeleteWithdrawalGroup + ":" + rec.withdrawalGroupId, + }); + } + return TransitionResult.delete(); + }, + ); + } + + async suspendTransaction(): Promise<void> { + const { withdrawalGroupId } = this; + await this.transition( + { + transactionLabel: "suspend-transaction-withdraw", + }, + async (wg, _tx) => { + if (!wg) { + logger.warn(`withdrawal group ${withdrawalGroupId} not found`); + return TransitionResult.stay(); + } + let newStatus: WithdrawalGroupStatus | undefined = undefined; + switch (wg.status) { + case WithdrawalGroupStatus.PendingReady: + newStatus = WithdrawalGroupStatus.SuspendedReady; + break; + case WithdrawalGroupStatus.AbortingBank: + newStatus = WithdrawalGroupStatus.SuspendedAbortingBank; + break; + case WithdrawalGroupStatus.PendingWaitConfirmBank: + newStatus = WithdrawalGroupStatus.SuspendedWaitConfirmBank; + break; + case WithdrawalGroupStatus.PendingRegisteringBank: + newStatus = WithdrawalGroupStatus.SuspendedRegisteringBank; + break; + case WithdrawalGroupStatus.PendingQueryingStatus: + newStatus = WithdrawalGroupStatus.SuspendedQueryingStatus; + break; + case WithdrawalGroupStatus.PendingKyc: + newStatus = WithdrawalGroupStatus.SuspendedKyc; + break; + case WithdrawalGroupStatus.PendingAml: + newStatus = WithdrawalGroupStatus.SuspendedAml; + break; + default: + logger.warn( + `Unsupported 'suspend' on withdrawal transaction in status ${wg.status}`, + ); + return TransitionResult.stay(); + } + wg.status = newStatus; + return TransitionResult.transition(wg); + }, + ); + } + + async abortTransaction(): Promise<void> { + const { withdrawalGroupId } = this; + await this.transition( + { + transactionLabel: "abort-transaction-withdraw", + }, + async (wg, _tx) => { + if (!wg) { + logger.warn(`withdrawal group ${withdrawalGroupId} not found`); + return TransitionResult.stay(); + } + let newStatus: WithdrawalGroupStatus | undefined = undefined; + switch (wg.status) { + case WithdrawalGroupStatus.SuspendedRegisteringBank: + case WithdrawalGroupStatus.SuspendedWaitConfirmBank: + case WithdrawalGroupStatus.PendingWaitConfirmBank: + case WithdrawalGroupStatus.PendingRegisteringBank: + newStatus = WithdrawalGroupStatus.AbortingBank; + break; + case WithdrawalGroupStatus.SuspendedAml: + case WithdrawalGroupStatus.SuspendedKyc: + case WithdrawalGroupStatus.SuspendedQueryingStatus: + case WithdrawalGroupStatus.SuspendedReady: + case WithdrawalGroupStatus.PendingAml: + case WithdrawalGroupStatus.PendingKyc: + case WithdrawalGroupStatus.PendingQueryingStatus: + newStatus = WithdrawalGroupStatus.AbortedExchange; + break; + case WithdrawalGroupStatus.PendingReady: + newStatus = WithdrawalGroupStatus.SuspendedReady; + break; + case WithdrawalGroupStatus.SuspendedAbortingBank: + case WithdrawalGroupStatus.AbortingBank: + case WithdrawalGroupStatus.AbortedUserRefused: + // No transition needed, but not an error + return TransitionResult.stay(); + case WithdrawalGroupStatus.DialogProposed: + newStatus = WithdrawalGroupStatus.AbortedUserRefused; + break; + case WithdrawalGroupStatus.Done: + case WithdrawalGroupStatus.FailedBankAborted: + case WithdrawalGroupStatus.AbortedExchange: + case WithdrawalGroupStatus.AbortedBank: + case WithdrawalGroupStatus.FailedAbortingBank: + case WithdrawalGroupStatus.AbortedOtherWallet: + // Not allowed + throw Error("abort not allowed in current state"); + default: + assertUnreachable(wg.status); + } + wg.status = newStatus; + return TransitionResult.transition(wg); + }, + ); + } + + async resumeTransaction(): Promise<void> { + const { withdrawalGroupId } = this; + await this.transition( + { + transactionLabel: "resume-transaction-withdraw", + }, + async (wg, _tx) => { + if (!wg) { + logger.warn(`withdrawal group ${withdrawalGroupId} not found`); + return TransitionResult.stay(); + } + let newStatus: WithdrawalGroupStatus | undefined = undefined; + switch (wg.status) { + case WithdrawalGroupStatus.SuspendedReady: + newStatus = WithdrawalGroupStatus.PendingReady; + break; + case WithdrawalGroupStatus.SuspendedAbortingBank: + newStatus = WithdrawalGroupStatus.AbortingBank; + break; + case WithdrawalGroupStatus.SuspendedWaitConfirmBank: + newStatus = WithdrawalGroupStatus.PendingWaitConfirmBank; + break; + case WithdrawalGroupStatus.SuspendedQueryingStatus: + newStatus = WithdrawalGroupStatus.PendingQueryingStatus; + break; + case WithdrawalGroupStatus.SuspendedRegisteringBank: + newStatus = WithdrawalGroupStatus.PendingRegisteringBank; + break; + case WithdrawalGroupStatus.SuspendedAml: + newStatus = WithdrawalGroupStatus.PendingAml; + break; + case WithdrawalGroupStatus.SuspendedKyc: + newStatus = WithdrawalGroupStatus.PendingKyc; + break; + default: + logger.warn( + `Unsupported 'resume' on withdrawal transaction in status ${wg.status}`, + ); + return TransitionResult.stay(); + } + wg.status = newStatus; + return TransitionResult.transition(wg); + }, + ); + } + + async failTransaction(): Promise<void> { + const { withdrawalGroupId } = this; + await this.transition( + { + transactionLabel: "fail-transaction-withdraw", + }, + async (wg, _tx) => { + if (!wg) { + logger.warn(`withdrawal group ${withdrawalGroupId} not found`); + return TransitionResult.stay(); + } + let newStatus: WithdrawalGroupStatus | undefined = undefined; + switch (wg.status) { + case WithdrawalGroupStatus.SuspendedAbortingBank: + case WithdrawalGroupStatus.AbortingBank: + newStatus = WithdrawalGroupStatus.FailedAbortingBank; + break; + default: + return TransitionResult.stay(); + } + wg.status = newStatus; + return TransitionResult.transition(wg); + }, + ); + } +} + +/** + * Compute the DD37 transaction state of a withdrawal transaction + * from the database's withdrawal group record. + */ +export function computeWithdrawalTransactionStatus( + wgRecord: WithdrawalGroupRecord, +): TransactionState { + switch (wgRecord.status) { + case WithdrawalGroupStatus.FailedBankAborted: + return { + major: TransactionMajorState.Aborted, + }; + case WithdrawalGroupStatus.Done: + return { + major: TransactionMajorState.Done, + }; + case WithdrawalGroupStatus.PendingRegisteringBank: + return { + major: TransactionMajorState.Pending, + minor: TransactionMinorState.BankRegisterReserve, + }; + case WithdrawalGroupStatus.PendingReady: + return { + major: TransactionMajorState.Pending, + minor: TransactionMinorState.WithdrawCoins, + }; + case WithdrawalGroupStatus.PendingQueryingStatus: + return { + major: TransactionMajorState.Pending, + minor: TransactionMinorState.ExchangeWaitReserve, + }; + case WithdrawalGroupStatus.PendingWaitConfirmBank: + return { + major: TransactionMajorState.Pending, + minor: TransactionMinorState.BankConfirmTransfer, + }; + case WithdrawalGroupStatus.AbortingBank: + return { + major: TransactionMajorState.Aborting, + minor: TransactionMinorState.Bank, + }; + case WithdrawalGroupStatus.SuspendedAbortingBank: + return { + major: TransactionMajorState.SuspendedAborting, + minor: TransactionMinorState.Bank, + }; + case WithdrawalGroupStatus.SuspendedQueryingStatus: + return { + major: TransactionMajorState.Suspended, + minor: TransactionMinorState.ExchangeWaitReserve, + }; + case WithdrawalGroupStatus.SuspendedRegisteringBank: + return { + major: TransactionMajorState.Suspended, + minor: TransactionMinorState.BankRegisterReserve, + }; + case WithdrawalGroupStatus.SuspendedWaitConfirmBank: + return { + major: TransactionMajorState.Suspended, + minor: TransactionMinorState.BankConfirmTransfer, + }; + case WithdrawalGroupStatus.SuspendedReady: + return { + major: TransactionMajorState.Suspended, + minor: TransactionMinorState.WithdrawCoins, + }; + case WithdrawalGroupStatus.PendingAml: + return { + major: TransactionMajorState.Pending, + minor: TransactionMinorState.AmlRequired, + }; + case WithdrawalGroupStatus.PendingKyc: + return { + major: TransactionMajorState.Pending, + minor: TransactionMinorState.KycRequired, + }; + case WithdrawalGroupStatus.SuspendedAml: + return { + major: TransactionMajorState.Suspended, + minor: TransactionMinorState.AmlRequired, + }; + case WithdrawalGroupStatus.SuspendedKyc: + return { + major: TransactionMajorState.Suspended, + minor: TransactionMinorState.KycRequired, + }; + case WithdrawalGroupStatus.FailedAbortingBank: + return { + major: TransactionMajorState.Failed, + minor: TransactionMinorState.AbortingBank, + }; + case WithdrawalGroupStatus.AbortedExchange: + return { + major: TransactionMajorState.Aborted, + minor: TransactionMinorState.Exchange, + }; + case WithdrawalGroupStatus.AbortedBank: + return { + major: TransactionMajorState.Aborted, + minor: TransactionMinorState.Bank, + }; + case WithdrawalGroupStatus.AbortedUserRefused: + return { + major: TransactionMajorState.Aborted, + minor: TransactionMinorState.Refused, + }; + case WithdrawalGroupStatus.DialogProposed: + return { + major: TransactionMajorState.Dialog, + minor: TransactionMinorState.Proposed, + }; + case WithdrawalGroupStatus.AbortedOtherWallet: + return { + major: TransactionMajorState.Aborted, + minor: TransactionMinorState.CompletedByOtherWallet, + }; + } +} + +/** + * Compute DD37 transaction actions for a withdrawal transaction + * based on the database's withdrawal group record. + */ +export function computeWithdrawalTransactionActions( + wgRecord: WithdrawalGroupRecord, +): TransactionAction[] { + switch (wgRecord.status) { + case WithdrawalGroupStatus.FailedBankAborted: + return [TransactionAction.Delete]; + case WithdrawalGroupStatus.Done: + return [TransactionAction.Delete]; + case WithdrawalGroupStatus.PendingRegisteringBank: + return [TransactionAction.Suspend, TransactionAction.Abort]; + case WithdrawalGroupStatus.PendingReady: + return [TransactionAction.Suspend, TransactionAction.Abort]; + case WithdrawalGroupStatus.PendingQueryingStatus: + return [TransactionAction.Suspend, TransactionAction.Abort]; + case WithdrawalGroupStatus.PendingWaitConfirmBank: + return [TransactionAction.Suspend, TransactionAction.Abort]; + case WithdrawalGroupStatus.AbortingBank: + return [TransactionAction.Suspend, TransactionAction.Fail]; + case WithdrawalGroupStatus.SuspendedAbortingBank: + return [TransactionAction.Resume, TransactionAction.Fail]; + case WithdrawalGroupStatus.SuspendedQueryingStatus: + return [TransactionAction.Resume, TransactionAction.Abort]; + case WithdrawalGroupStatus.SuspendedRegisteringBank: + return [TransactionAction.Resume, TransactionAction.Abort]; + case WithdrawalGroupStatus.SuspendedWaitConfirmBank: + return [TransactionAction.Resume, TransactionAction.Abort]; + case WithdrawalGroupStatus.SuspendedReady: + return [TransactionAction.Resume, TransactionAction.Abort]; + case WithdrawalGroupStatus.PendingAml: + return [TransactionAction.Resume, TransactionAction.Abort]; + case WithdrawalGroupStatus.PendingKyc: + return [TransactionAction.Resume, TransactionAction.Abort]; + case WithdrawalGroupStatus.SuspendedAml: + return [TransactionAction.Resume, TransactionAction.Abort]; + case WithdrawalGroupStatus.SuspendedKyc: + return [TransactionAction.Resume, TransactionAction.Abort]; + case WithdrawalGroupStatus.FailedAbortingBank: + case WithdrawalGroupStatus.AbortedExchange: + case WithdrawalGroupStatus.AbortedBank: + case WithdrawalGroupStatus.AbortedOtherWallet: + case WithdrawalGroupStatus.AbortedUserRefused: + return [TransactionAction.Delete]; + case WithdrawalGroupStatus.DialogProposed: + return [TransactionAction.Abort]; + } +} + +async function processWithdrawalGroupDialogProposed( + ctx: WithdrawTransactionContext, + withdrawalGroup: WithdrawalGroupRecord, +): Promise<TaskRunResult> { + if ( + withdrawalGroup.wgInfo.withdrawalType !== + WithdrawalRecordType.BankIntegrated + ) { + throw new Error( + "processWithdrawalGroupDialogProposed called in unexpected state", + ); + } + + const talerWithdrawUri = withdrawalGroup.wgInfo.bankInfo.talerWithdrawUri; + + const parsedUri = parseWithdrawUri(talerWithdrawUri); + + checkLogicInvariant(!!parsedUri); + + const wopid = parsedUri.withdrawalOperationId; + + const url = new URL( + `withdrawal-operation/${wopid}`, + parsedUri.bankIntegrationApiBaseUrl, + ); + + url.searchParams.set("old_state", "pending"); + url.searchParams.set("long_poll_ms", "30000"); + + const resp = await ctx.wex.http.fetch(url.href, { + method: "GET", + cancellationToken: ctx.wex.cancellationToken, + }); + + // If the bank claims that the withdrawal operation is already + // pending, but we're still in DialogProposed, some other wallet + // must've completed the withdrawal, we're giving up. + + switch (resp.status) { + case HttpStatusCode.Ok: { + const body = await readSuccessResponseJsonOrThrow( + resp, + codecForBankWithdrawalOperationStatus(), + ); + if (body.status !== "pending") { + await ctx.transition({}, async (rec) => { + switch (rec?.status) { + case WithdrawalGroupStatus.DialogProposed: { + rec.status = WithdrawalGroupStatus.AbortedOtherWallet; + return TransitionResult.transition(rec); + } + } + return TransitionResult.stay(); + }); + } + break; + } + } + + return TaskRunResult.longpollReturnedPending(); +} + +/** + * Get information about a withdrawal from + * a taler://withdraw URI by asking the bank. + * + * FIXME: Move into bank client. + */ +export async function getBankWithdrawalInfo( + http: HttpRequestLibrary, + talerWithdrawUri: string, +): Promise<BankWithdrawDetails> { + const uriResult = parseWithdrawUri(talerWithdrawUri); + if (!uriResult) { + throw Error(`can't parse URL ${talerWithdrawUri}`); + } + + const bankApi = new TalerBankIntegrationHttpClient( + uriResult.bankIntegrationApiBaseUrl, + http, + ); + + const { body: config } = await bankApi.getConfig(); + + if (!bankApi.isCompatible(config.version)) { + throw TalerError.fromDetail( + TalerErrorCode.WALLET_BANK_INTEGRATION_PROTOCOL_VERSION_INCOMPATIBLE, + { + bankProtocolVersion: config.version, + walletProtocolVersion: WALLET_BANK_INTEGRATION_PROTOCOL_VERSION, + }, + "bank integration protocol version not compatible with wallet", + ); + } + + const resp = await bankApi.getWithdrawalOperationById( + uriResult.withdrawalOperationId, + ); + + if (resp.type === "fail") { + throw TalerError.fromUncheckedDetail(resp.detail); + } + const { body: status } = resp; + + logger.info(`bank withdrawal operation status: ${j2s(status)}`); + + return { + operationId: uriResult.withdrawalOperationId, + apiBaseUrl: uriResult.bankIntegrationApiBaseUrl, + amount: Amounts.parseOrThrow(status.amount), + confirmTransferUrl: status.confirm_transfer_url, + senderWire: status.sender_wire, + suggestedExchange: status.suggested_exchange, + wireTypes: status.wire_types, + status: status.status, + }; +} + +/** + * Return denominations that can potentially used for a withdrawal. + */ +async function getCandidateWithdrawalDenoms( + wex: WalletExecutionContext, + exchangeBaseUrl: string, + currency: string, +): Promise<DenominationRecord[]> { + return await wex.db.runReadOnlyTx( + { storeNames: ["denominations"] }, + async (tx) => { + return getCandidateWithdrawalDenomsTx(wex, tx, exchangeBaseUrl, currency); + }, + ); +} + +export async function getCandidateWithdrawalDenomsTx( + wex: WalletExecutionContext, + tx: WalletDbReadOnlyTransaction<["denominations"]>, + exchangeBaseUrl: string, + currency: string, +): Promise<DenominationRecord[]> { + // FIXME(https://bugs.taler.net/n/8446): Use denom groups instead of querying all denominations! + const allDenoms = + await tx.denominations.indexes.byExchangeBaseUrl.getAll(exchangeBaseUrl); + return allDenoms + .filter((d) => d.currency === currency) + .filter((d) => + isWithdrawableDenom(d, wex.ws.config.testing.denomselAllowLate), + ); +} + +/** + * Generate a planchet for a coin index in a withdrawal group. + * Does not actually withdraw the coin yet. + * + * Split up so that we can parallelize the crypto, but serialize + * the exchange requests per reserve. + */ +async function processPlanchetGenerate( + wex: WalletExecutionContext, + withdrawalGroup: WithdrawalGroupRecord, + coinIdx: number, +): Promise<void> { + let planchet = await wex.db.runReadOnlyTx( + { storeNames: ["planchets"] }, + async (tx) => { + return tx.planchets.indexes.byGroupAndIndex.get([ + withdrawalGroup.withdrawalGroupId, + coinIdx, + ]); + }, + ); + if (planchet) { + return; + } + let ci = 0; + let isSkipped = false; + let maybeDenomPubHash: string | undefined; + for (let di = 0; di < withdrawalGroup.denomsSel.selectedDenoms.length; di++) { + const d = withdrawalGroup.denomsSel.selectedDenoms[di]; + if (coinIdx >= ci && coinIdx < ci + d.count) { + maybeDenomPubHash = d.denomPubHash; + if (coinIdx >= ci + d.count - (d.skip ?? 0)) { + isSkipped = true; + } + break; + } + ci += d.count; + } + if (isSkipped) { + return; + } + if (!maybeDenomPubHash) { + throw Error("invariant violated"); + } + const denomPubHash = maybeDenomPubHash; + + const denom = await wex.db.runReadOnlyTx( + { storeNames: ["denominations"] }, + async (tx) => { + return getDenomInfo( + wex, + tx, + withdrawalGroup.exchangeBaseUrl, + denomPubHash, + ); + }, + ); + checkDbInvariant(!!denom); + const r = await wex.cryptoApi.createPlanchet({ + denomPub: denom.denomPub, + feeWithdraw: Amounts.parseOrThrow(denom.feeWithdraw), + reservePriv: withdrawalGroup.reservePriv, + reservePub: withdrawalGroup.reservePub, + value: Amounts.parseOrThrow(denom.value), + coinIndex: coinIdx, + secretSeed: withdrawalGroup.secretSeed, + restrictAge: withdrawalGroup.restrictAge, + }); + const newPlanchet: PlanchetRecord = { + blindingKey: r.blindingKey, + coinEv: r.coinEv, + coinEvHash: r.coinEvHash, + coinIdx, + coinPriv: r.coinPriv, + coinPub: r.coinPub, + denomPubHash: r.denomPubHash, + planchetStatus: PlanchetStatus.Pending, + withdrawSig: r.withdrawSig, + withdrawalGroupId: withdrawalGroup.withdrawalGroupId, + ageCommitmentProof: r.ageCommitmentProof, + lastError: undefined, + }; + await wex.db.runReadWriteTx({ storeNames: ["planchets"] }, async (tx) => { + const p = await tx.planchets.indexes.byGroupAndIndex.get([ + withdrawalGroup.withdrawalGroupId, + coinIdx, + ]); + if (p) { + planchet = p; + return; + } + await tx.planchets.put(newPlanchet); + planchet = newPlanchet; + }); +} + +interface WithdrawalRequestBatchArgs { + coinStartIndex: number; + + batchSize: number; +} + +interface WithdrawalBatchResult { + coinIdxs: number[]; + batchResp: ExchangeWithdrawBatchResponse; +} + +// FIXME: Move to exchange API types +enum ExchangeAmlStatus { + Normal = 0, + Pending = 1, + Frozen = 2, +} + +async function handleKycRequired( + wex: WalletExecutionContext, + withdrawalGroup: WithdrawalGroupRecord, + resp: HttpResponse, + startIdx: number, + requestCoinIdxs: number[], +): Promise<void> { + logger.info("withdrawal requires KYC"); + const respJson = await resp.json(); + const uuidResp = codecForWalletKycUuid().decode(respJson); + const withdrawalGroupId = withdrawalGroup.withdrawalGroupId; + const ctx = new WithdrawTransactionContext(wex, withdrawalGroupId); + logger.info(`kyc uuid response: ${j2s(uuidResp)}`); + const exchangeUrl = withdrawalGroup.exchangeBaseUrl; + const userType = "individual"; + const kycInfo: KycPendingInfo = { + paytoHash: uuidResp.h_payto, + requirementRow: uuidResp.requirement_row, + }; + const url = new URL( + `kyc-check/${kycInfo.requirementRow}/${kycInfo.paytoHash}/${userType}`, + exchangeUrl, + ); + logger.info(`kyc url ${url.href}`); + const kycStatusRes = await wex.http.fetch(url.href, { + method: "GET", + cancellationToken: wex.cancellationToken, + }); + let kycUrl: string; + let amlStatus: ExchangeAmlStatus | undefined; + if ( + kycStatusRes.status === HttpStatusCode.Ok || + // FIXME: NoContent is not expected https://docs.taler.net/core/api-exchange.html#post--purses-$PURSE_PUB-merge + // remove after the exchange is fixed or clarified + kycStatusRes.status === HttpStatusCode.NoContent + ) { + logger.warn("kyc requested, but already fulfilled"); + return; + } else if (kycStatusRes.status === HttpStatusCode.Accepted) { + const kycStatus = await kycStatusRes.json(); + logger.info(`kyc status: ${j2s(kycStatus)}`); + kycUrl = kycStatus.kyc_url; + } else if ( + kycStatusRes.status === HttpStatusCode.UnavailableForLegalReasons + ) { + const kycStatus = await kycStatusRes.json(); + logger.info(`aml status: ${j2s(kycStatus)}`); + amlStatus = kycStatus.aml_status; + } else { + throw Error(`unexpected response from kyc-check (${kycStatusRes.status})`); + } + + await ctx.transition( + { + extraStores: ["planchets"], + }, + async (wg2, tx) => { + if (!wg2) { + return TransitionResult.stay(); + } + for (let i = startIdx; i < requestCoinIdxs.length; i++) { + let planchet = await tx.planchets.indexes.byGroupAndIndex.get([ + withdrawalGroup.withdrawalGroupId, + requestCoinIdxs[i], + ]); + if (!planchet) { + continue; + } + planchet.planchetStatus = PlanchetStatus.KycRequired; + await tx.planchets.put(planchet); + } + if (wg2.status !== WithdrawalGroupStatus.PendingReady) { + return TransitionResult.stay(); + } + wg2.kycPending = { + paytoHash: uuidResp.h_payto, + requirementRow: uuidResp.requirement_row, + }; + wg2.kycUrl = kycUrl; + wg2.status = + amlStatus === ExchangeAmlStatus.Normal || amlStatus === undefined + ? WithdrawalGroupStatus.PendingKyc + : amlStatus === ExchangeAmlStatus.Pending + ? WithdrawalGroupStatus.PendingAml + : amlStatus === ExchangeAmlStatus.Frozen + ? WithdrawalGroupStatus.SuspendedAml + : assertUnreachable(amlStatus); + return TransitionResult.transition(wg2); + }, + ); +} + +/** + * Send the withdrawal request for a generated planchet to the exchange. + * + * The verification of the response is done asynchronously to enable parallelism. + */ +async function processPlanchetExchangeBatchRequest( + wex: WalletExecutionContext, + wgContext: WithdrawalGroupStatusInfo, + args: WithdrawalRequestBatchArgs, +): Promise<WithdrawalBatchResult> { + const withdrawalGroup: WithdrawalGroupRecord = wgContext.wgRecord; + logger.info( + `processing planchet exchange batch request ${withdrawalGroup.withdrawalGroupId}, start=${args.coinStartIndex}, len=${args.batchSize}`, + ); + + const batchReq: ExchangeBatchWithdrawRequest = { planchets: [] }; + // Indices of coins that are included in the batch request + const requestCoinIdxs: number[] = []; + + await wex.db.runReadOnlyTx( + { storeNames: ["planchets", "denominations"] }, + async (tx) => { + for ( + let coinIdx = args.coinStartIndex; + coinIdx < args.coinStartIndex + args.batchSize && + coinIdx < wgContext.numPlanchets; + coinIdx++ + ) { + let planchet = await tx.planchets.indexes.byGroupAndIndex.get([ + withdrawalGroup.withdrawalGroupId, + coinIdx, + ]); + if (!planchet) { + continue; + } + if (planchet.planchetStatus === PlanchetStatus.WithdrawalDone) { + logger.warn("processPlanchet: planchet already withdrawn"); + continue; + } + if (planchet.planchetStatus === PlanchetStatus.AbortedReplaced) { + continue; + } + const denom = await getDenomInfo( + wex, + tx, + withdrawalGroup.exchangeBaseUrl, + planchet.denomPubHash, + ); + + if (!denom) { + logger.error("db inconsistent: denom for planchet not found"); + continue; + } + + const planchetReq: ExchangeWithdrawRequest = { + denom_pub_hash: planchet.denomPubHash, + reserve_sig: planchet.withdrawSig, + coin_ev: planchet.coinEv, + }; + batchReq.planchets.push(planchetReq); + requestCoinIdxs.push(coinIdx); + } + }, + ); + + if (batchReq.planchets.length == 0) { + logger.warn("empty withdrawal batch"); + return { + batchResp: { ev_sigs: [] }, + coinIdxs: [], + }; + } + + async function storeCoinError( + errDetail: TalerErrorDetail, + coinIdx: number, + ): Promise<void> { + logger.trace(`withdrawal request failed: ${j2s(errDetail)}`); + await wex.db.runReadWriteTx({ storeNames: ["planchets"] }, async (tx) => { + let planchet = await tx.planchets.indexes.byGroupAndIndex.get([ + withdrawalGroup.withdrawalGroupId, + coinIdx, + ]); + if (!planchet) { + return; + } + planchet.lastError = errDetail; + await tx.planchets.put(planchet); + }); + } + + // FIXME: handle individual error codes better! + + const reqUrl = new URL( + `reserves/${withdrawalGroup.reservePub}/batch-withdraw`, + withdrawalGroup.exchangeBaseUrl, + ).href; + + try { + const resp = await wex.http.fetch(reqUrl, { + method: "POST", + body: batchReq, + cancellationToken: wex.cancellationToken, + timeout: Duration.fromSpec({ seconds: 40 }), + }); + if (resp.status === HttpStatusCode.UnavailableForLegalReasons) { + await handleKycRequired(wex, withdrawalGroup, resp, 0, requestCoinIdxs); + return { + batchResp: { ev_sigs: [] }, + coinIdxs: [], + }; + } + if (resp.status === HttpStatusCode.Gone) { + const e = await readTalerErrorResponse(resp); + // FIXME: Store in place of the planchet that is actually affected! + await storeCoinError(e, requestCoinIdxs[0]); + return { + batchResp: { ev_sigs: [] }, + coinIdxs: [], + }; + } + const r = await readSuccessResponseJsonOrThrow( + resp, + codecForExchangeWithdrawBatchResponse(), + ); + return { + coinIdxs: requestCoinIdxs, + batchResp: r, + }; + } catch (e) { + const errDetail = getErrorDetailFromException(e); + // We don't know which coin is affected, so we store the error + // with the first coin of the batch. + await storeCoinError(errDetail, requestCoinIdxs[0]); + return { + batchResp: { ev_sigs: [] }, + coinIdxs: [], + }; + } +} + +async function processPlanchetVerifyAndStoreCoin( + wex: WalletExecutionContext, + wgContext: WithdrawalGroupStatusInfo, + coinIdx: number, + resp: ExchangeWithdrawResponse, +): Promise<void> { + const withdrawalGroup = wgContext.wgRecord; + logger.trace(`checking and storing planchet idx=${coinIdx}`); + const d = await wex.db.runReadOnlyTx( + { storeNames: ["planchets", "denominations"] }, + async (tx) => { + let planchet = await tx.planchets.indexes.byGroupAndIndex.get([ + withdrawalGroup.withdrawalGroupId, + coinIdx, + ]); + if (!planchet) { + return; + } + if (planchet.planchetStatus === PlanchetStatus.WithdrawalDone) { + logger.warn("processPlanchet: planchet already withdrawn"); + return; + } + const denomInfo = await getDenomInfo( + wex, + tx, + withdrawalGroup.exchangeBaseUrl, + planchet.denomPubHash, + ); + if (!denomInfo) { + return; + } + return { + planchet, + denomInfo, + exchangeBaseUrl: withdrawalGroup.exchangeBaseUrl, + }; + }, + ); + + if (!d) { + return; + } + + const transactionId = constructTransactionIdentifier({ + tag: TransactionType.Withdrawal, + withdrawalGroupId: wgContext.wgRecord.withdrawalGroupId, + }); + + const { planchet, denomInfo } = d; + + const planchetDenomPub = denomInfo.denomPub; + if (planchetDenomPub.cipher !== DenomKeyType.Rsa) { + throw Error(`cipher (${planchetDenomPub.cipher}) not supported`); + } + + let evSig = resp.ev_sig; + if (!(evSig.cipher === DenomKeyType.Rsa)) { + throw Error("unsupported cipher"); + } + + const denomSigRsa = await wex.cryptoApi.rsaUnblind({ + bk: planchet.blindingKey, + blindedSig: evSig.blinded_rsa_signature, + pk: planchetDenomPub.rsa_public_key, + }); + + const isValid = await wex.cryptoApi.rsaVerify({ + hm: planchet.coinPub, + pk: planchetDenomPub.rsa_public_key, + sig: denomSigRsa.sig, + }); + + if (!isValid) { + await wex.db.runReadWriteTx({ storeNames: ["planchets"] }, async (tx) => { + let planchet = await tx.planchets.indexes.byGroupAndIndex.get([ + withdrawalGroup.withdrawalGroupId, + coinIdx, + ]); + if (!planchet) { + return; + } + planchet.lastError = makeErrorDetail( + TalerErrorCode.WALLET_EXCHANGE_COIN_SIGNATURE_INVALID, + {}, + "invalid signature from the exchange after unblinding", + ); + await tx.planchets.put(planchet); + }); + return; + } + + let denomSig: UnblindedSignature; + if (planchetDenomPub.cipher === DenomKeyType.Rsa) { + denomSig = { + cipher: planchetDenomPub.cipher, + rsa_signature: denomSigRsa.sig, + }; + } else { + throw Error("unsupported cipher"); + } + + const coin: CoinRecord = { + blindingKey: planchet.blindingKey, + coinPriv: planchet.coinPriv, + coinPub: planchet.coinPub, + denomPubHash: planchet.denomPubHash, + denomSig, + coinEvHash: planchet.coinEvHash, + exchangeBaseUrl: d.exchangeBaseUrl, + status: CoinStatus.Fresh, + coinSource: { + type: CoinSourceType.Withdraw, + coinIndex: coinIdx, + reservePub: withdrawalGroup.reservePub, + withdrawalGroupId: withdrawalGroup.withdrawalGroupId, + }, + sourceTransactionId: transactionId, + maxAge: withdrawalGroup.restrictAge ?? AgeRestriction.AGE_UNRESTRICTED, + ageCommitmentProof: planchet.ageCommitmentProof, + spendAllocation: undefined, + }; + + const planchetCoinPub = planchet.coinPub; + + wgContext.planchetsFinished.add(planchet.coinPub); + + await wex.db.runReadWriteTx( + { storeNames: ["planchets", "coins", "coinAvailability", "denominations"] }, + async (tx) => { + const p = await tx.planchets.get(planchetCoinPub); + if (!p || p.planchetStatus === PlanchetStatus.WithdrawalDone) { + return; + } + p.planchetStatus = PlanchetStatus.WithdrawalDone; + p.lastError = undefined; + await tx.planchets.put(p); + await makeCoinAvailable(wex, tx, coin); + }, + ); +} + +/** + * Make sure that denominations that currently can be used for withdrawal + * are validated, and the result of validation is stored in the database. + */ +export async function updateWithdrawalDenoms( + wex: WalletExecutionContext, + exchangeBaseUrl: string, +): Promise<void> { + logger.trace( + `updating denominations used for withdrawal for ${exchangeBaseUrl}`, + ); + const exchangeDetails = await wex.db.runReadOnlyTx( + { storeNames: ["exchanges", "exchangeDetails"] }, + async (tx) => { + return getExchangeWireDetailsInTx(tx, exchangeBaseUrl); + }, + ); + if (!exchangeDetails) { + logger.error("exchange details not available"); + throw Error(`exchange ${exchangeBaseUrl} details not available`); + } + // First do a pass where the validity of candidate denominations + // is checked and the result is stored in the database. + logger.trace("getting candidate denominations"); + const denominations = await getCandidateWithdrawalDenoms( + wex, + exchangeBaseUrl, + exchangeDetails.currency, + ); + logger.trace(`got ${denominations.length} candidate denominations`); + const batchSize = 500; + let current = 0; + + while (current < denominations.length) { + const updatedDenominations: DenominationRecord[] = []; + // Do a batch of batchSize + for ( + let batchIdx = 0; + batchIdx < batchSize && current < denominations.length; + batchIdx++, current++ + ) { + const denom = denominations[current]; + if ( + denom.verificationStatus === DenominationVerificationStatus.Unverified + ) { + logger.trace( + `Validating denomination (${current + 1}/${ + denominations.length + }) signature of ${denom.denomPubHash}`, + ); + let valid = false; + if (wex.ws.config.testing.insecureTrustExchange) { + valid = true; + } else { + const res = await wex.cryptoApi.isValidDenom({ + denom, + masterPub: exchangeDetails.masterPublicKey, + }); + valid = res.valid; + } + logger.trace(`Done validating ${denom.denomPubHash}`); + if (!valid) { + logger.warn( + `Signature check for denomination h=${denom.denomPubHash} failed`, + ); + denom.verificationStatus = DenominationVerificationStatus.VerifiedBad; + } else { + denom.verificationStatus = + DenominationVerificationStatus.VerifiedGood; + } + updatedDenominations.push(denom); + } + } + if (updatedDenominations.length > 0) { + logger.trace("writing denomination batch to db"); + await wex.db.runReadWriteTx( + { storeNames: ["denominations"] }, + async (tx) => { + for (let i = 0; i < updatedDenominations.length; i++) { + const denom = updatedDenominations[i]; + await tx.denominations.put(denom); + } + }, + ); + wex.ws.denomInfoCache.clear(); + logger.trace("done with DB write"); + } + } +} + +/** + * Update the information about a reserve that is stored in the wallet + * by querying the reserve's exchange. + * + * If the reserve have funds that are not allocated in a withdrawal group yet + * and are big enough to withdraw with available denominations, + * create a new withdrawal group for the remaining amount. + */ +async function processQueryReserve( + wex: WalletExecutionContext, + withdrawalGroupId: string, +): Promise<TaskRunResult> { + const ctx = new WithdrawTransactionContext(wex, withdrawalGroupId); + const withdrawalGroup = await getWithdrawalGroupRecordTx(wex.db, { + withdrawalGroupId, + }); + if (!withdrawalGroup) { + return TaskRunResult.finished(); + } + if (withdrawalGroup.status !== WithdrawalGroupStatus.PendingQueryingStatus) { + return TaskRunResult.backoff(); + } + const reservePub = withdrawalGroup.reservePub; + + const reserveUrl = new URL( + `reserves/${reservePub}`, + withdrawalGroup.exchangeBaseUrl, + ); + reserveUrl.searchParams.set("timeout_ms", "30000"); + + logger.trace(`querying reserve status via ${reserveUrl.href}`); + + const resp = await wex.http.fetch(reserveUrl.href, { + timeout: getReserveRequestTimeout(withdrawalGroup), + cancellationToken: wex.cancellationToken, + }); + + logger.trace(`reserve status code: HTTP ${resp.status}`); + + const result = await readSuccessResponseJsonOrErrorCode( + resp, + codecForReserveStatus(), + ); + + if (result.isError) { + logger.trace( + `got reserve status error, EC=${result.talerErrorResponse.code}`, + ); + if (resp.status === HttpStatusCode.NotFound) { + return TaskRunResult.longpollReturnedPending(); + } else { + throwUnexpectedRequestError(resp, result.talerErrorResponse); + } + } + + logger.trace(`got reserve status ${j2s(result.response)}`); + + const transitionResult = await ctx.transition({}, async (wg) => { + if (!wg) { + logger.warn(`withdrawal group ${withdrawalGroupId} not found`); + return TransitionResult.stay(); + } + wg.status = WithdrawalGroupStatus.PendingReady; + wg.reserveBalanceAmount = Amounts.stringify(result.response.balance); + return TransitionResult.transition(wg); + }); + + if (transitionResult) { + return TaskRunResult.progress(); + } else { + return TaskRunResult.backoff(); + } +} + +/** + * Withdrawal context that is kept in-memory. + * + * Used to store some cached info during a withdrawal operation. + */ +interface WithdrawalGroupStatusInfo { + numPlanchets: number; + planchetsFinished: Set<string>; + + /** + * Cached withdrawal group record from the database. + */ + wgRecord: WithdrawalGroupRecord; +} + +async function processWithdrawalGroupAbortingBank( + wex: WalletExecutionContext, + withdrawalGroup: WithdrawalGroupRecord, +): Promise<TaskRunResult> { + const { withdrawalGroupId } = withdrawalGroup; + const ctx = new WithdrawTransactionContext(wex, withdrawalGroupId); + const wgInfo = withdrawalGroup.wgInfo; + if (wgInfo.withdrawalType != WithdrawalRecordType.BankIntegrated) { + throw Error("invalid state (aborting(bank) without bank info"); + } + const abortUrl = getBankAbortUrl(wgInfo.bankInfo.talerWithdrawUri); + logger.info(`aborting withdrawal at ${abortUrl}`); + const abortResp = await wex.http.fetch(abortUrl, { + method: "POST", + body: {}, + cancellationToken: wex.cancellationToken, + }); + logger.info(`abort response status: ${abortResp.status}`); + + await ctx.transition({}, async (wg) => { + if (!wg) { + return TransitionResult.stay(); + } + wg.status = WithdrawalGroupStatus.AbortedBank; + wg.timestampFinish = timestampPreciseToDb(TalerPreciseTimestamp.now()); + return TransitionResult.transition(wg); + }); + return TaskRunResult.finished(); +} + +async function processWithdrawalGroupPendingKyc( + wex: WalletExecutionContext, + withdrawalGroup: WithdrawalGroupRecord, +): Promise<TaskRunResult> { + const ctx = new WithdrawTransactionContext( + wex, + withdrawalGroup.withdrawalGroupId, + ); + const userType = "individual"; + const kycInfo = withdrawalGroup.kycPending; + if (!kycInfo) { + throw Error("no kyc info available in pending(kyc)"); + } + const exchangeUrl = withdrawalGroup.exchangeBaseUrl; + const url = new URL( + `kyc-check/${kycInfo.requirementRow}/${kycInfo.paytoHash}/${userType}`, + exchangeUrl, + ); + url.searchParams.set("timeout_ms", "30000"); + logger.info(`long-polling for withdrawal KYC status via ${url.href}`); + const kycStatusRes = await wex.http.fetch(url.href, { + method: "GET", + cancellationToken: wex.cancellationToken, + }); + logger.info(`kyc long-polling response status: HTTP ${kycStatusRes.status}`); + if ( + kycStatusRes.status === HttpStatusCode.Ok || + // FIXME: NoContent is not expected https://docs.taler.net/core/api-exchange.html#post--purses-$PURSE_PUB-merge + // remove after the exchange is fixed or clarified + kycStatusRes.status === HttpStatusCode.NoContent + ) { + await ctx.transition({}, async (rec) => { + if (!rec) { + return TransitionResult.stay(); + } + switch (rec.status) { + case WithdrawalGroupStatus.PendingKyc: { + delete rec.kycPending; + delete rec.kycUrl; + rec.status = WithdrawalGroupStatus.PendingReady; + return TransitionResult.transition(rec); + } + default: + return TransitionResult.stay(); + } + }); + } else if (kycStatusRes.status === HttpStatusCode.Accepted) { + const kycStatus = await kycStatusRes.json(); + logger.info(`kyc status: ${j2s(kycStatus)}`); + const kycUrl = kycStatus.kyc_url; + if (typeof kycUrl === "string") { + await ctx.transition({}, async (rec) => { + if (!rec) { + return TransitionResult.stay(); + } + switch (rec.status) { + case WithdrawalGroupStatus.PendingReady: { + rec.kycUrl = kycUrl; + return TransitionResult.transition(rec); + } + } + return TransitionResult.stay(); + }); + } + } else if ( + kycStatusRes.status === HttpStatusCode.UnavailableForLegalReasons + ) { + const kycStatus = await kycStatusRes.json(); + logger.info(`aml status: ${j2s(kycStatus)}`); + } else { + throw Error(`unexpected response from kyc-check (${kycStatusRes.status})`); + } + return TaskRunResult.backoff(); +} + +/** + * Select new denominations for a withdrawal group. + * Necessary when denominations expired or got revoked + * before the withdrawal could complete. + */ +async function redenominateWithdrawal( + wex: WalletExecutionContext, + withdrawalGroupId: string, +): Promise<void> { + logger.trace(`redenominating withdrawal group ${withdrawalGroupId}`); + await wex.db.runReadWriteTx( + { storeNames: ["withdrawalGroups", "planchets", "denominations"] }, + async (tx) => { + const wg = await tx.withdrawalGroups.get(withdrawalGroupId); + if (!wg) { + return; + } + const currency = Amounts.currencyOf(wg.denomsSel.totalWithdrawCost); + const exchangeBaseUrl = wg.exchangeBaseUrl; + + const candidates = await getCandidateWithdrawalDenomsTx( + wex, + tx, + exchangeBaseUrl, + currency, + ); + + const oldSel = wg.denomsSel; + + if (logger.shouldLogTrace()) { + logger.trace(`old denom sel: ${j2s(oldSel)}`); + } + + let zero = Amount.zeroOfCurrency(currency); + let amountRemaining = zero; + let prevTotalCoinValue = zero; + let prevTotalWithdrawalCost = zero; + let prevHasDenomWithAgeRestriction = false; + let prevEarliestDepositExpiration = AbsoluteTime.never(); + let prevDenoms: DenomSelItem[] = []; + let coinIndex = 0; + for (let i = 0; i < oldSel.selectedDenoms.length; i++) { + const sel = wg.denomsSel.selectedDenoms[i]; + const denom = await tx.denominations.get([ + exchangeBaseUrl, + sel.denomPubHash, + ]); + if (!denom) { + throw Error("denom in use but not not found"); + } + // FIXME: Also check planchet if there was a different error or planchet already withdrawn + let denomOkay = isWithdrawableDenom( + denom, + wex.ws.config.testing.denomselAllowLate, + ); + const numCoins = sel.count - (sel.skip ?? 0); + const denomValue = Amount.from(denom.value).mult(numCoins); + const denomFeeWithdraw = Amount.from(denom.fees.feeWithdraw).mult( + numCoins, + ); + if (denomOkay) { + prevTotalCoinValue = prevTotalCoinValue.add(denomValue); + prevTotalWithdrawalCost = prevTotalWithdrawalCost.add( + denomValue, + denomFeeWithdraw, + ); + prevDenoms.push({ + count: sel.count, + denomPubHash: sel.denomPubHash, + skip: sel.skip, + }); + prevHasDenomWithAgeRestriction = + prevHasDenomWithAgeRestriction || denom.denomPub.age_mask > 0; + prevEarliestDepositExpiration = AbsoluteTime.min( + prevEarliestDepositExpiration, + timestampAbsoluteFromDb(denom.stampExpireDeposit), + ); + } else { + amountRemaining = amountRemaining.add(denomValue, denomFeeWithdraw); + prevDenoms.push({ + count: sel.count, + denomPubHash: sel.denomPubHash, + skip: (sel.skip ?? 0) + numCoins, + }); + + for (let j = 0; j < sel.count; j++) { + const ci = coinIndex + j; + const p = await tx.planchets.indexes.byGroupAndIndex.get([ + withdrawalGroupId, + ci, + ]); + if (!p) { + // Maybe planchet wasn't yet generated. + // No problem! + logger.info( + `not aborting planchet #${coinIndex}, planchet not found`, + ); + continue; + } + logger.info(`aborting planchet #${coinIndex}`); + p.planchetStatus = PlanchetStatus.AbortedReplaced; + await tx.planchets.put(p); + } + } + + coinIndex += sel.count; + } + + const newSel = selectWithdrawalDenominations( + amountRemaining.toJson(), + candidates, + ); + + if (logger.shouldLogTrace()) { + logger.trace(`new denom sel: ${j2s(newSel)}`); + } + + const mergedSel: DenomSelectionState = { + selectedDenoms: [...prevDenoms, ...newSel.selectedDenoms], + totalCoinValue: zero + .add(prevTotalCoinValue, newSel.totalCoinValue) + .toString(), + totalWithdrawCost: zero + .add(prevTotalWithdrawalCost, newSel.totalWithdrawCost) + .toString(), + hasDenomWithAgeRestriction: + prevHasDenomWithAgeRestriction || newSel.hasDenomWithAgeRestriction, + earliestDepositExpiration: AbsoluteTime.toProtocolTimestamp( + AbsoluteTime.min( + prevEarliestDepositExpiration, + AbsoluteTime.fromProtocolTimestamp( + newSel.earliestDepositExpiration, + ), + ), + ), + }; + wg.denomsSel = mergedSel; + if (logger.shouldLogTrace()) { + logger.trace(`merged denom sel: ${j2s(mergedSel)}`); + } + await tx.withdrawalGroups.put(wg); + }, + ); +} + +async function processWithdrawalGroupPendingReady( + wex: WalletExecutionContext, + withdrawalGroup: WithdrawalGroupRecord, +): Promise<TaskRunResult> { + const { withdrawalGroupId } = withdrawalGroup; + const ctx = new WithdrawTransactionContext(wex, withdrawalGroupId); + + const exchangeBaseUrl = withdrawalGroup.exchangeBaseUrl; + + await fetchFreshExchange(wex, withdrawalGroup.exchangeBaseUrl); + + if (withdrawalGroup.denomsSel.selectedDenoms.length === 0) { + logger.warn("Finishing empty withdrawal group (no denoms)"); + await ctx.transition({}, async (wg) => { + if (!wg) { + return TransitionResult.stay(); + } + wg.status = WithdrawalGroupStatus.Done; + wg.timestampFinish = timestampPreciseToDb(TalerPreciseTimestamp.now()); + return TransitionResult.transition(wg); + }); + return TaskRunResult.finished(); + } + + const numTotalCoins = withdrawalGroup.denomsSel.selectedDenoms + .map((x) => x.count) + .reduce((a, b) => a + b); + + const wgContext: WithdrawalGroupStatusInfo = { + numPlanchets: numTotalCoins, + planchetsFinished: new Set<string>(), + wgRecord: withdrawalGroup, + }; + + await wex.db.runReadOnlyTx({ storeNames: ["planchets"] }, async (tx) => { + const planchets = + await tx.planchets.indexes.byGroup.getAll(withdrawalGroupId); + for (const p of planchets) { + if (p.planchetStatus === PlanchetStatus.WithdrawalDone) { + wgContext.planchetsFinished.add(p.coinPub); + } + } + }); + + // We sequentially generate planchets, so that + // large withdrawal groups don't make the wallet unresponsive. + for (let i = 0; i < numTotalCoins; i++) { + await processPlanchetGenerate(wex, withdrawalGroup, i); + } + + const maxBatchSize = 100; + + for (let i = 0; i < numTotalCoins; i += maxBatchSize) { + const resp = await processPlanchetExchangeBatchRequest(wex, wgContext, { + batchSize: maxBatchSize, + coinStartIndex: i, + }); + let work: Promise<void>[] = []; + work = []; + for (let j = 0; j < resp.coinIdxs.length; j++) { + if (!resp.batchResp.ev_sigs[j]) { + // response may not be available when there is kyc needed + continue; + } + work.push( + processPlanchetVerifyAndStoreCoin( + wex, + wgContext, + resp.coinIdxs[j], + resp.batchResp.ev_sigs[j], + ), + ); + } + await Promise.all(work); + } + + let redenomRequired = false; + + await wex.db.runReadOnlyTx({ storeNames: ["planchets"] }, async (tx) => { + const planchets = + await tx.planchets.indexes.byGroup.getAll(withdrawalGroupId); + for (const p of planchets) { + if (p.planchetStatus !== PlanchetStatus.Pending) { + continue; + } + if (!p.lastError) { + continue; + } + switch (p.lastError.code) { + case TalerErrorCode.EXCHANGE_GENERIC_DENOMINATION_EXPIRED: + case TalerErrorCode.EXCHANGE_GENERIC_DENOMINATION_REVOKED: + redenomRequired = true; + return; + } + } + }); + + if (redenomRequired) { + logger.warn(`withdrawal ${withdrawalGroupId} requires redenomination`); + await fetchFreshExchange(wex, exchangeBaseUrl, { + forceUpdate: true, + }); + await updateWithdrawalDenoms(wex, exchangeBaseUrl); + await redenominateWithdrawal(wex, withdrawalGroupId); + return TaskRunResult.backoff(); + } + + const errorsPerCoin: Record<number, TalerErrorDetail> = {}; + let numPlanchetErrors = 0; + let numActive = 0; + let numDone = 0; + const maxReportedErrors = 5; + + const res = await ctx.transition( + { + extraStores: ["coins", "coinAvailability", "planchets"], + }, + async (wg, tx) => { + if (!wg) { + return TransitionResult.stay(); + } + + const groupPlanchets = + await tx.planchets.indexes.byGroup.getAll(withdrawalGroupId); + for (const x of groupPlanchets) { + switch (x.planchetStatus) { + case PlanchetStatus.KycRequired: + case PlanchetStatus.Pending: + numActive++; + break; + case PlanchetStatus.WithdrawalDone: + numDone++; + break; + } + if (x.lastError) { + numPlanchetErrors++; + if (numPlanchetErrors < maxReportedErrors) { + errorsPerCoin[x.coinIdx] = x.lastError; + } + } + } + + if (wg.timestampFinish === undefined && numActive === 0) { + wg.timestampFinish = timestampPreciseToDb(TalerPreciseTimestamp.now()); + wg.status = WithdrawalGroupStatus.Done; + await makeCoinsVisible(wex, tx, ctx.transactionId); + } + return TransitionResult.transition(wg); + }, + ); + + if (!res) { + throw Error("withdrawal group does not exist anymore"); + } + + wex.ws.notify({ + type: NotificationType.BalanceChange, + hintTransactionId: ctx.transactionId, + }); + + if (numPlanchetErrors > 0) { + return { + type: TaskRunResultType.Error, + errorDetail: makeErrorDetail( + TalerErrorCode.WALLET_WITHDRAWAL_GROUP_INCOMPLETE, + { + errorsPerCoin, + numErrors: numPlanchetErrors, + }, + ), + }; + } + + return TaskRunResult.backoff(); +} + +export async function processWithdrawalGroup( + wex: WalletExecutionContext, + withdrawalGroupId: string, +): Promise<TaskRunResult> { + logger.trace("processing withdrawal group", withdrawalGroupId); + const withdrawalGroup = await wex.db.runReadOnlyTx( + { storeNames: ["withdrawalGroups"] }, + async (tx) => { + return tx.withdrawalGroups.get(withdrawalGroupId); + }, + ); + + if (!withdrawalGroup) { + throw Error(`withdrawal group ${withdrawalGroupId} not found`); + } + + const ctx = new WithdrawTransactionContext(wex, withdrawalGroupId); + + switch (withdrawalGroup.status) { + case WithdrawalGroupStatus.PendingRegisteringBank: + return await processBankRegisterReserve(wex, withdrawalGroupId); + case WithdrawalGroupStatus.PendingQueryingStatus: + return processQueryReserve(wex, withdrawalGroupId); + case WithdrawalGroupStatus.PendingWaitConfirmBank: + return await processReserveBankStatus(wex, withdrawalGroupId); + case WithdrawalGroupStatus.PendingAml: + // FIXME: Handle this case, withdrawal doesn't support AML yet. + return TaskRunResult.backoff(); + case WithdrawalGroupStatus.PendingKyc: + return processWithdrawalGroupPendingKyc(wex, withdrawalGroup); + case WithdrawalGroupStatus.PendingReady: + // Continue with the actual withdrawal! + return await processWithdrawalGroupPendingReady(wex, withdrawalGroup); + case WithdrawalGroupStatus.AbortingBank: + return await processWithdrawalGroupAbortingBank(wex, withdrawalGroup); + case WithdrawalGroupStatus.DialogProposed: + return await processWithdrawalGroupDialogProposed(ctx, withdrawalGroup); + case WithdrawalGroupStatus.AbortedBank: + case WithdrawalGroupStatus.AbortedExchange: + case WithdrawalGroupStatus.FailedAbortingBank: + case WithdrawalGroupStatus.SuspendedAbortingBank: + case WithdrawalGroupStatus.SuspendedAml: + case WithdrawalGroupStatus.SuspendedKyc: + case WithdrawalGroupStatus.SuspendedQueryingStatus: + case WithdrawalGroupStatus.SuspendedReady: + case WithdrawalGroupStatus.SuspendedRegisteringBank: + case WithdrawalGroupStatus.SuspendedWaitConfirmBank: + case WithdrawalGroupStatus.Done: + case WithdrawalGroupStatus.FailedBankAborted: + case WithdrawalGroupStatus.AbortedUserRefused: + case WithdrawalGroupStatus.AbortedOtherWallet: + // Nothing to do. + return TaskRunResult.finished(); + default: + assertUnreachable(withdrawalGroup.status); + } +} + +const AGE_MASK_GROUPS = "8:10:12:14:16:18" + .split(":") + .map((n) => parseInt(n, 10)); + +export async function getExchangeWithdrawalInfo( + wex: WalletExecutionContext, + exchangeBaseUrl: string, + instructedAmount: AmountJson, + ageRestricted: number | undefined, +): Promise<ExchangeWithdrawalDetails> { + logger.trace("updating exchange"); + const exchange = await fetchFreshExchange(wex, exchangeBaseUrl, { + cancellationToken: wex.cancellationToken, + }); + + wex.cancellationToken.throwIfCancelled(); + + if (exchange.currency != instructedAmount.currency) { + // Specifying the amount in the conversion input currency is not yet supported. + // We might add support for it later. + throw new Error( + `withdrawal only supported when specifying target currency ${exchange.currency}`, + ); + } + + const withdrawalAccountsList = await fetchWithdrawalAccountInfo( + wex, + { + exchange, + instructedAmount, + }, + wex.cancellationToken, + ); + + logger.trace("updating withdrawal denoms"); + await updateWithdrawalDenoms(wex, exchangeBaseUrl); + + wex.cancellationToken.throwIfCancelled(); + + logger.trace("getting candidate denoms"); + const candidateDenoms = await getCandidateWithdrawalDenoms( + wex, + exchangeBaseUrl, + instructedAmount.currency, + ); + + wex.cancellationToken.throwIfCancelled(); + + logger.trace("selecting withdrawal denoms"); + // FIXME: Why not in a transaction? + const selectedDenoms = selectWithdrawalDenominations( + instructedAmount, + candidateDenoms, + wex.ws.config.testing.denomselAllowLate, + ); + + logger.trace("selection done"); + + if (selectedDenoms.selectedDenoms.length === 0) { + throw Error( + `unable to withdraw from ${exchangeBaseUrl}, can't select denominations for instructed amount (${Amounts.stringify( + instructedAmount, + )}`, + ); + } + + const exchangeWireAccounts: string[] = []; + + for (const account of exchange.wireInfo.accounts) { + exchangeWireAccounts.push(account.payto_uri); + } + + let versionMatch; + if (exchange.protocolVersionRange) { + versionMatch = LibtoolVersion.compare( + WALLET_EXCHANGE_PROTOCOL_VERSION, + exchange.protocolVersionRange, + ); + + if ( + versionMatch && + !versionMatch.compatible && + versionMatch.currentCmp === -1 + ) { + logger.warn( + `wallet's support for exchange protocol version ${WALLET_EXCHANGE_PROTOCOL_VERSION} might be outdated ` + + `(exchange has ${exchange.protocolVersionRange}), checking for updates`, + ); + } + } + + let tosAccepted = false; + if (exchange.tosAcceptedTimestamp) { + if (exchange.tosAcceptedEtag === exchange.tosCurrentEtag) { + tosAccepted = true; + } + } + + const paytoUris = exchange.wireInfo.accounts.map((x) => x.payto_uri); + if (!paytoUris) { + throw Error("exchange is in invalid state"); + } + + const ret: ExchangeWithdrawalDetails = { + earliestDepositExpiration: selectedDenoms.earliestDepositExpiration, + exchangePaytoUris: paytoUris, + exchangeWireAccounts, + exchangeCreditAccountDetails: withdrawalAccountsList, + exchangeVersion: exchange.protocolVersionRange || "unknown", + selectedDenoms, + versionMatch, + walletVersion: WALLET_EXCHANGE_PROTOCOL_VERSION, + termsOfServiceAccepted: tosAccepted, + withdrawalAmountEffective: Amounts.stringify(selectedDenoms.totalCoinValue), + withdrawalAmountRaw: Amounts.stringify(instructedAmount), + // TODO: remove hardcoding, this should be calculated from the denominations info + // force enabled for testing + ageRestrictionOptions: selectedDenoms.hasDenomWithAgeRestriction + ? AGE_MASK_GROUPS + : undefined, + scopeInfo: exchange.scopeInfo, + }; + return ret; +} + +export interface GetWithdrawalDetailsForUriOpts { + restrictAge?: number; + notifyChangeFromPendingTimeoutMs?: number; +} + +/** + * Get more information about a taler://withdraw URI. + * + * As side effects, the bank (via the bank integration API) is queried + * and the exchange suggested by the bank is ephemerally added + * to the wallet's list of known exchanges. + */ +export async function getWithdrawalDetailsForUri( + wex: WalletExecutionContext, + talerWithdrawUri: string, + opts: GetWithdrawalDetailsForUriOpts = {}, +): Promise<WithdrawUriInfoResponse> { + logger.trace(`getting withdrawal details for URI ${talerWithdrawUri}`); + const info = await getBankWithdrawalInfo(wex.http, talerWithdrawUri); + logger.trace(`got bank info`); + if (info.suggestedExchange) { + try { + // If the exchange entry doesn't exist yet, + // it'll be created as an ephemeral entry. + await fetchFreshExchange(wex, info.suggestedExchange); + } catch (e) { + // We still continued if it failed, as other exchanges might be available. + // We don't want to fail if the bank-suggested exchange is broken/offline. + logger.trace( + `querying bank-suggested exchange (${info.suggestedExchange}) failed`, + ); + } + } + + const currency = Amounts.currencyOf(info.amount); + + const listExchangesResp = await listExchanges(wex); + const possibleExchanges = listExchangesResp.exchanges.filter((x) => { + return ( + x.currency === currency && + (x.exchangeUpdateStatus === ExchangeUpdateStatus.Ready || + x.exchangeUpdateStatus === ExchangeUpdateStatus.ReadyUpdate) + ); + }); + + return { + operationId: info.operationId, + confirmTransferUrl: info.confirmTransferUrl, + status: info.status, + amount: Amounts.stringify(info.amount), + defaultExchangeBaseUrl: info.suggestedExchange, + possibleExchanges, + }; +} + +export function augmentPaytoUrisForWithdrawal( + plainPaytoUris: string[], + reservePub: string, + instructedAmount: AmountLike, +): string[] { + return plainPaytoUris.map((x) => + addPaytoQueryParams(x, { + amount: Amounts.stringify(instructedAmount), + message: `Taler Withdrawal ${reservePub}`, + }), + ); +} + +/** + * Get payto URIs that can be used to fund a withdrawal operation. + */ +export async function getFundingPaytoUris( + tx: WalletDbReadOnlyTransaction< + ["withdrawalGroups", "exchanges", "exchangeDetails"] + >, + withdrawalGroupId: string, +): Promise<string[]> { + const withdrawalGroup = await tx.withdrawalGroups.get(withdrawalGroupId); + checkDbInvariant(!!withdrawalGroup); + const exchangeDetails = await getExchangeWireDetailsInTx( + tx, + withdrawalGroup.exchangeBaseUrl, + ); + if (!exchangeDetails) { + logger.error(`exchange ${withdrawalGroup.exchangeBaseUrl} not found`); + return []; + } + const plainPaytoUris = + exchangeDetails.wireInfo?.accounts.map((x) => x.payto_uri) ?? []; + if (!plainPaytoUris) { + logger.error( + `exchange ${withdrawalGroup.exchangeBaseUrl} has no wire info`, + ); + return []; + } + return augmentPaytoUrisForWithdrawal( + plainPaytoUris, + withdrawalGroup.reservePub, + withdrawalGroup.instructedAmount, + ); +} + +async function getWithdrawalGroupRecordTx( + db: DbAccess<typeof WalletStoresV1>, + req: { + withdrawalGroupId: string; + }, +): Promise<WithdrawalGroupRecord | undefined> { + return await db.runReadOnlyTx( + { storeNames: ["withdrawalGroups"] }, + async (tx) => { + return tx.withdrawalGroups.get(req.withdrawalGroupId); + }, + ); +} + +export function getReserveRequestTimeout(r: WithdrawalGroupRecord): Duration { + return { d_ms: 60000 }; +} + +export function getBankStatusUrl(talerWithdrawUri: string): string { + const uriResult = parseWithdrawUri(talerWithdrawUri); + if (!uriResult) { + throw Error(`can't parse withdrawal URL ${talerWithdrawUri}`); + } + const url = new URL( + `withdrawal-operation/${uriResult.withdrawalOperationId}`, + uriResult.bankIntegrationApiBaseUrl, + ); + return url.href; +} + +export function getBankAbortUrl(talerWithdrawUri: string): string { + const uriResult = parseWithdrawUri(talerWithdrawUri); + if (!uriResult) { + throw Error(`can't parse withdrawal URL ${talerWithdrawUri}`); + } + const url = new URL( + `withdrawal-operation/${uriResult.withdrawalOperationId}/abort`, + uriResult.bankIntegrationApiBaseUrl, + ); + return url.href; +} + +async function registerReserveWithBank( + wex: WalletExecutionContext, + withdrawalGroupId: string, +): Promise<void> { + const withdrawalGroup = await wex.db.runReadOnlyTx( + { storeNames: ["withdrawalGroups"] }, + async (tx) => { + return await tx.withdrawalGroups.get(withdrawalGroupId); + }, + ); + const ctx = new WithdrawTransactionContext(wex, withdrawalGroupId); + switch (withdrawalGroup?.status) { + case WithdrawalGroupStatus.PendingWaitConfirmBank: + case WithdrawalGroupStatus.PendingRegisteringBank: + break; + default: + return; + } + if ( + withdrawalGroup.wgInfo.withdrawalType != WithdrawalRecordType.BankIntegrated + ) { + throw Error("expecting withdrawal type = bank integrated"); + } + const bankInfo = withdrawalGroup.wgInfo.bankInfo; + if (!bankInfo) { + return; + } + const bankStatusUrl = getBankStatusUrl(bankInfo.talerWithdrawUri); + const reqBody = { + reserve_pub: withdrawalGroup.reservePub, + selected_exchange: bankInfo.exchangePaytoUri, + }; + logger.info(`registering reserve with bank: ${j2s(reqBody)}`); + const httpResp = await wex.http.fetch(bankStatusUrl, { + method: "POST", + body: reqBody, + timeout: getReserveRequestTimeout(withdrawalGroup), + cancellationToken: wex.cancellationToken, + }); + const status = await readSuccessResponseJsonOrThrow( + httpResp, + codeForBankWithdrawalOperationPostResponse(), + ); + + await ctx.transition({}, async (r) => { + if (!r) { + return TransitionResult.stay(); + } + switch (r.status) { + case WithdrawalGroupStatus.PendingRegisteringBank: + case WithdrawalGroupStatus.PendingWaitConfirmBank: + break; + default: + return TransitionResult.stay(); + } + if (r.wgInfo.withdrawalType !== WithdrawalRecordType.BankIntegrated) { + throw Error("invariant failed"); + } + r.wgInfo.bankInfo.timestampReserveInfoPosted = timestampPreciseToDb( + AbsoluteTime.toPreciseTimestamp(AbsoluteTime.now()), + ); + r.status = WithdrawalGroupStatus.PendingWaitConfirmBank; + r.wgInfo.bankInfo.confirmUrl = status.confirm_transfer_url; + return TransitionResult.transition(r); + }); +} + +async function transitionBankAborted( + ctx: WithdrawTransactionContext, +): Promise<TaskRunResult> { + logger.info("bank aborted the withdrawal"); + await ctx.transition({}, async (r) => { + if (!r) { + return TransitionResult.stay(); + } + switch (r.status) { + case WithdrawalGroupStatus.PendingRegisteringBank: + case WithdrawalGroupStatus.PendingWaitConfirmBank: + break; + default: + return TransitionResult.stay(); + } + if (r.wgInfo.withdrawalType !== WithdrawalRecordType.BankIntegrated) { + throw Error("invariant failed"); + } + const now = AbsoluteTime.toPreciseTimestamp(AbsoluteTime.now()); + r.wgInfo.bankInfo.timestampBankConfirmed = timestampPreciseToDb(now); + r.status = WithdrawalGroupStatus.FailedBankAborted; + return TransitionResult.transition(r); + }); + return TaskRunResult.progress(); +} + +async function processBankRegisterReserve( + wex: WalletExecutionContext, + withdrawalGroupId: string, +): Promise<TaskRunResult> { + const ctx = new WithdrawTransactionContext(wex, withdrawalGroupId); + const withdrawalGroup = await getWithdrawalGroupRecordTx(wex.db, { + withdrawalGroupId, + }); + if (!withdrawalGroup) { + return TaskRunResult.finished(); + } + + if ( + withdrawalGroup.wgInfo.withdrawalType != WithdrawalRecordType.BankIntegrated + ) { + throw Error("wrong withdrawal record type"); + } + const bankInfo = withdrawalGroup.wgInfo.bankInfo; + if (!bankInfo) { + throw Error("no bank info in bank-integrated withdrawal"); + } + + const uriResult = parseWithdrawUri(bankInfo.talerWithdrawUri); + if (!uriResult) { + throw Error(`can't parse withdrawal URL ${bankInfo.talerWithdrawUri}`); + } + const url = new URL( + `withdrawal-operation/${uriResult.withdrawalOperationId}`, + uriResult.bankIntegrationApiBaseUrl, + ); + + const statusResp = await wex.http.fetch(url.href, { + timeout: getReserveRequestTimeout(withdrawalGroup), + cancellationToken: wex.cancellationToken, + }); + + const status = await readSuccessResponseJsonOrThrow( + statusResp, + codecForWithdrawOperationStatusResponse(), + ); + + if (status.aborted) { + return transitionBankAborted(ctx); + } + + // FIXME: Put confirm transfer URL in the DB! + + await registerReserveWithBank(wex, withdrawalGroupId); + return TaskRunResult.progress(); +} + +async function processReserveBankStatus( + wex: WalletExecutionContext, + withdrawalGroupId: string, +): Promise<TaskRunResult> { + const withdrawalGroup = await getWithdrawalGroupRecordTx(wex.db, { + withdrawalGroupId, + }); + + if (!withdrawalGroup) { + return TaskRunResult.finished(); + } + + const ctx = new WithdrawTransactionContext(wex, withdrawalGroupId); + + if ( + withdrawalGroup.wgInfo.withdrawalType != WithdrawalRecordType.BankIntegrated + ) { + throw Error("wrong withdrawal record type"); + } + const bankInfo = withdrawalGroup.wgInfo.bankInfo; + if (!bankInfo) { + throw Error("no bank info in bank-integrated withdrawal"); + } + + const uriResult = parseWithdrawUri(bankInfo.talerWithdrawUri); + if (!uriResult) { + throw Error(`can't parse withdrawal URL ${bankInfo.talerWithdrawUri}`); + } + const bankStatusUrl = new URL( + `withdrawal-operation/${uriResult.withdrawalOperationId}`, + uriResult.bankIntegrationApiBaseUrl, + ); + bankStatusUrl.searchParams.set("long_poll_ms", "30000"); + + logger.info(`long-polling for withdrawal operation at ${bankStatusUrl.href}`); + const statusResp = await wex.http.fetch(bankStatusUrl.href, { + timeout: getReserveRequestTimeout(withdrawalGroup), + cancellationToken: wex.cancellationToken, + }); + logger.info( + `long-polling for withdrawal operation returned status ${statusResp.status}`, + ); + + const status = await readSuccessResponseJsonOrThrow( + statusResp, + codecForWithdrawOperationStatusResponse(), + ); + + if (logger.shouldLogTrace()) { + logger.trace(`response body: ${j2s(status)}`); + } + + if (status.aborted) { + return transitionBankAborted(ctx); + } + + if (!status.transfer_done) { + return TaskRunResult.longpollReturnedPending(); + } + + const transitionInfo = await ctx.transition({}, async (r) => { + if (!r) { + return TransitionResult.stay(); + } + // Re-check reserve status within transaction + switch (r.status) { + case WithdrawalGroupStatus.PendingWaitConfirmBank: + break; + default: + return TransitionResult.stay(); + } + if (r.wgInfo.withdrawalType !== WithdrawalRecordType.BankIntegrated) { + throw Error("invariant failed"); + } + if (status.transfer_done) { + logger.info("withdrawal: transfer confirmed by bank."); + const now = AbsoluteTime.toPreciseTimestamp(AbsoluteTime.now()); + r.wgInfo.bankInfo.timestampBankConfirmed = timestampPreciseToDb(now); + r.status = WithdrawalGroupStatus.PendingQueryingStatus; + return TransitionResult.transition(r); + } else { + return TransitionResult.stay(); + } + }); + + if (transitionInfo) { + return TaskRunResult.progress(); + } else { + return TaskRunResult.backoff(); + } +} + +export interface PrepareCreateWithdrawalGroupResult { + withdrawalGroup: WithdrawalGroupRecord; + transactionId: string; + creationInfo?: { + amount: AmountJson; + canonExchange: string; + }; +} + +export async function internalPrepareCreateWithdrawalGroup( + wex: WalletExecutionContext, + args: { + reserveStatus: WithdrawalGroupStatus; + amount: AmountJson; + exchangeBaseUrl: string; + forcedWithdrawalGroupId?: string; + forcedDenomSel?: ForcedDenomSel; + reserveKeyPair?: EddsaKeypair; + restrictAge?: number; + wgInfo: WgInfo; + }, +): Promise<PrepareCreateWithdrawalGroupResult> { + const reserveKeyPair = + args.reserveKeyPair ?? (await wex.cryptoApi.createEddsaKeypair({})); + const now = AbsoluteTime.toPreciseTimestamp(AbsoluteTime.now()); + const secretSeed = encodeCrock(getRandomBytes(32)); + const canonExchange = canonicalizeBaseUrl(args.exchangeBaseUrl); + const amount = args.amount; + const currency = Amounts.currencyOf(amount); + + let withdrawalGroupId; + + if (args.forcedWithdrawalGroupId) { + withdrawalGroupId = args.forcedWithdrawalGroupId; + const wgId = withdrawalGroupId; + const existingWg = await wex.db.runReadOnlyTx( + { storeNames: ["withdrawalGroups"] }, + async (tx) => { + return tx.withdrawalGroups.get(wgId); + }, + ); + + if (existingWg) { + const transactionId = constructTransactionIdentifier({ + tag: TransactionType.Withdrawal, + withdrawalGroupId: existingWg.withdrawalGroupId, + }); + return { withdrawalGroup: existingWg, transactionId }; + } + } else { + withdrawalGroupId = encodeCrock(getRandomBytes(32)); + } + + await updateWithdrawalDenoms(wex, canonExchange); + const denoms = await getCandidateWithdrawalDenoms( + wex, + canonExchange, + currency, + ); + + let initialDenomSel: DenomSelectionState; + const denomSelUid = encodeCrock(getRandomBytes(16)); + if (args.forcedDenomSel) { + logger.warn("using forced denom selection"); + initialDenomSel = selectForcedWithdrawalDenominations( + amount, + denoms, + args.forcedDenomSel, + wex.ws.config.testing.denomselAllowLate, + ); + } else { + initialDenomSel = selectWithdrawalDenominations( + amount, + denoms, + wex.ws.config.testing.denomselAllowLate, + ); + } + + const withdrawalGroup: WithdrawalGroupRecord = { + denomSelUid, + denomsSel: initialDenomSel, + exchangeBaseUrl: canonExchange, + instructedAmount: Amounts.stringify(amount), + timestampStart: timestampPreciseToDb(now), + rawWithdrawalAmount: initialDenomSel.totalWithdrawCost, + effectiveWithdrawalAmount: initialDenomSel.totalCoinValue, + secretSeed, + reservePriv: reserveKeyPair.priv, + reservePub: reserveKeyPair.pub, + status: args.reserveStatus, + withdrawalGroupId, + restrictAge: args.restrictAge, + senderWire: undefined, + timestampFinish: undefined, + wgInfo: args.wgInfo, + }; + + await fetchFreshExchange(wex, canonExchange); + const transactionId = constructTransactionIdentifier({ + tag: TransactionType.Withdrawal, + withdrawalGroupId: withdrawalGroup.withdrawalGroupId, + }); + + return { + withdrawalGroup, + transactionId, + creationInfo: { + canonExchange, + amount, + }, + }; +} + +export interface PerformCreateWithdrawalGroupResult { + withdrawalGroup: WithdrawalGroupRecord; + transitionInfo: TransitionInfo | undefined; + + /** + * Notification for the exchange state transition. + * + * Should be emitted after the transaction has succeeded. + */ + exchangeNotif: WalletNotification | undefined; +} + +export async function internalPerformCreateWithdrawalGroup( + wex: WalletExecutionContext, + tx: WalletDbReadWriteTransaction< + ["withdrawalGroups", "reserves", "exchanges"] + >, + prep: PrepareCreateWithdrawalGroupResult, +): Promise<PerformCreateWithdrawalGroupResult> { + const { withdrawalGroup } = prep; + if (!prep.creationInfo) { + return { + withdrawalGroup, + transitionInfo: undefined, + exchangeNotif: undefined, + }; + } + const existingWg = await tx.withdrawalGroups.get( + withdrawalGroup.withdrawalGroupId, + ); + if (existingWg) { + return { + withdrawalGroup: existingWg, + exchangeNotif: undefined, + transitionInfo: undefined, + }; + } + await tx.withdrawalGroups.add(withdrawalGroup); + await tx.reserves.put({ + reservePub: withdrawalGroup.reservePub, + reservePriv: withdrawalGroup.reservePriv, + }); + + const exchange = await tx.exchanges.get(withdrawalGroup.exchangeBaseUrl); + if (exchange) { + exchange.lastWithdrawal = timestampPreciseToDb(TalerPreciseTimestamp.now()); + await tx.exchanges.put(exchange); + } + + const oldTxState = { + major: TransactionMajorState.None, + minor: undefined, + }; + const newTxState = computeWithdrawalTransactionStatus(withdrawalGroup); + const transitionInfo = { + oldTxState, + newTxState, + }; + + const exchangeUsedRes = await markExchangeUsed( + wex, + tx, + prep.withdrawalGroup.exchangeBaseUrl, + ); + + const ctx = new WithdrawTransactionContext( + wex, + withdrawalGroup.withdrawalGroupId, + ); + + wex.taskScheduler.startShepherdTask(ctx.taskId); + + return { + withdrawalGroup, + transitionInfo, + exchangeNotif: exchangeUsedRes.notif, + }; +} + +/** + * Create a withdrawal group. + * + * If a forcedWithdrawalGroupId is given and a + * withdrawal group with this ID already exists, + * the existing one is returned. No conflict checking + * of the other arguments is done in that case. + */ +export async function internalCreateWithdrawalGroup( + wex: WalletExecutionContext, + args: { + reserveStatus: WithdrawalGroupStatus; + amount: AmountJson; + exchangeBaseUrl: string; + forcedWithdrawalGroupId?: string; + forcedDenomSel?: ForcedDenomSel; + reserveKeyPair?: EddsaKeypair; + restrictAge?: number; + wgInfo: WgInfo; + }, +): Promise<WithdrawalGroupRecord> { + const prep = await internalPrepareCreateWithdrawalGroup(wex, args); + const transactionId = constructTransactionIdentifier({ + tag: TransactionType.Withdrawal, + withdrawalGroupId: prep.withdrawalGroup.withdrawalGroupId, + }); + const ctx = new WithdrawTransactionContext( + wex, + prep.withdrawalGroup.withdrawalGroupId, + ); + const res = await wex.db.runReadWriteTx( + { + storeNames: [ + "withdrawalGroups", + "reserves", + "exchanges", + "exchangeDetails", + "transactions", + "operationRetries", + ], + }, + async (tx) => { + const res = await internalPerformCreateWithdrawalGroup(wex, tx, prep); + await updateWithdrawalTransaction(ctx, tx); + return res; + }, + ); + if (res.exchangeNotif) { + wex.ws.notify(res.exchangeNotif); + } + notifyTransition(wex, transactionId, res.transitionInfo); + return res.withdrawalGroup; +} + +export async function prepareBankIntegratedWithdrawal( + wex: WalletExecutionContext, + req: { + talerWithdrawUri: string; + selectedExchange: string; + forcedDenomSel?: ForcedDenomSel; + restrictAge?: number; + }, +): Promise<PrepareBankIntegratedWithdrawalResponse> { + const existingWithdrawalGroup = await wex.db.runReadOnlyTx( + { storeNames: ["withdrawalGroups"] }, + async (tx) => { + return await tx.withdrawalGroups.indexes.byTalerWithdrawUri.get( + req.talerWithdrawUri, + ); + }, + ); + + if (existingWithdrawalGroup) { + let url: string | undefined; + if ( + existingWithdrawalGroup.wgInfo.withdrawalType === + WithdrawalRecordType.BankIntegrated + ) { + url = existingWithdrawalGroup.wgInfo.bankInfo.confirmUrl; + } + return { + transactionId: constructTransactionIdentifier({ + tag: TransactionType.Withdrawal, + withdrawalGroupId: existingWithdrawalGroup.withdrawalGroupId, + }), + }; + } + + const selectedExchange = canonicalizeBaseUrl(req.selectedExchange); + const exchange = await fetchFreshExchange(wex, selectedExchange); + + const withdrawInfo = await getBankWithdrawalInfo( + wex.http, + req.talerWithdrawUri, + ); + const exchangePaytoUri = await getExchangePaytoUri( + wex, + selectedExchange, + withdrawInfo.wireTypes, + ); + + const withdrawalAccountList = await fetchWithdrawalAccountInfo( + wex, + { + exchange, + instructedAmount: withdrawInfo.amount, + }, + wex.cancellationToken, + ); + + const withdrawalGroup = await internalCreateWithdrawalGroup(wex, { + amount: withdrawInfo.amount, + exchangeBaseUrl: req.selectedExchange, + wgInfo: { + withdrawalType: WithdrawalRecordType.BankIntegrated, + exchangeCreditAccounts: withdrawalAccountList, + bankInfo: { + exchangePaytoUri, + talerWithdrawUri: req.talerWithdrawUri, + confirmUrl: withdrawInfo.confirmTransferUrl, + timestampBankConfirmed: undefined, + timestampReserveInfoPosted: undefined, + }, + }, + restrictAge: req.restrictAge, + forcedDenomSel: req.forcedDenomSel, + reserveStatus: WithdrawalGroupStatus.DialogProposed, + }); + + const withdrawalGroupId = withdrawalGroup.withdrawalGroupId; + + const ctx = new WithdrawTransactionContext(wex, withdrawalGroupId); + + wex.taskScheduler.startShepherdTask(ctx.taskId); + + return { + transactionId: ctx.transactionId, + }; +} + +export async function confirmWithdrawal( + wex: WalletExecutionContext, + transactionId: string, +): Promise<void> { + const parsedTx = parseTransactionIdentifier(transactionId); + if (parsedTx?.tag !== TransactionType.Withdrawal) { + throw Error("invalid withdrawal transaction ID"); + } + const withdrawalGroup = await wex.db.runReadOnlyTx( + { storeNames: ["withdrawalGroups"] }, + async (tx) => { + return await tx.withdrawalGroups.get(parsedTx.withdrawalGroupId); + }, + ); + + if (!withdrawalGroup) { + throw Error("withdrawal group not found"); + } + + const ctx = new WithdrawTransactionContext( + wex, + withdrawalGroup.withdrawalGroupId, + ); + ctx.transition({}, async (rec) => { + if (!rec) { + return TransitionResult.stay(); + } + switch (rec.status) { + case WithdrawalGroupStatus.DialogProposed: { + rec.status = WithdrawalGroupStatus.PendingRegisteringBank; + return TransitionResult.transition(rec); + } + default: + throw Error("unable to confirm withdrawal in current state"); + } + }); + + await wex.taskScheduler.resetTaskRetries(ctx.taskId); + wex.taskScheduler.startShepherdTask(ctx.taskId); +} + +/** + * Accept a bank-integrated withdrawal. + * + * Before returning, the wallet tries to register the reserve with the bank. + * + * Thus after this call returns, the withdrawal operation can be confirmed + * with the bank. + * + * @deprecated in favor of prepare/accept + */ +export async function acceptWithdrawalFromUri( + wex: WalletExecutionContext, + req: { + talerWithdrawUri: string; + selectedExchange: string; + forcedDenomSel?: ForcedDenomSel; + restrictAge?: number; + }, +): Promise<AcceptWithdrawalResponse> { + const selectedExchange = canonicalizeBaseUrl(req.selectedExchange); + logger.info( + `accepting withdrawal via ${req.talerWithdrawUri}, canonicalized selected exchange ${selectedExchange}`, + ); + const existingWithdrawalGroup = await wex.db.runReadOnlyTx( + { storeNames: ["withdrawalGroups"] }, + async (tx) => { + return await tx.withdrawalGroups.indexes.byTalerWithdrawUri.get( + req.talerWithdrawUri, + ); + }, + ); + + if (existingWithdrawalGroup) { + let url: string | undefined; + if ( + existingWithdrawalGroup.wgInfo.withdrawalType === + WithdrawalRecordType.BankIntegrated + ) { + url = existingWithdrawalGroup.wgInfo.bankInfo.confirmUrl; + } + return { + reservePub: existingWithdrawalGroup.reservePub, + confirmTransferUrl: url, + transactionId: constructTransactionIdentifier({ + tag: TransactionType.Withdrawal, + withdrawalGroupId: existingWithdrawalGroup.withdrawalGroupId, + }), + }; + } + + const exchange = await fetchFreshExchange(wex, selectedExchange); + const withdrawInfo = await getBankWithdrawalInfo( + wex.http, + req.talerWithdrawUri, + ); + const exchangePaytoUri = await getExchangePaytoUri( + wex, + selectedExchange, + withdrawInfo.wireTypes, + ); + + const withdrawalAccountList = await fetchWithdrawalAccountInfo( + wex, + { + exchange, + instructedAmount: withdrawInfo.amount, + }, + CancellationToken.CONTINUE, + ); + + const withdrawalGroup = await internalCreateWithdrawalGroup(wex, { + amount: withdrawInfo.amount, + exchangeBaseUrl: req.selectedExchange, + wgInfo: { + withdrawalType: WithdrawalRecordType.BankIntegrated, + exchangeCreditAccounts: withdrawalAccountList, + bankInfo: { + exchangePaytoUri, + talerWithdrawUri: req.talerWithdrawUri, + confirmUrl: withdrawInfo.confirmTransferUrl, + timestampBankConfirmed: undefined, + timestampReserveInfoPosted: undefined, + }, + }, + restrictAge: req.restrictAge, + forcedDenomSel: req.forcedDenomSel, + reserveStatus: WithdrawalGroupStatus.PendingRegisteringBank, + }); + + const withdrawalGroupId = withdrawalGroup.withdrawalGroupId; + + const ctx = new WithdrawTransactionContext(wex, withdrawalGroupId); + + wex.ws.notify({ + type: NotificationType.BalanceChange, + hintTransactionId: ctx.transactionId, + }); + + await waitWithdrawalRegistered(wex, ctx); + + wex.taskScheduler.startShepherdTask(ctx.taskId); + + return { + reservePub: withdrawalGroup.reservePub, + confirmTransferUrl: withdrawInfo.confirmTransferUrl, + transactionId: ctx.transactionId, + }; +} + +async function internalWaitWithdrawalRegistered( + wex: WalletExecutionContext, + ctx: WithdrawTransactionContext, + withdrawalNotifFlag: AsyncFlag, +): Promise<void> { + while (true) { + const { withdrawalRec, retryRec } = await wex.db.runReadOnlyTx( + { storeNames: ["withdrawalGroups", "operationRetries"] }, + async (tx) => { + return { + withdrawalRec: await tx.withdrawalGroups.get(ctx.withdrawalGroupId), + retryRec: await tx.operationRetries.get(ctx.taskId), + }; + }, + ); + + if (!withdrawalRec) { + throw Error("withdrawal not found anymore"); + } + + switch (withdrawalRec.status) { + case WithdrawalGroupStatus.FailedBankAborted: + throw TalerError.fromDetail( + TalerErrorCode.WALLET_WITHDRAWAL_OPERATION_ABORTED_BY_BANK, + {}, + ); + case WithdrawalGroupStatus.PendingKyc: + case WithdrawalGroupStatus.PendingAml: + case WithdrawalGroupStatus.PendingQueryingStatus: + case WithdrawalGroupStatus.PendingReady: + case WithdrawalGroupStatus.Done: + case WithdrawalGroupStatus.PendingWaitConfirmBank: + return; + case WithdrawalGroupStatus.PendingRegisteringBank: + break; + default: { + if (retryRec) { + if (retryRec.lastError) { + throw TalerError.fromUncheckedDetail(retryRec.lastError); + } else { + throw Error("withdrawal unexpectedly pending"); + } + } + } + } + + await withdrawalNotifFlag.wait(); + withdrawalNotifFlag.reset(); + } +} + +async function waitWithdrawalRegistered( + wex: WalletExecutionContext, + ctx: WithdrawTransactionContext, +): Promise<void> { + // FIXME: Doesn't support cancellation yet + // FIXME: We should use Symbol.dispose magic here for cleanup! + + const withdrawalNotifFlag = new AsyncFlag(); + // Raise exchangeNotifFlag whenever we get a notification + // about our exchange. + const cancelNotif = wex.ws.addNotificationListener((notif) => { + if ( + notif.type === NotificationType.TransactionStateTransition && + notif.transactionId === ctx.transactionId + ) { + logger.info(`raising update notification: ${j2s(notif)}`); + withdrawalNotifFlag.raise(); + } + }); + + try { + const res = await internalWaitWithdrawalRegistered( + wex, + ctx, + withdrawalNotifFlag, + ); + logger.info("done waiting for ready exchange"); + return res; + } finally { + cancelNotif(); + } +} + +async function fetchAccount( + wex: WalletExecutionContext, + instructedAmount: AmountJson, + acct: ExchangeWireAccount, + reservePub: string | undefined, + cancellationToken: CancellationToken, +): Promise<WithdrawalExchangeAccountDetails> { + let paytoUri: string; + let transferAmount: AmountString | undefined = undefined; + let currencySpecification: CurrencySpecification | undefined = undefined; + if (acct.conversion_url != null) { + const reqUrl = new URL("cashin-rate", acct.conversion_url); + reqUrl.searchParams.set( + "amount_credit", + Amounts.stringify(instructedAmount), + ); + const httpResp = await wex.http.fetch(reqUrl.href, { + cancellationToken, + }); + const respOrErr = await readSuccessResponseJsonOrErrorCode( + httpResp, + codecForCashinConversionResponse(), + ); + if (respOrErr.isError) { + return { + status: "error", + paytoUri: acct.payto_uri, + conversionError: respOrErr.talerErrorResponse, + }; + } + const resp = respOrErr.response; + paytoUri = acct.payto_uri; + transferAmount = resp.amount_debit; + const configUrl = new URL("config", acct.conversion_url); + const configResp = await wex.http.fetch(configUrl.href, { + cancellationToken, + }); + const configRespOrError = await readSuccessResponseJsonOrErrorCode( + configResp, + codecForConversionBankConfig(), + ); + if (configRespOrError.isError) { + return { + status: "error", + paytoUri: acct.payto_uri, + conversionError: configRespOrError.talerErrorResponse, + }; + } + const configParsed = configRespOrError.response; + currencySpecification = configParsed.fiat_currency_specification; + } else { + paytoUri = acct.payto_uri; + transferAmount = Amounts.stringify(instructedAmount); + } + paytoUri = addPaytoQueryParams(paytoUri, { + amount: Amounts.stringify(transferAmount), + }); + if (reservePub != null) { + paytoUri = addPaytoQueryParams(paytoUri, { + message: `Taler Withdrawal ${reservePub}`, + }); + } + const acctInfo: WithdrawalExchangeAccountDetails = { + status: "ok", + paytoUri, + transferAmount, + bankLabel: acct.bank_label, + priority: acct.priority, + currencySpecification, + creditRestrictions: acct.credit_restrictions, + }; + if (transferAmount != null) { + acctInfo.transferAmount = transferAmount; + } + return acctInfo; +} + +/** + * Gather information about bank accounts that can be used for + * withdrawals. This includes accounts that are in a different + * currency and require conversion. + */ +async function fetchWithdrawalAccountInfo( + wex: WalletExecutionContext, + req: { + exchange: ReadyExchangeSummary; + instructedAmount: AmountJson; + reservePub?: string; + }, + cancellationToken: CancellationToken, +): Promise<WithdrawalExchangeAccountDetails[]> { + const { exchange } = req; + const withdrawalAccounts: WithdrawalExchangeAccountDetails[] = []; + for (let acct of exchange.wireInfo.accounts) { + const acctInfo = await fetchAccount( + wex, + req.instructedAmount, + acct, + req.reservePub, + cancellationToken, + ); + withdrawalAccounts.push(acctInfo); + } + withdrawalAccounts.sort((x1, x2) => { + // Accounts without explicit priority have prio 0. + const n1 = x1.priority ?? 0; + const n2 = x2.priority ?? 0; + return Math.sign(n2 - n1); + }); + return withdrawalAccounts; +} + +/** + * Create a manual withdrawal operation. + * + * Adds the corresponding exchange as a trusted exchange if it is neither + * audited nor trusted already. + * + * Asynchronously starts the withdrawal. + */ +export async function createManualWithdrawal( + wex: WalletExecutionContext, + req: { + exchangeBaseUrl: string; + amount: AmountLike; + restrictAge?: number; + forcedDenomSel?: ForcedDenomSel; + forceReservePriv?: EddsaPrivateKeyString; + }, +): Promise<AcceptManualWithdrawalResult> { + const { exchangeBaseUrl } = req; + const amount = Amounts.parseOrThrow(req.amount); + const exchange = await fetchFreshExchange(wex, exchangeBaseUrl); + + if (exchange.currency != amount.currency) { + throw Error( + "manual withdrawal with conversion from foreign currency is not yet supported", + ); + } + + let reserveKeyPair: EddsaKeypair; + if (req.forceReservePriv) { + const pubResp = await wex.cryptoApi.eddsaGetPublic({ + priv: req.forceReservePriv, + }); + + reserveKeyPair = { + priv: req.forceReservePriv, + pub: pubResp.pub, + }; + } else { + reserveKeyPair = await wex.cryptoApi.createEddsaKeypair({}); + } + + const withdrawalAccountsList = await fetchWithdrawalAccountInfo( + wex, + { + exchange, + instructedAmount: amount, + reservePub: reserveKeyPair.pub, + }, + CancellationToken.CONTINUE, + ); + + const withdrawalGroup = await internalCreateWithdrawalGroup(wex, { + amount: Amounts.jsonifyAmount(req.amount), + wgInfo: { + withdrawalType: WithdrawalRecordType.BankManual, + exchangeCreditAccounts: withdrawalAccountsList, + }, + exchangeBaseUrl: req.exchangeBaseUrl, + forcedDenomSel: req.forcedDenomSel, + restrictAge: req.restrictAge, + reserveStatus: WithdrawalGroupStatus.PendingQueryingStatus, + reserveKeyPair, + }); + + const ctx = new WithdrawTransactionContext( + wex, + withdrawalGroup.withdrawalGroupId, + ); + + const exchangePaytoUris = await wex.db.runReadOnlyTx( + { storeNames: ["withdrawalGroups", "exchanges", "exchangeDetails"] }, + async (tx) => { + return await getFundingPaytoUris(tx, withdrawalGroup.withdrawalGroupId); + }, + ); + + wex.ws.notify({ + type: NotificationType.BalanceChange, + hintTransactionId: ctx.transactionId, + }); + + wex.taskScheduler.startShepherdTask(ctx.taskId); + + return { + reservePub: withdrawalGroup.reservePub, + exchangePaytoUris: exchangePaytoUris, + withdrawalAccountsList: withdrawalAccountsList, + transactionId: ctx.transactionId, + }; +} + +/** + * Wait until a refresh operation is final. + */ +export async function waitWithdrawalFinal( + wex: WalletExecutionContext, + withdrawalGroupId: string, +): Promise<void> { + const ctx = new WithdrawTransactionContext(wex, withdrawalGroupId); + wex.taskScheduler.startShepherdTask(ctx.taskId); + + // FIXME: Clean up using the new JS "using" / Symbol.dispose syntax. + const withdrawalNotifFlag = new AsyncFlag(); + // Raise purchaseNotifFlag whenever we get a notification + // about our refresh. + const cancelNotif = wex.ws.addNotificationListener((notif) => { + if ( + notif.type === NotificationType.TransactionStateTransition && + notif.transactionId === ctx.transactionId + ) { + withdrawalNotifFlag.raise(); + } + }); + const unregisterOnCancelled = wex.cancellationToken.onCancelled(() => { + cancelNotif(); + withdrawalNotifFlag.raise(); + }); + + try { + await internalWaitWithdrawalFinal(ctx, withdrawalNotifFlag); + } catch (e) { + unregisterOnCancelled(); + cancelNotif(); + } +} + +async function internalWaitWithdrawalFinal( + ctx: WithdrawTransactionContext, + flag: AsyncFlag, +): Promise<void> { + while (true) { + if (ctx.wex.cancellationToken.isCancelled) { + throw Error("cancelled"); + } + + // Check if refresh is final + const res = await ctx.wex.db.runReadOnlyTx( + { storeNames: ["withdrawalGroups", "operationRetries"] }, + async (tx) => { + return { + wg: await tx.withdrawalGroups.get(ctx.withdrawalGroupId), + }; + }, + ); + const { wg } = res; + if (!wg) { + // Must've been deleted, we consider that final. + return; + } + switch (wg.status) { + case WithdrawalGroupStatus.AbortedBank: + case WithdrawalGroupStatus.AbortedExchange: + case WithdrawalGroupStatus.Done: + case WithdrawalGroupStatus.FailedAbortingBank: + case WithdrawalGroupStatus.FailedBankAborted: + // Transaction is final + return; + } + + // Wait for the next transition + await flag.wait(); + flag.reset(); + } +} + +export async function getWithdrawalDetailsForAmount( + wex: WalletExecutionContext, + cts: CancellationToken.Source, + req: GetWithdrawalDetailsForAmountRequest, +): Promise<WithdrawalDetailsForAmount> { + const clientCancelKey = req.clientCancellationId + ? `ccid:getWithdrawalDetailsForAmount:${req.clientCancellationId}` + : undefined; + if (clientCancelKey) { + const prevCts = wex.ws.clientCancellationMap.get(clientCancelKey); + if (prevCts) { + wex.oc.observe({ + type: ObservabilityEventType.Message, + contents: `Cancelling previous key ${clientCancelKey}`, + }); + prevCts.cancel(); + } else { + wex.oc.observe({ + type: ObservabilityEventType.Message, + contents: `No previous key ${clientCancelKey}`, + }); + } + wex.oc.observe({ + type: ObservabilityEventType.Message, + contents: `Setting clientCancelKey ${clientCancelKey} to ${cts}`, + }); + wex.ws.clientCancellationMap.set(clientCancelKey, cts); + } + try { + return await internalGetWithdrawalDetailsForAmount(wex, req); + } finally { + wex.oc.observe({ + type: ObservabilityEventType.Message, + contents: `Deleting clientCancelKey ${clientCancelKey} to ${cts}`, + }); + if (clientCancelKey && !cts.token.isCancelled) { + wex.ws.clientCancellationMap.delete(clientCancelKey); + } + } +} + +async function internalGetWithdrawalDetailsForAmount( + wex: WalletExecutionContext, + req: GetWithdrawalDetailsForAmountRequest, +): Promise<WithdrawalDetailsForAmount> { + const wi = await getExchangeWithdrawalInfo( + wex, + req.exchangeBaseUrl, + Amounts.parseOrThrow(req.amount), + req.restrictAge, + ); + let numCoins = 0; + for (const x of wi.selectedDenoms.selectedDenoms) { + numCoins += x.count; + } + const resp: WithdrawalDetailsForAmount = { + amountRaw: req.amount, + amountEffective: Amounts.stringify(wi.selectedDenoms.totalCoinValue), + paytoUris: wi.exchangePaytoUris, + tosAccepted: wi.termsOfServiceAccepted, + ageRestrictionOptions: wi.ageRestrictionOptions, + withdrawalAccountsList: wi.exchangeCreditAccountDetails, + numCoins, + scopeInfo: wi.scopeInfo, + }; + return resp; +} |