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