/* This file is part of GNU Taler (C) 2019-2021 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 */ /** * Imports. */ import { AbsoluteTime, AcceptManualWithdrawalResult, AcceptWithdrawalResponse, addPaytoQueryParams, AgeRestriction, AmountJson, AmountLike, Amounts, BankWithdrawDetails, CancellationToken, canonicalizeBaseUrl, codecForBankWithdrawalOperationPostResponse, codecForReserveStatus, codecForTalerConfigResponse, codecForWalletKycUuid, codecForWithdrawBatchResponse, codecForWithdrawOperationStatusResponse, codecForWithdrawResponse, CoinStatus, DenomKeyType, DenomSelectionState, Duration, encodeCrock, ExchangeListItem, ExchangeWithdrawalDetails, ExchangeWithdrawRequest, ForcedDenomSel, getRandomBytes, HttpStatusCode, j2s, LibtoolVersion, Logger, NotificationType, parseWithdrawUri, TalerErrorCode, TalerErrorDetail, TalerProtocolTimestamp, TransactionType, UnblindedSignature, URL, ExchangeWithdrawBatchResponse, ExchangeWithdrawResponse, WithdrawUriInfoResponse, ExchangeBatchWithdrawRequest, WalletNotification, TransactionState, TransactionMajorState, TransactionMinorState, } from "@gnu-taler/taler-util"; import { EddsaKeypair } from "../crypto/cryptoImplementation.js"; import { CoinRecord, CoinSourceType, DenominationRecord, DenominationVerificationStatus, KycPendingInfo, KycUserType, PlanchetRecord, PlanchetStatus, WalletStoresV1, WgInfo, WithdrawalGroupRecord, WithdrawalGroupStatus, WithdrawalRecordType, } from "../db.js"; import { getErrorDetailFromException, makeErrorDetail, TalerError, } from "@gnu-taler/taler-util"; import { InternalWalletState } from "../internal-wallet-state.js"; import { makeCoinAvailable, makeExchangeListItem, runLongpollAsync, runOperationWithErrorReporting, } from "../operations/common.js"; import { HttpRequestLibrary, HttpResponse, readSuccessResponseJsonOrErrorCode, readSuccessResponseJsonOrThrow, throwUnexpectedRequestError, } from "@gnu-taler/taler-util/http"; import { checkDbInvariant, checkLogicInvariant, InvariantViolatedError, } from "../util/invariants.js"; import { DbAccess, GetReadOnlyAccess } from "../util/query.js"; import { OperationAttemptResult, OperationAttemptResultType, TaskIdentifiers, constructTaskIdentifier, } from "../util/retries.js"; import { WALLET_BANK_INTEGRATION_PROTOCOL_VERSION, WALLET_EXCHANGE_PROTOCOL_VERSION, } from "../versions.js"; import { makeTransactionId } from "./common.js"; import { getExchangeDetails, getExchangePaytoUri, getExchangeTrust, updateExchangeFromUrl, } from "./exchanges.js"; import { selectForcedWithdrawalDenominations, selectWithdrawalDenominations, } from "../util/coinSelection.js"; import { PendingTaskType, isWithdrawableDenom } from "../index.js"; import { constructTransactionIdentifier, stopLongpolling, } from "./transactions.js"; /** * Logger for this file. */ const logger = new Logger("operations/withdraw.ts"); export async function suspendWithdrawalTransaction( ws: InternalWalletState, withdrawalGroupId: string, ) { const taskId = constructTaskIdentifier({ tag: PendingTaskType.Withdraw, withdrawalGroupId, }); stopLongpolling(ws, taskId); const stateUpdate = await ws.db .mktx((x) => [x.withdrawalGroups]) .runReadWrite(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.Ready: newStatus = WithdrawalGroupStatus.SuspendedReady; break; case WithdrawalGroupStatus.AbortingBank: newStatus = WithdrawalGroupStatus.SuspendedAbortingBank; break; case WithdrawalGroupStatus.WaitConfirmBank: newStatus = WithdrawalGroupStatus.SuspendedWaitConfirmBank; break; case WithdrawalGroupStatus.RegisteringBank: newStatus = WithdrawalGroupStatus.SuspendedRegisteringBank; break; case WithdrawalGroupStatus.QueryingStatus: newStatus = WithdrawalGroupStatus.QueryingStatus; break; case WithdrawalGroupStatus.Kyc: newStatus = WithdrawalGroupStatus.SuspendedKyc; break; case WithdrawalGroupStatus.Aml: 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; }); if (stateUpdate) { ws.notify({ type: NotificationType.TransactionStateTransition, transactionId: constructTransactionIdentifier({ tag: TransactionType.Withdrawal, withdrawalGroupId, }), oldTxState: stateUpdate.oldTxState, newTxState: stateUpdate.newTxState, }); } } export async function resumeWithdrawalTransaction( ws: InternalWalletState, withdrawalGroupId: string, ) { const stateUpdate = await ws.db .mktx((x) => [x.withdrawalGroups]) .runReadWrite(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.Ready; break; case WithdrawalGroupStatus.SuspendedAbortingBank: newStatus = WithdrawalGroupStatus.AbortingBank; break; case WithdrawalGroupStatus.SuspendedWaitConfirmBank: newStatus = WithdrawalGroupStatus.WaitConfirmBank; break; case WithdrawalGroupStatus.SuspendedQueryingStatus: newStatus = WithdrawalGroupStatus.QueryingStatus; break; case WithdrawalGroupStatus.SuspendedRegisteringBank: newStatus = WithdrawalGroupStatus.RegisteringBank; break; case WithdrawalGroupStatus.SuspendedAml: newStatus = WithdrawalGroupStatus.Aml; break; case WithdrawalGroupStatus.SuspendedKyc: newStatus = WithdrawalGroupStatus.Kyc; 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; }); if (stateUpdate) { ws.notify({ type: NotificationType.TransactionStateTransition, transactionId: constructTransactionIdentifier({ tag: TransactionType.Withdrawal, withdrawalGroupId, }), oldTxState: stateUpdate.oldTxState, newTxState: stateUpdate.newTxState, }); } } export async function abortWithdrawalTransaction( ws: InternalWalletState, withdrawalGroupId: string, ) { const taskId = constructTaskIdentifier({ tag: PendingTaskType.Withdraw, withdrawalGroupId, }); stopLongpolling(ws, taskId); const stateUpdate = await ws.db .mktx((x) => [x.withdrawalGroups]) .runReadWrite(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.WaitConfirmBank: case WithdrawalGroupStatus.RegisteringBank: case WithdrawalGroupStatus.AbortingBank: newStatus = WithdrawalGroupStatus.AbortingBank; break; case WithdrawalGroupStatus.Aml: newStatus = WithdrawalGroupStatus.SuspendedAml; break; case WithdrawalGroupStatus.Kyc: newStatus = WithdrawalGroupStatus.SuspendedKyc; break; case WithdrawalGroupStatus.QueryingStatus: newStatus = WithdrawalGroupStatus.SuspendedQueryingStatus; break; case WithdrawalGroupStatus.Ready: newStatus = WithdrawalGroupStatus.SuspendedReady; break; case WithdrawalGroupStatus.SuspendedAbortingBank: case WithdrawalGroupStatus.SuspendedQueryingStatus: case WithdrawalGroupStatus.SuspendedAml: case WithdrawalGroupStatus.SuspendedKyc: case WithdrawalGroupStatus.SuspendedReady: // No transition needed break; case WithdrawalGroupStatus.SuspendedRegisteringBank: case WithdrawalGroupStatus.SuspendedWaitConfirmBank: case WithdrawalGroupStatus.Finished: case WithdrawalGroupStatus.BankAborted: // Not allowed 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; }); if (stateUpdate) { ws.notify({ type: NotificationType.TransactionStateTransition, transactionId: constructTransactionIdentifier({ tag: TransactionType.Withdrawal, withdrawalGroupId, }), oldTxState: stateUpdate.oldTxState, newTxState: stateUpdate.newTxState, }); } } // Called "cancel" in the spec right now, // from suspended-aborting. export async function cancelAbortingWithdrawalTransaction( ws: InternalWalletState, withdrawalGroupId: string, ) { const taskId = constructTaskIdentifier({ tag: PendingTaskType.Withdraw, withdrawalGroupId, }); stopLongpolling(ws, taskId); const stateUpdate = await ws.db .mktx((x) => [x.withdrawalGroups]) .runReadWrite(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.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; }); if (stateUpdate) { ws.notify({ type: NotificationType.TransactionStateTransition, transactionId: constructTransactionIdentifier({ tag: TransactionType.Withdrawal, withdrawalGroupId, }), oldTxState: stateUpdate.oldTxState, newTxState: stateUpdate.newTxState, }); } } export function computeWithdrawalTransactionStatus( wgRecord: WithdrawalGroupRecord, ): TransactionState { switch (wgRecord.status) { case WithdrawalGroupStatus.BankAborted: return { major: TransactionMajorState.Aborted, }; case WithdrawalGroupStatus.Finished: return { major: TransactionMajorState.Done, }; case WithdrawalGroupStatus.RegisteringBank: return { major: TransactionMajorState.Pending, minor: TransactionMinorState.BankRegisterReserve, }; case WithdrawalGroupStatus.Ready: return { major: TransactionMajorState.Pending, minor: TransactionMinorState.WithdrawCoins, }; case WithdrawalGroupStatus.QueryingStatus: return { major: TransactionMajorState.Pending, minor: TransactionMinorState.ExchangeWaitReserve, }; case WithdrawalGroupStatus.WaitConfirmBank: 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.Aml: { return { major: TransactionMajorState.Pending, minor: TransactionMinorState.AmlRequired, }; } case WithdrawalGroupStatus.Kyc: { 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, }; } } /** * 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 configReqUrl = new URL("config", uriResult.bankIntegrationApiBaseUrl); const configResp = await http.get(configReqUrl.href); const config = await readSuccessResponseJsonOrThrow( configResp, codecForTalerConfigResponse(), ); const versionRes = LibtoolVersion.compare( WALLET_BANK_INTEGRATION_PROTOCOL_VERSION, config.version, ); if (versionRes?.compatible != true) { throw TalerError.fromDetail( TalerErrorCode.WALLET_BANK_INTEGRATION_PROTOCOL_VERSION_INCOMPATIBLE, { exchangeProtocolVersion: config.version, walletProtocolVersion: WALLET_BANK_INTEGRATION_PROTOCOL_VERSION, }, "bank integration protocol version not compatible with wallet", ); } const reqUrl = new URL( `withdrawal-operation/${uriResult.withdrawalOperationId}`, uriResult.bankIntegrationApiBaseUrl, ); logger.info(`bank withdrawal status URL: ${reqUrl.href}}`); const resp = await http.get(reqUrl.href); const status = await readSuccessResponseJsonOrThrow( resp, codecForWithdrawOperationStatusResponse(), ); logger.info(`bank withdrawal operation status: ${j2s(status)}`); return { amount: Amounts.parseOrThrow(status.amount), confirmTransferUrl: status.confirm_transfer_url, selectionDone: status.selection_done, senderWire: status.sender_wire, suggestedExchange: status.suggested_exchange, transferDone: status.transfer_done, wireTypes: status.wire_types, }; } /** * Return denominations that can potentially used for a withdrawal. */ export async function getCandidateWithdrawalDenoms( ws: InternalWalletState, exchangeBaseUrl: string, ): Promise { return await ws.db .mktx((x) => [x.denominations]) .runReadOnly(async (tx) => { const allDenoms = await tx.denominations.indexes.byExchangeBaseUrl.getAll( exchangeBaseUrl, ); return allDenoms.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 { let planchet = await ws.db .mktx((x) => [x.planchets]) .runReadOnly(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 .mktx((x) => [x.denominations]) .runReadOnly(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 .mktx((x) => [x.planchets]) .runReadWrite(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 { /** * Use the batched request on the network level. * Not supported by older exchanges. */ useBatchRequest: boolean; coinStartIndex: number; batchSize: number; } interface WithdrawalBatchResult { coinIdxs: number[]; batchResp: ExchangeWithdrawBatchResponse; } /** * 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 { 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 .mktx((x) => [ x.withdrawalGroups, x.planchets, x.exchanges, x.denominations, ]) .runReadOnly(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 handleKycRequired( resp: HttpResponse, startIdx: number, ): Promise { logger.info("withdrawal requires KYC"); const respJson = await resp.json(); const uuidResp = codecForWalletKycUuid().decode(respJson); logger.info(`kyc uuid response: ${j2s(uuidResp)}`); await ws.db .mktx((x) => [x.planchets, x.withdrawalGroups]) .runReadWrite(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; } wg2.kycPending = { paytoHash: uuidResp.h_payto, requirementRow: uuidResp.requirement_row, }; await tx.withdrawalGroups.put(wg2); }); return; } async function storeCoinError(e: any, coinIdx: number): Promise { const errDetail = getErrorDetailFromException(e); logger.trace("withdrawal request failed", e); logger.trace(String(e)); await ws.db .mktx((x) => [x.planchets]) .runReadWrite(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! if (args.useBatchRequest) { const reqUrl = new URL( `reserves/${withdrawalGroup.reservePub}/batch-withdraw`, withdrawalGroup.exchangeBaseUrl, ).href; try { const resp = await ws.http.postJson(reqUrl, batchReq); if (resp.status === HttpStatusCode.UnavailableForLegalReasons) { await handleKycRequired(resp, 0); } const r = await readSuccessResponseJsonOrThrow( resp, codecForWithdrawBatchResponse(), ); return { coinIdxs: requestCoinIdxs, batchResp: r, }; } catch (e) { await storeCoinError(e, requestCoinIdxs[0]); return { batchResp: { ev_sigs: [] }, coinIdxs: [], }; } } else { // We emulate the batch response here by making multiple individual requests const responses: ExchangeWithdrawBatchResponse = { ev_sigs: [], }; const responseCoinIdxs: number[] = []; for (let i = 0; i < batchReq.planchets.length; i++) { try { const p = batchReq.planchets[i]; const reqUrl = new URL( `reserves/${withdrawalGroup.reservePub}/withdraw`, withdrawalGroup.exchangeBaseUrl, ).href; const resp = await ws.http.postJson(reqUrl, p); if (resp.status === HttpStatusCode.UnavailableForLegalReasons) { await handleKycRequired(resp, i); // We still return blinded coins that we could actually withdraw. return { coinIdxs: responseCoinIdxs, batchResp: responses, }; } const r = await readSuccessResponseJsonOrThrow( resp, codecForWithdrawResponse(), ); responses.ev_sigs.push(r); responseCoinIdxs.push(requestCoinIdxs[i]); } catch (e) { await storeCoinError(e, requestCoinIdxs[i]); } } return { coinIdxs: responseCoinIdxs, batchResp: responses, }; } } async function processPlanchetVerifyAndStoreCoin( ws: InternalWalletState, wgContext: WithdrawalGroupContext, coinIdx: number, resp: ExchangeWithdrawResponse, ): Promise { const withdrawalGroup = wgContext.wgRecord; logger.info(`checking and storing planchet idx=${coinIdx}`); const d = await ws.db .mktx((x) => [x.withdrawalGroups, x.planchets, x.denominations]) .runReadOnly(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 { 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 .mktx((x) => [x.planchets]) .runReadWrite(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, }, maxAge: withdrawalGroup.restrictAge ?? AgeRestriction.AGE_UNRESTRICTED, ageCommitmentProof: planchet.ageCommitmentProof, spendAllocation: undefined, }; const planchetCoinPub = planchet.coinPub; wgContext.planchetsFinished.add(planchet.coinPub); // We create the notification here, as the async transaction below // allows other planchet withdrawals to change wgContext.planchetsFinished const notification: WalletNotification = { type: NotificationType.CoinWithdrawn, numTotal: wgContext.numPlanchets, numWithdrawn: wgContext.planchetsFinished.size, }; // Check if this is the first time that the whole // withdrawal succeeded. If so, mark the withdrawal // group as finished. const firstSuccess = await ws.db .mktx((x) => [ x.coins, x.denominations, x.coinAvailability, x.withdrawalGroups, x.planchets, ]) .runReadWrite(async (tx) => { const p = await tx.planchets.get(planchetCoinPub); if (!p || p.planchetStatus === PlanchetStatus.WithdrawalDone) { return false; } p.planchetStatus = PlanchetStatus.WithdrawalDone; await tx.planchets.put(p); await makeCoinAvailable(ws, tx, coin); return true; }); if (firstSuccess) { ws.notify(notification); } } /** * 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( ws: InternalWalletState, exchangeBaseUrl: string, ): Promise { logger.trace( `updating denominations used for withdrawal for ${exchangeBaseUrl}`, ); const exchangeDetails = await ws.db .mktx((x) => [x.exchanges, x.exchangeDetails]) .runReadOnly(async (tx) => { return ws.exchangeOps.getExchangeDetails(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); 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 .mktx((x) => [x.denominations]) .runReadWrite(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<{ ready: boolean }> { const withdrawalGroup = await getWithdrawalGroupRecordTx(ws.db, { withdrawalGroupId, }); checkDbInvariant(!!withdrawalGroup); if (withdrawalGroup.status !== WithdrawalGroupStatus.QueryingStatus) { return { ready: true }; } const reservePub = withdrawalGroup.reservePub; const reserveUrl = new URL( `reserves/${reservePub}`, withdrawalGroup.exchangeBaseUrl, ); reserveUrl.searchParams.set("timeout_ms", "30000"); logger.info(`querying reserve status via ${reserveUrl.href}`); const resp = await ws.http.get(reserveUrl.href, { timeout: getReserveRequestTimeout(withdrawalGroup), cancellationToken, }); logger.info(`reserve status code: HTTP ${resp.status}`); const result = await readSuccessResponseJsonOrErrorCode( resp, codecForReserveStatus(), ); if (result.isError) { logger.info( `got reserve status error, EC=${result.talerErrorResponse.code}`, ); if ( resp.status === 404 && result.talerErrorResponse.code === TalerErrorCode.EXCHANGE_RESERVES_STATUS_UNKNOWN ) { ws.notify({ type: NotificationType.ReserveNotYetFound, reservePub, }); return { ready: false }; } else { throwUnexpectedRequestError(resp, result.talerErrorResponse); } } logger.trace(`got reserve status ${j2s(result.response)}`); await ws.db .mktx((x) => [x.withdrawalGroups]) .runReadWrite(async (tx) => { const wg = await tx.withdrawalGroups.get(withdrawalGroupId); if (!wg) { logger.warn(`withdrawal group ${withdrawalGroupId} not found`); return; } wg.status = WithdrawalGroupStatus.Ready; wg.reserveBalanceAmount = Amounts.stringify(result.response.balance); await tx.withdrawalGroups.put(wg); }); ws.notify({ type: NotificationType.WithdrawalGroupReserveReady, transactionId: makeTransactionId( TransactionType.Withdrawal, withdrawalGroupId, ), }); return { ready: true }; } enum BankStatusResultCode { Done = "done", Waiting = "waiting", Aborted = "aborted", } /** * Withdrawal context that is kept in-memory. * * Used to store some cached info during a withdrawal operation. */ export interface WithdrawalGroupContext { numPlanchets: number; planchetsFinished: Set; /** * Cached withdrawal group record from the database. */ wgRecord: WithdrawalGroupRecord; } export async function processWithdrawalGroup( ws: InternalWalletState, withdrawalGroupId: string, options: {} = {}, ): Promise { logger.trace("processing withdrawal group", withdrawalGroupId); const withdrawalGroup = await ws.db .mktx((x) => [x.withdrawalGroups]) .runReadOnly(async (tx) => { return tx.withdrawalGroups.get(withdrawalGroupId); }); if (!withdrawalGroup) { throw Error(`withdrawal group ${withdrawalGroupId} not found`); } const retryTag = TaskIdentifiers.forWithdrawal(withdrawalGroup); // We're already running! if (ws.activeLongpoll[retryTag]) { logger.info("withdrawal group already in long-polling, returning!"); return { type: OperationAttemptResultType.Longpoll, }; } switch (withdrawalGroup.status) { case WithdrawalGroupStatus.RegisteringBank: await processReserveBankStatus(ws, withdrawalGroupId); return await processWithdrawalGroup(ws, withdrawalGroupId, { forceNow: true, }); case WithdrawalGroupStatus.QueryingStatus: { runLongpollAsync(ws, retryTag, (ct) => { return queryReserve(ws, withdrawalGroupId, ct); }); logger.trace( "returning early from withdrawal for long-polling in background", ); return { type: OperationAttemptResultType.Longpoll, }; } case WithdrawalGroupStatus.WaitConfirmBank: { const res = await processReserveBankStatus(ws, withdrawalGroupId); switch (res.status) { case BankStatusResultCode.Aborted: case BankStatusResultCode.Done: return { type: OperationAttemptResultType.Finished, result: undefined, }; case BankStatusResultCode.Waiting: { return { type: OperationAttemptResultType.Pending, result: undefined, }; } } break; } case WithdrawalGroupStatus.BankAborted: { // FIXME return { type: OperationAttemptResultType.Pending, result: undefined, }; } case WithdrawalGroupStatus.Finished: // We can try to withdraw, nothing needs to be done with the reserve. break; case WithdrawalGroupStatus.Ready: // Continue with the actual withdrawal! break; default: throw new InvariantViolatedError( `unknown reserve record status: ${withdrawalGroup.status}`, ); } await ws.exchangeOps.updateExchangeFromUrl( ws, withdrawalGroup.exchangeBaseUrl, ); if (withdrawalGroup.denomsSel.selectedDenoms.length === 0) { logger.warn("Finishing empty withdrawal group (no denoms)"); await ws.db .mktx((x) => [x.withdrawalGroups]) .runReadWrite(async (tx) => { const wg = await tx.withdrawalGroups.get(withdrawalGroupId); if (!wg) { return; } wg.status = WithdrawalGroupStatus.Finished; wg.timestampFinish = TalerProtocolTimestamp.now(); await tx.withdrawalGroups.put(wg); }); return { type: OperationAttemptResultType.Finished, result: undefined, }; } const numTotalCoins = withdrawalGroup.denomsSel.selectedDenoms .map((x) => x.count) .reduce((a, b) => a + b); const wgContext: WithdrawalGroupContext = { numPlanchets: numTotalCoins, planchetsFinished: new Set(), wgRecord: withdrawalGroup, }; await ws.db .mktx((x) => [x.planchets]) .runReadOnly(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, useBatchRequest: ws.config.features.batchWithdrawal, }); 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( ws, wgContext, resp.coinIdxs[j], resp.batchResp.ev_sigs[j], ), ); } await Promise.all(work); } let numFinished = 0; let numKycRequired = 0; let finishedForFirstTime = false; const errorsPerCoin: Record = {}; const res = await ws.db .mktx((x) => [x.coins, x.withdrawalGroups, x.planchets]) .runReadWrite(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.planchetStatus === PlanchetStatus.KycRequired) { numKycRequired++; } if (x.lastError) { errorsPerCoin[x.coinIdx] = x.lastError; } }); logger.info(`now withdrawn ${numFinished} of ${numTotalCoins} coins`); if (wg.timestampFinish === undefined && numFinished === numTotalCoins) { finishedForFirstTime = true; wg.timestampFinish = TalerProtocolTimestamp.now(); wg.status = WithdrawalGroupStatus.Finished; } await tx.withdrawalGroups.put(wg); return { kycInfo: wg.kycPending, }; }); if (!res) { throw Error("withdrawal group does not exist anymore"); } const { kycInfo } = res; if (numKycRequired > 0) { if (kycInfo) { const txId = makeTransactionId( TransactionType.Withdrawal, withdrawalGroup.withdrawalGroupId, ); await checkWithdrawalKycStatus( ws, withdrawalGroup.exchangeBaseUrl, txId, kycInfo, "individual", ); return { type: OperationAttemptResultType.Pending, result: undefined, }; } else { throw TalerError.fromDetail( TalerErrorCode.WALLET_WITHDRAWAL_KYC_REQUIRED, { //FIXME we can't rise KYC error here since we don't have the url } as any, `KYC check required for withdrawal (not yet implemented in wallet-core)`, ); } } if (numFinished != numTotalCoins) { throw TalerError.fromDetail( TalerErrorCode.WALLET_WITHDRAWAL_GROUP_INCOMPLETE, { errorsPerCoin, }, `withdrawal did not finish (${numFinished} / ${numTotalCoins} coins withdrawn)`, ); } if (finishedForFirstTime) { ws.notify({ type: NotificationType.WithdrawGroupFinished, reservePub: withdrawalGroup.reservePub, }); } return { type: OperationAttemptResultType.Finished, result: undefined, }; } export async function checkWithdrawalKycStatus( ws: InternalWalletState, exchangeUrl: string, txId: string, kycInfo: KycPendingInfo, userType: KycUserType, ): Promise { 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", }); 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)}`); ws.notify({ type: NotificationType.KycRequested, kycUrl: kycStatus.kyc_url, transactionId: txId, }); throw TalerError.fromDetail( TalerErrorCode.WALLET_WITHDRAWAL_KYC_REQUIRED, //FIXME: another error code or rename for merge { kycUrl: kycStatus.kyc_url, }, `KYC check required for transfer`, ); } else { throw Error(`unexpected response from kyc-check (${kycStatusRes.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 { const { exchange, exchangeDetails } = await ws.exchangeOps.updateExchangeFromUrl(ws, exchangeBaseUrl); await updateWithdrawalDenoms(ws, exchangeBaseUrl); const denoms = await getCandidateWithdrawalDenoms(ws, exchangeBaseUrl); const selectedDenoms = selectWithdrawalDenominations( instructedAmount, denoms, ws.config.testing.denomselAllowLate, ); 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 exchangeDetails.wireInfo.accounts) { exchangeWireAccounts.push(account.payto_uri); } const { isTrusted, isAudited } = await ws.exchangeOps.getExchangeTrust( ws, exchange, ); let hasDenomWithAgeRestriction = false; 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 .mktx((x) => [x.denominations]) .runReadOnly(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.fromTimestamp(expireDeposit), AbsoluteTime.fromTimestamp(earliestDepositExpiration), ) < 0 ) { earliestDepositExpiration = expireDeposit; } } checkLogicInvariant(!!earliestDepositExpiration); const possibleDenoms = await ws.db .mktx((x) => [x.denominations]) .runReadOnly(async (tx) => { const ds = await tx.denominations.indexes.byExchangeBaseUrl.getAll( exchangeBaseUrl, ); return ds.filter((x) => x.isOffered); }); let versionMatch; if (exchangeDetails.protocolVersionRange) { versionMatch = LibtoolVersion.compare( WALLET_EXCHANGE_PROTOCOL_VERSION, exchangeDetails.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 ${exchangeDetails.protocolVersionRange}), checking for updates`, ); } } let tosAccepted = false; if (exchangeDetails.tosAccepted?.timestamp) { if (exchangeDetails.tosAccepted.etag === exchangeDetails.tosCurrentEtag) { tosAccepted = true; } } const paytoUris = exchangeDetails.wireInfo.accounts.map((x) => x.payto_uri); if (!paytoUris) { throw Error("exchange is in invalid state"); } const ret: ExchangeWithdrawalDetails = { earliestDepositExpiration, exchangePaytoUris: paytoUris, exchangeWireAccounts, exchangeVersion: exchangeDetails.protocolVersionRange || "unknown", isAudited, isTrusted, 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, }; return ret; } export interface GetWithdrawalDetailsForUriOpts { restrictAge?: number; } /** * Get more information about a taler://withdraw URI. * * As side effects, the bank (via the bank integration API) is queried * and the exchange suggested by the bank is permanently added * to the wallet's list of known exchanges. */ export async function getWithdrawalDetailsForUri( ws: InternalWalletState, talerWithdrawUri: string, opts: GetWithdrawalDetailsForUriOpts = {}, ): Promise { logger.trace(`getting withdrawal details for URI ${talerWithdrawUri}`); const info = await getBankWithdrawalInfo(ws.http, talerWithdrawUri); logger.trace(`got bank info`); if (info.suggestedExchange) { // FIXME: right now the exchange gets permanently added, // we might want to only temporarily add it. try { await ws.exchangeOps.updateExchangeFromUrl(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`, ); } } // Extract information about possible exchanges for the withdrawal // operation from the database. const exchanges: ExchangeListItem[] = []; await ws.db .mktx((x) => [ x.exchanges, x.exchangeDetails, x.exchangeTos, x.denominations, x.operationRetries, ]) .runReadOnly(async (tx) => { const exchangeRecords = await tx.exchanges.iter().toArray(); for (const r of exchangeRecords) { const exchangeDetails = await ws.exchangeOps.getExchangeDetails( tx, r.baseUrl, ); const denominations = await tx.denominations.indexes.byExchangeBaseUrl .iter(r.baseUrl) .toArray(); const retryRecord = await tx.operationRetries.get( TaskIdentifiers.forExchangeUpdate(r), ); if (exchangeDetails && denominations) { exchanges.push( makeExchangeListItem(r, exchangeDetails, retryRecord?.lastError), ); } } }); return { amount: Amounts.stringify(info.amount), defaultExchangeBaseUrl: info.suggestedExchange, possibleExchanges: exchanges, }; } export async function getFundingPaytoUrisTx( ws: InternalWalletState, withdrawalGroupId: string, ): Promise { return await ws.db .mktx((x) => [x.exchanges, x.exchangeDetails, x.withdrawalGroups]) .runReadWrite((tx) => getFundingPaytoUris(tx, withdrawalGroupId)); } 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: GetReadOnlyAccess<{ withdrawalGroups: typeof WalletStoresV1.withdrawalGroups; exchanges: typeof WalletStoresV1.exchanges; exchangeDetails: typeof WalletStoresV1.exchangeDetails; }>, withdrawalGroupId: string, ): Promise { const withdrawalGroup = await tx.withdrawalGroups.get(withdrawalGroupId); checkDbInvariant(!!withdrawalGroup); const exchangeDetails = await getExchangeDetails( 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 .mktx((x) => [x.withdrawalGroups]) .runReadOnly(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; } async function registerReserveWithBank( ws: InternalWalletState, withdrawalGroupId: string, ): Promise { const withdrawalGroup = await ws.db .mktx((x) => [x.withdrawalGroups]) .runReadOnly(async (tx) => { return await tx.withdrawalGroups.get(withdrawalGroupId); }); switch (withdrawalGroup?.status) { case WithdrawalGroupStatus.WaitConfirmBank: case WithdrawalGroupStatus.RegisteringBank: break; default: return; } if ( withdrawalGroup.wgInfo.withdrawalType != WithdrawalRecordType.BankIntegrated ) { throw Error(); } 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.postJson(bankStatusUrl, reqBody, { timeout: getReserveRequestTimeout(withdrawalGroup), }); await readSuccessResponseJsonOrThrow( httpResp, codecForBankWithdrawalOperationPostResponse(), ); await ws.db .mktx((x) => [x.withdrawalGroups]) .runReadWrite(async (tx) => { const r = await tx.withdrawalGroups.get(withdrawalGroupId); if (!r) { return; } switch (r.status) { case WithdrawalGroupStatus.RegisteringBank: case WithdrawalGroupStatus.WaitConfirmBank: break; default: return; } if (r.wgInfo.withdrawalType !== WithdrawalRecordType.BankIntegrated) { throw Error("invariant failed"); } r.wgInfo.bankInfo.timestampReserveInfoPosted = AbsoluteTime.toTimestamp( AbsoluteTime.now(), ); r.status = WithdrawalGroupStatus.WaitConfirmBank; await tx.withdrawalGroups.put(r); }); ws.notify({ type: NotificationType.ReserveRegisteredWithBank }); } interface BankStatusResult { status: BankStatusResultCode; } async function processReserveBankStatus( ws: InternalWalletState, withdrawalGroupId: string, ): Promise { const withdrawalGroup = await getWithdrawalGroupRecordTx(ws.db, { withdrawalGroupId, }); switch (withdrawalGroup?.status) { case WithdrawalGroupStatus.WaitConfirmBank: case WithdrawalGroupStatus.RegisteringBank: break; default: return { status: BankStatusResultCode.Done, }; } if ( withdrawalGroup.wgInfo.withdrawalType != WithdrawalRecordType.BankIntegrated ) { throw Error("wrong withdrawal record type"); } const bankInfo = withdrawalGroup.wgInfo.bankInfo; if (!bankInfo) { return { status: BankStatusResultCode.Done, }; } const bankStatusUrl = getBankStatusUrl(bankInfo.talerWithdrawUri); const statusResp = await ws.http.get(bankStatusUrl, { timeout: getReserveRequestTimeout(withdrawalGroup), }); const status = await readSuccessResponseJsonOrThrow( statusResp, codecForWithdrawOperationStatusResponse(), ); if (status.aborted) { logger.info("bank aborted the withdrawal"); await ws.db .mktx((x) => [x.withdrawalGroups]) .runReadWrite(async (tx) => { const r = await tx.withdrawalGroups.get(withdrawalGroupId); if (!r) { return; } switch (r.status) { case WithdrawalGroupStatus.RegisteringBank: case WithdrawalGroupStatus.WaitConfirmBank: break; default: return; } if (r.wgInfo.withdrawalType !== WithdrawalRecordType.BankIntegrated) { throw Error("invariant failed"); } const now = AbsoluteTime.toTimestamp(AbsoluteTime.now()); r.wgInfo.bankInfo.timestampBankConfirmed = now; r.status = WithdrawalGroupStatus.BankAborted; await tx.withdrawalGroups.put(r); }); return { status: BankStatusResultCode.Aborted, }; } // 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.RegisteringBank) { await registerReserveWithBank(ws, withdrawalGroupId); return await processReserveBankStatus(ws, withdrawalGroupId); } await ws.db .mktx((x) => [x.withdrawalGroups]) .runReadWrite(async (tx) => { const r = await tx.withdrawalGroups.get(withdrawalGroupId); if (!r) { return; } // Re-check reserve status within transaction switch (r.status) { case WithdrawalGroupStatus.RegisteringBank: case WithdrawalGroupStatus.WaitConfirmBank: break; default: return; } if (r.wgInfo.withdrawalType !== WithdrawalRecordType.BankIntegrated) { throw Error("invariant failed"); } if (status.transfer_done) { logger.info("withdrawal: transfer confirmed by bank."); const now = AbsoluteTime.toTimestamp(AbsoluteTime.now()); r.wgInfo.bankInfo.timestampBankConfirmed = now; r.status = WithdrawalGroupStatus.QueryingStatus; ws.notify({ type: NotificationType.WithdrawalGroupBankConfirmed, transactionId: makeTransactionId( TransactionType.Withdrawal, r.withdrawalGroupId, ), }); } else { logger.info("withdrawal: transfer not yet confirmed by bank"); r.wgInfo.bankInfo.confirmUrl = status.confirm_transfer_url; r.senderWire = status.sender_wire; } await tx.withdrawalGroups.put(r); }); if (status.transfer_done) { return { status: BankStatusResultCode.Done, }; } else { return { status: BankStatusResultCode.Waiting, }; } } /** * 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 { const reserveKeyPair = args.reserveKeyPair ?? (await ws.cryptoApi.createEddsaKeypair({})); const now = AbsoluteTime.toTimestamp(AbsoluteTime.now()); const secretSeed = encodeCrock(getRandomBytes(32)); const canonExchange = canonicalizeBaseUrl(args.exchangeBaseUrl); const amount = args.amount; let withdrawalGroupId; if (args.forcedWithdrawalGroupId) { withdrawalGroupId = args.forcedWithdrawalGroupId; const wgId = withdrawalGroupId; const existingWg = await ws.db .mktx((x) => [x.withdrawalGroups]) .runReadOnly(async (tx) => { return tx.withdrawalGroups.get(wgId); }); if (existingWg) { return existingWg; } } else { withdrawalGroupId = encodeCrock(getRandomBytes(32)); } await updateWithdrawalDenoms(ws, canonExchange); const denoms = await getCandidateWithdrawalDenoms(ws, canonExchange); 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: 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, }; const exchangeInfo = await updateExchangeFromUrl(ws, canonExchange); const exchangeDetails = exchangeInfo.exchangeDetails; if (!exchangeDetails) { logger.trace(exchangeDetails); throw Error("exchange not updated"); } const { isAudited, isTrusted } = await getExchangeTrust( ws, exchangeInfo.exchange, ); await ws.db .mktx((x) => [ x.withdrawalGroups, x.reserves, x.exchanges, x.exchangeDetails, x.exchangeTrust, ]) .runReadWrite(async (tx) => { 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 = TalerProtocolTimestamp.now(); await tx.exchanges.put(exchange); } if (!isAudited && !isTrusted) { await tx.exchangeTrust.put({ currency: amount.currency, exchangeBaseUrl: canonExchange, exchangeMasterPub: exchangeDetails.masterPublicKey, uids: [encodeCrock(getRandomBytes(32))], }); } }); return withdrawalGroup; } export async function acceptWithdrawalFromUri( ws: InternalWalletState, 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 ws.db .mktx((x) => [x.withdrawalGroups]) .runReadOnly(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: makeTransactionId( TransactionType.Withdrawal, existingWithdrawalGroup.withdrawalGroupId, ), }; } await updateExchangeFromUrl(ws, selectedExchange); const withdrawInfo = await getBankWithdrawalInfo( ws.http, req.talerWithdrawUri, ); const exchangePaytoUri = await getExchangePaytoUri( ws, selectedExchange, withdrawInfo.wireTypes, ); const withdrawalGroup = await internalCreateWithdrawalGroup(ws, { amount: withdrawInfo.amount, exchangeBaseUrl: req.selectedExchange, wgInfo: { withdrawalType: WithdrawalRecordType.BankIntegrated, bankInfo: { exchangePaytoUri, talerWithdrawUri: req.talerWithdrawUri, confirmUrl: withdrawInfo.confirmTransferUrl, timestampBankConfirmed: undefined, timestampReserveInfoPosted: undefined, }, }, restrictAge: req.restrictAge, forcedDenomSel: req.forcedDenomSel, reserveStatus: WithdrawalGroupStatus.RegisteringBank, }); const withdrawalGroupId = withdrawalGroup.withdrawalGroupId; // 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.BankAborted) { throw TalerError.fromDetail( TalerErrorCode.WALLET_WITHDRAWAL_OPERATION_ABORTED_BY_BANK, {}, ); } // Start withdrawal in the background processWithdrawalGroup(ws, withdrawalGroupId, { forceNow: true, }).catch((err) => { logger.error("Processing withdrawal (after creation) failed:", err); }); return { reservePub: withdrawalGroup.reservePub, confirmTransferUrl: withdrawInfo.confirmTransferUrl, transactionId: makeTransactionId( TransactionType.Withdrawal, withdrawalGroupId, ), }; } /** * 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 { const withdrawalGroup = await internalCreateWithdrawalGroup(ws, { amount: Amounts.jsonifyAmount(req.amount), wgInfo: { withdrawalType: WithdrawalRecordType.BankManual, }, exchangeBaseUrl: req.exchangeBaseUrl, forcedDenomSel: req.forcedDenomSel, restrictAge: req.restrictAge, reserveStatus: WithdrawalGroupStatus.QueryingStatus, }); const withdrawalGroupId = withdrawalGroup.withdrawalGroupId; const exchangePaytoUris = await ws.db .mktx((x) => [ x.withdrawalGroups, x.exchanges, x.exchangeDetails, x.exchangeTrust, ]) .runReadOnly(async (tx) => { return await getFundingPaytoUris(tx, withdrawalGroup.withdrawalGroupId); }); // Start withdrawal in the background (do not await!) // FIXME: We could also interrupt the task look if it is waiting and // rely on retry handling to re-process the withdrawal group. runOperationWithErrorReporting( ws, TaskIdentifiers.forWithdrawal(withdrawalGroup), async () => { return await processWithdrawalGroup(ws, withdrawalGroupId, { forceNow: true, }); }, ); return { reservePub: withdrawalGroup.reservePub, exchangePaytoUris: exchangePaytoUris, transactionId: makeTransactionId( TransactionType.Withdrawal, withdrawalGroupId, ), }; }