/* 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 */ /** * @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, TalerBankIntegrationHttpClient, TalerError, TalerErrorCode, TalerErrorDetail, TalerPreciseTimestamp, Transaction, TransactionAction, TransactionIdStr, TransactionMajorState, TransactionMinorState, TransactionState, TransactionType, URL, UnblindedSignature, WalletNotification, WithdrawUriInfoResponse, WithdrawalDetailsForAmount, WithdrawalExchangeAccountDetails, WithdrawalType, addPaytoQueryParams, assertUnreachable, canonicalizeBaseUrl, checkDbInvariant, codeForBankWithdrawalOperationPostResponse, 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, } 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("operations/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 { 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( opts: { extraStores?: StoreNameArray; transactionLabel?: string }, f: ( rec: WithdrawalGroupRecord | undefined, tx: WalletDbReadWriteTransaction< [ "withdrawalGroups", "transactions", "operationRetries", "exchanges", "exchangeDetails", ...StoreNameArray, ] >, ) => Promise>, ): Promise { 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 { 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 { 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 { 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: // No transition needed, but not an error return TransitionResult.stay(); case WithdrawalGroupStatus.Done: case WithdrawalGroupStatus.FailedBankAborted: case WithdrawalGroupStatus.AbortedExchange: case WithdrawalGroupStatus.AbortedBank: case WithdrawalGroupStatus.FailedAbortingBank: // Not allowed throw Error("abort not allowed in current state"); default: assertUnreachable(wg.status); } wg.status = newStatus; return TransitionResult.transition(wg); }, ); } async resumeTransaction(): Promise { 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 { 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, }; } } /** * Compute DD37 transaction actions for a withdrawal transaction * based on the database's withdrawal group record. */ export function computeWithdrawalTransactionActions( wgRecord: WithdrawalGroupRecord, ): TransactionAction[] { switch (wgRecord.status) { case WithdrawalGroupStatus.FailedBankAborted: return [TransactionAction.Delete]; case WithdrawalGroupStatus.Done: return [TransactionAction.Delete]; case WithdrawalGroupStatus.PendingRegisteringBank: return [TransactionAction.Suspend, TransactionAction.Abort]; case WithdrawalGroupStatus.PendingReady: return [TransactionAction.Suspend, TransactionAction.Abort]; case WithdrawalGroupStatus.PendingQueryingStatus: return [TransactionAction.Suspend, TransactionAction.Abort]; case WithdrawalGroupStatus.PendingWaitConfirmBank: return [TransactionAction.Suspend, TransactionAction.Abort]; case WithdrawalGroupStatus.AbortingBank: return [TransactionAction.Suspend, TransactionAction.Fail]; case WithdrawalGroupStatus.SuspendedAbortingBank: return [TransactionAction.Resume, TransactionAction.Fail]; case WithdrawalGroupStatus.SuspendedQueryingStatus: return [TransactionAction.Resume, TransactionAction.Abort]; case WithdrawalGroupStatus.SuspendedRegisteringBank: return [TransactionAction.Resume, TransactionAction.Abort]; case WithdrawalGroupStatus.SuspendedWaitConfirmBank: return [TransactionAction.Resume, TransactionAction.Abort]; case WithdrawalGroupStatus.SuspendedReady: return [TransactionAction.Resume, TransactionAction.Abort]; case WithdrawalGroupStatus.PendingAml: return [TransactionAction.Resume, TransactionAction.Abort]; case WithdrawalGroupStatus.PendingKyc: return [TransactionAction.Resume, TransactionAction.Abort]; case WithdrawalGroupStatus.SuspendedAml: return [TransactionAction.Resume, TransactionAction.Abort]; case WithdrawalGroupStatus.SuspendedKyc: return [TransactionAction.Resume, TransactionAction.Abort]; case WithdrawalGroupStatus.FailedAbortingBank: return [TransactionAction.Delete]; case WithdrawalGroupStatus.AbortedExchange: return [TransactionAction.Delete]; case WithdrawalGroupStatus.AbortedBank: return [TransactionAction.Delete]; } } /** * Get information about a withdrawal from * a taler://withdraw URI by asking the bank. * * FIXME: Move into bank client. */ export async function getBankWithdrawalInfo( http: HttpRequestLibrary, talerWithdrawUri: string, ): Promise { 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 { 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 { // 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 { 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 { 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 { 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 { 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 { 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 { 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 { 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; /** * Cached withdrawal group record from the database. */ wgRecord: WithdrawalGroupRecord; } async function processWithdrawalGroupAbortingBank( wex: WalletExecutionContext, withdrawalGroup: WithdrawalGroupRecord, ): Promise { 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 { 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 { 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 { 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(), 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[] = []; 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 = {}; 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 { 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`); } 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.AbortedBank: case WithdrawalGroupStatus.AbortedExchange: case WithdrawalGroupStatus.FailedAbortingBank: case WithdrawalGroupStatus.SuspendedAbortingBank: case WithdrawalGroupStatus.SuspendedAml: case WithdrawalGroupStatus.SuspendedKyc: case WithdrawalGroupStatus.SuspendedQueryingStatus: case WithdrawalGroupStatus.SuspendedReady: case WithdrawalGroupStatus.SuspendedRegisteringBank: case WithdrawalGroupStatus.SuspendedWaitConfirmBank: case WithdrawalGroupStatus.Done: case WithdrawalGroupStatus.FailedBankAborted: // Nothing to do. return TaskRunResult.finished(); default: assertUnreachable(withdrawalGroup.status); } } const AGE_MASK_GROUPS = "8:10:12:14:16:18" .split(":") .map((n) => parseInt(n, 10)); export async function getExchangeWithdrawalInfo( wex: WalletExecutionContext, exchangeBaseUrl: string, instructedAmount: AmountJson, ageRestricted: number | undefined, ): Promise { 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; } type WithdrawalOperationMemoryMap = { [uri: string]: boolean | undefined; }; const ongoingChecks: WithdrawalOperationMemoryMap = {}; /** * Get more information about a taler://withdraw URI. * * As side effects, the bank (via the bank integration API) is queried * and the exchange suggested by the bank is ephemerally added * to the wallet's list of known exchanges. */ export async function getWithdrawalDetailsForUri( wex: WalletExecutionContext, talerWithdrawUri: string, opts: GetWithdrawalDetailsForUriOpts = {}, ): Promise { 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) ); }); // FIXME: this should be removed after the extended version of // withdrawal state machine. issue #8099 if ( info.status === "pending" && opts.notifyChangeFromPendingTimeoutMs !== undefined && !ongoingChecks[talerWithdrawUri] ) { ongoingChecks[talerWithdrawUri] = true; const bankApi = new TalerBankIntegrationHttpClient( info.apiBaseUrl, wex.http, ); bankApi .getWithdrawalOperationById(info.operationId, { old_state: "pending", timeoutMs: opts.notifyChangeFromPendingTimeoutMs, }) .then((resp) => { if (resp.type === "ok" && resp.body.status !== "pending") { wex.ws.notify({ type: NotificationType.WithdrawalOperationTransition, uri: talerWithdrawUri, }); } }) .finally(() => { ongoingChecks[talerWithdrawUri] = false; }); } return { operationId: info.operationId, confirmTransferUrl: info.confirmTransferUrl, status: info.status, amount: Amounts.stringify(info.amount), defaultExchangeBaseUrl: info.suggestedExchange, possibleExchanges, }; } export function augmentPaytoUrisForWithdrawal( plainPaytoUris: string[], reservePub: string, instructedAmount: AmountLike, ): string[] { return plainPaytoUris.map((x) => addPaytoQueryParams(x, { amount: Amounts.stringify(instructedAmount), message: `Taler Withdrawal ${reservePub}`, }), ); } /** * Get payto URIs that can be used to fund a withdrawal operation. */ export async function getFundingPaytoUris( tx: WalletDbReadOnlyTransaction< ["withdrawalGroups", "exchanges", "exchangeDetails"] >, withdrawalGroupId: string, ): Promise { 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, req: { withdrawalGroupId: string; }, ): Promise { 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 { 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 { 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 { 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 { 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 { 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 { 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 { 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; } /** * 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. */ export async function acceptWithdrawalFromUri( wex: WalletExecutionContext, req: { talerWithdrawUri: string; selectedExchange: string; forcedDenomSel?: ForcedDenomSel; restrictAge?: number; }, ): Promise { 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, }), }; } await fetchFreshExchange(wex, selectedExchange); const withdrawInfo = await getBankWithdrawalInfo( wex.http, req.talerWithdrawUri, ); const exchangePaytoUri = await getExchangePaytoUri( wex, selectedExchange, withdrawInfo.wireTypes, ); const exchange = await fetchFreshExchange(wex, selectedExchange); 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 { 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 { // 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 { 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 { 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 { 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 { 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 { 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 { 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 { 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; }