/* This file is part of GNU Taler (C) 2019 GNUnet e.V. 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 */ import { CreateReserveRequest, CreateReserveResponse, TalerErrorDetails, AcceptWithdrawalResponse, } from "../types/walletTypes"; import { canonicalizeBaseUrl } from "../util/helpers"; import { InternalWalletState } from "./state"; import { ReserveRecordStatus, ReserveRecord, CurrencyRecord, Stores, WithdrawalGroupRecord, WalletReserveHistoryItemType, ReserveHistoryRecord, ReserveBankInfo, } from "../types/dbTypes"; import { Logger } from "../util/logging"; import { Amounts } from "../util/amounts"; import { updateExchangeFromUrl, getExchangeTrust, getExchangePaytoUri, } from "./exchanges"; import { codecForWithdrawOperationStatusResponse, codecForBankWithdrawalOperationPostResponse, } from "../types/talerTypes"; import { assertUnreachable } from "../util/assertUnreachable"; import { encodeCrock, getRandomBytes } from "../crypto/talerCrypto"; import { randomBytes } from "../crypto/primitives/nacl-fast"; import { selectWithdrawalDenoms, processWithdrawGroup, getBankWithdrawalInfo, denomSelectionInfoToState, } from "./withdraw"; import { guardOperationException, OperationFailedAndReportedError, makeErrorDetails, OperationFailedError, } from "./errors"; import { NotificationType } from "../types/notifications"; import { codecForReserveStatus } from "../types/ReserveStatus"; import { getTimestampNow, Duration, durationMin, durationMax, } from "../util/time"; import { reconcileReserveHistory, summarizeReserveHistory, ReserveHistorySummary, } from "../util/reserveHistoryUtil"; import { TransactionHandle } from "../util/query"; import { addPaytoQueryParams } from "../util/payto"; import { TalerErrorCode } from "../TalerErrorCode"; import { readSuccessResponseJsonOrErrorCode, throwUnexpectedRequestError, readSuccessResponseJsonOrThrow, } from "../util/http"; import { codecForAny } from "../util/codec"; import { URL } from "../util/url"; import { initRetryInfo, getRetryDuration, updateRetryInfoTimeout } from "../util/retries"; const logger = new Logger("reserves.ts"); async function resetReserveRetry( ws: InternalWalletState, reservePub: string, ): Promise { await ws.db.mutate(Stores.reserves, reservePub, (x) => { if (x.retryInfo.active) { x.retryInfo = initRetryInfo(); } return x; }); } /** * Create a reserve, but do not flag it as confirmed yet. * * Adds the corresponding exchange as a trusted exchange if it is neither * audited nor trusted already. */ export async function createReserve( ws: InternalWalletState, req: CreateReserveRequest, ): Promise { const keypair = await ws.cryptoApi.createEddsaKeypair(); const now = getTimestampNow(); const canonExchange = canonicalizeBaseUrl(req.exchange); let reserveStatus; if (req.bankWithdrawStatusUrl) { reserveStatus = ReserveRecordStatus.REGISTERING_BANK; } else { reserveStatus = ReserveRecordStatus.QUERYING_STATUS; } let bankInfo: ReserveBankInfo | undefined; if (req.bankWithdrawStatusUrl) { if (!req.exchangePaytoUri) { throw Error( "Exchange payto URI must be specified for a bank-integrated withdrawal", ); } bankInfo = { statusUrl: req.bankWithdrawStatusUrl, exchangePaytoUri: req.exchangePaytoUri, }; } const initialWithdrawalGroupId = encodeCrock(getRandomBytes(32)); const denomSelInfo = await selectWithdrawalDenoms( ws, canonExchange, req.amount, ); const initialDenomSel = denomSelectionInfoToState(denomSelInfo); const reserveRecord: ReserveRecord = { instructedAmount: req.amount, initialWithdrawalGroupId, initialDenomSel, initialWithdrawalStarted: false, timestampCreated: now, exchangeBaseUrl: canonExchange, reservePriv: keypair.priv, reservePub: keypair.pub, senderWire: req.senderWire, timestampBankConfirmed: undefined, timestampReserveInfoPosted: undefined, bankInfo, reserveStatus, lastSuccessfulStatusQuery: undefined, retryInfo: initRetryInfo(), lastError: undefined, currency: req.amount.currency, requestedQuery: false, }; const reserveHistoryRecord: ReserveHistoryRecord = { reservePub: keypair.pub, reserveTransactions: [], }; reserveHistoryRecord.reserveTransactions.push({ type: WalletReserveHistoryItemType.Credit, expectedAmount: req.amount, }); const exchangeInfo = await updateExchangeFromUrl(ws, req.exchange); const exchangeDetails = exchangeInfo.details; if (!exchangeDetails) { logger.trace(exchangeDetails); throw Error("exchange not updated"); } const { isAudited, isTrusted } = await getExchangeTrust(ws, exchangeInfo); let currencyRecord = await ws.db.get( Stores.currencies, exchangeDetails.currency, ); if (!currencyRecord) { currencyRecord = { auditors: [], exchanges: [], fractionalDigits: 2, name: exchangeDetails.currency, }; } if (!isAudited && !isTrusted) { currencyRecord.exchanges.push({ exchangeBaseUrl: req.exchange, exchangeMasterPub: exchangeDetails.masterPublicKey, }); } const cr: CurrencyRecord = currencyRecord; const resp = await ws.db.runWithWriteTransaction( [ Stores.currencies, Stores.reserves, Stores.reserveHistory, Stores.bankWithdrawUris, ], async (tx) => { // Check if we have already created a reserve for that bankWithdrawStatusUrl if (reserveRecord.bankInfo?.statusUrl) { const bwi = await tx.get( Stores.bankWithdrawUris, reserveRecord.bankInfo.statusUrl, ); if (bwi) { const otherReserve = await tx.get(Stores.reserves, bwi.reservePub); if (otherReserve) { logger.trace( "returning existing reserve for bankWithdrawStatusUri", ); return { exchange: otherReserve.exchangeBaseUrl, reservePub: otherReserve.reservePub, }; } } await tx.put(Stores.bankWithdrawUris, { reservePub: reserveRecord.reservePub, talerWithdrawUri: reserveRecord.bankInfo.statusUrl, }); } await tx.put(Stores.currencies, cr); await tx.put(Stores.reserves, reserveRecord); await tx.put(Stores.reserveHistory, reserveHistoryRecord); const r: CreateReserveResponse = { exchange: canonExchange, reservePub: keypair.pub, }; return r; }, ); if (reserveRecord.reservePub === resp.reservePub) { // Only emit notification when a new reserve was created. ws.notify({ type: NotificationType.ReserveCreated, reservePub: reserveRecord.reservePub, }); } // Asynchronously process the reserve, but return // to the caller already. processReserve(ws, resp.reservePub, true).catch((e) => { logger.error("Processing reserve (after createReserve) failed:", e); }); return resp; } /** * Re-query the status of a reserve. */ export async function forceQueryReserve( ws: InternalWalletState, reservePub: string, ): Promise { await ws.db.runWithWriteTransaction([Stores.reserves], async (tx) => { const reserve = await tx.get(Stores.reserves, reservePub); if (!reserve) { return; } // Only force status query where it makes sense switch (reserve.reserveStatus) { case ReserveRecordStatus.DORMANT: reserve.reserveStatus = ReserveRecordStatus.QUERYING_STATUS; break; default: reserve.requestedQuery = true; break; } reserve.retryInfo = initRetryInfo(); await tx.put(Stores.reserves, reserve); }); await processReserve(ws, reservePub, true); } /** * First fetch information requred to withdraw from the reserve, * then deplete the reserve, withdrawing coins until it is empty. * * The returned promise resolves once the reserve is set to the * state DORMANT. */ export async function processReserve( ws: InternalWalletState, reservePub: string, forceNow = false, ): Promise { return ws.memoProcessReserve.memo(reservePub, async () => { const onOpError = (err: TalerErrorDetails): Promise => incrementReserveRetry(ws, reservePub, err); await guardOperationException( () => processReserveImpl(ws, reservePub, forceNow), onOpError, ); }); } async function registerReserveWithBank( ws: InternalWalletState, reservePub: string, ): Promise { const reserve = await ws.db.get(Stores.reserves, reservePub); switch (reserve?.reserveStatus) { case ReserveRecordStatus.WAIT_CONFIRM_BANK: case ReserveRecordStatus.REGISTERING_BANK: break; default: return; } const bankInfo = reserve.bankInfo; if (!bankInfo) { return; } const bankStatusUrl = bankInfo.statusUrl; const httpResp = await ws.http.postJson( bankStatusUrl, { reserve_pub: reservePub, selected_exchange: bankInfo.exchangePaytoUri, }, { timeout: getReserveRequestTimeout(reserve), }, ); await readSuccessResponseJsonOrThrow( httpResp, codecForBankWithdrawalOperationPostResponse(), ); await ws.db.mutate(Stores.reserves, reservePub, (r) => { switch (r.reserveStatus) { case ReserveRecordStatus.REGISTERING_BANK: case ReserveRecordStatus.WAIT_CONFIRM_BANK: break; default: return; } r.timestampReserveInfoPosted = getTimestampNow(); r.reserveStatus = ReserveRecordStatus.WAIT_CONFIRM_BANK; if (!r.bankInfo) { throw Error("invariant failed"); } r.retryInfo = initRetryInfo(); return r; }); ws.notify({ type: NotificationType.ReserveRegisteredWithBank }); return processReserveBankStatus(ws, reservePub); } async function processReserveBankStatus( ws: InternalWalletState, reservePub: string, ): Promise { const onOpError = (err: TalerErrorDetails): Promise => incrementReserveRetry(ws, reservePub, err); await guardOperationException( () => processReserveBankStatusImpl(ws, reservePub), onOpError, ); } export function getReserveRequestTimeout(r: ReserveRecord): Duration { return durationMax( { d_ms: 60000 }, durationMin({ d_ms: 5000 }, getRetryDuration(r.retryInfo)), ); } async function processReserveBankStatusImpl( ws: InternalWalletState, reservePub: string, ): Promise { const reserve = await ws.db.get(Stores.reserves, reservePub); switch (reserve?.reserveStatus) { case ReserveRecordStatus.WAIT_CONFIRM_BANK: case ReserveRecordStatus.REGISTERING_BANK: break; default: return; } const bankStatusUrl = reserve.bankInfo?.statusUrl; if (!bankStatusUrl) { return; } const statusResp = await ws.http.get(bankStatusUrl, { timeout: getReserveRequestTimeout(reserve), }); const status = await readSuccessResponseJsonOrThrow( statusResp, codecForWithdrawOperationStatusResponse(), ); if (status.aborted) { logger.trace("bank aborted the withdrawal"); await ws.db.mutate(Stores.reserves, reservePub, (r) => { switch (r.reserveStatus) { case ReserveRecordStatus.REGISTERING_BANK: case ReserveRecordStatus.WAIT_CONFIRM_BANK: break; default: return; } const now = getTimestampNow(); r.timestampBankConfirmed = now; r.reserveStatus = ReserveRecordStatus.BANK_ABORTED; r.retryInfo = initRetryInfo(); return r; }); return; } if (status.selection_done) { if (reserve.reserveStatus === ReserveRecordStatus.REGISTERING_BANK) { await registerReserveWithBank(ws, reservePub); return await processReserveBankStatus(ws, reservePub); } } else { await registerReserveWithBank(ws, reservePub); return await processReserveBankStatus(ws, reservePub); } if (status.transfer_done) { await ws.db.mutate(Stores.reserves, reservePub, (r) => { switch (r.reserveStatus) { case ReserveRecordStatus.REGISTERING_BANK: case ReserveRecordStatus.WAIT_CONFIRM_BANK: break; default: return; } const now = getTimestampNow(); r.timestampBankConfirmed = now; r.reserveStatus = ReserveRecordStatus.QUERYING_STATUS; r.retryInfo = initRetryInfo(); return r; }); await processReserveImpl(ws, reservePub, true); } else { await ws.db.mutate(Stores.reserves, reservePub, (r) => { switch (r.reserveStatus) { case ReserveRecordStatus.WAIT_CONFIRM_BANK: break; default: return; } if (r.bankInfo) { r.bankInfo.confirmUrl = status.confirm_transfer_url; } return r; }); await incrementReserveRetry(ws, reservePub, undefined); } } async function incrementReserveRetry( ws: InternalWalletState, reservePub: string, err: TalerErrorDetails | undefined, ): Promise { await ws.db.runWithWriteTransaction([Stores.reserves], async (tx) => { const r = await tx.get(Stores.reserves, reservePub); if (!r) { return; } if (!r.retryInfo) { return; } r.retryInfo.retryCounter++; updateRetryInfoTimeout(r.retryInfo); r.lastError = err; await tx.put(Stores.reserves, r); }); if (err) { ws.notify({ type: NotificationType.ReserveOperationError, error: err, }); } } /** * Update the information about a reserve that is stored in the wallet * by quering the reserve's exchange. */ async function updateReserve( ws: InternalWalletState, reservePub: string, ): Promise<{ ready: boolean }> { const reserve = await ws.db.get(Stores.reserves, reservePub); if (!reserve) { throw Error("reserve not in db"); } if (reserve.reserveStatus !== ReserveRecordStatus.QUERYING_STATUS) { return { ready: true }; } const resp = await ws.http.get( new URL(`reserves/${reservePub}`, reserve.exchangeBaseUrl).href, { timeout: getReserveRequestTimeout(reserve), }, ); const result = await readSuccessResponseJsonOrErrorCode( resp, codecForReserveStatus(), ); if (result.isError) { if ( resp.status === 404 && result.talerErrorResponse.code === TalerErrorCode.EXCHANGE_RESERVES_GET_STATUS_UNKNOWN ) { ws.notify({ type: NotificationType.ReserveNotYetFound, reservePub, }); await incrementReserveRetry(ws, reservePub, undefined); return { ready: false }; } else { throwUnexpectedRequestError(resp, result.talerErrorResponse); } } const reserveInfo = result.response; const balance = Amounts.parseOrThrow(reserveInfo.balance); const currency = balance.currency; let updateSummary: ReserveHistorySummary | undefined; await ws.db.runWithWriteTransaction( [Stores.reserves, Stores.reserveHistory], async (tx) => { const r = await tx.get(Stores.reserves, reservePub); if (!r) { return; } if (r.reserveStatus !== ReserveRecordStatus.QUERYING_STATUS) { return; } const hist = await tx.get(Stores.reserveHistory, reservePub); if (!hist) { throw Error("inconsistent database"); } const newHistoryTransactions = reserveInfo.history.slice( hist.reserveTransactions.length, ); const reserveUpdateId = encodeCrock(getRandomBytes(32)); const reconciled = reconcileReserveHistory( hist.reserveTransactions, reserveInfo.history, ); updateSummary = summarizeReserveHistory( reconciled.updatedLocalHistory, currency, ); if ( reconciled.newAddedItems.length + reconciled.newMatchedItems.length != 0 ) { logger.trace("setting reserve status to 'withdrawing' after query"); r.reserveStatus = ReserveRecordStatus.WITHDRAWING; r.retryInfo = initRetryInfo(); r.requestedQuery = false; } else { if (r.requestedQuery) { logger.trace( "setting reserve status to 'querying-status' (requested query) after query", ); r.reserveStatus = ReserveRecordStatus.QUERYING_STATUS; r.requestedQuery = false; r.retryInfo = initRetryInfo(); } else { logger.trace("setting reserve status to 'dormant' after query"); r.reserveStatus = ReserveRecordStatus.DORMANT; r.retryInfo = initRetryInfo(false); } } r.lastSuccessfulStatusQuery = getTimestampNow(); hist.reserveTransactions = reconciled.updatedLocalHistory; r.lastError = undefined; await tx.put(Stores.reserves, r); await tx.put(Stores.reserveHistory, hist); }, ); ws.notify({ type: NotificationType.ReserveUpdated, updateSummary }); const reserve2 = await ws.db.get(Stores.reserves, reservePub); if (reserve2) { logger.trace( `after db transaction, reserve status is ${reserve2.reserveStatus}`, ); } return { ready: true }; } async function processReserveImpl( ws: InternalWalletState, reservePub: string, forceNow = false, ): Promise { const reserve = await ws.db.get(Stores.reserves, reservePub); if (!reserve) { logger.trace("not processing reserve: reserve does not exist"); return; } if (!forceNow) { const now = getTimestampNow(); if (reserve.retryInfo.nextRetry.t_ms > now.t_ms) { logger.trace("processReserve retry not due yet"); return; } } else { await resetReserveRetry(ws, reservePub); } logger.trace( `Processing reserve ${reservePub} with status ${reserve.reserveStatus}`, ); switch (reserve.reserveStatus) { case ReserveRecordStatus.REGISTERING_BANK: await processReserveBankStatus(ws, reservePub); return await processReserveImpl(ws, reservePub, true); case ReserveRecordStatus.QUERYING_STATUS: { const res = await updateReserve(ws, reservePub); if (res.ready) { return await processReserveImpl(ws, reservePub, true); } else { break; } } case ReserveRecordStatus.WITHDRAWING: await depleteReserve(ws, reservePub); break; case ReserveRecordStatus.DORMANT: // nothing to do break; case ReserveRecordStatus.WAIT_CONFIRM_BANK: await processReserveBankStatus(ws, reservePub); break; case ReserveRecordStatus.BANK_ABORTED: break; default: console.warn("unknown reserve record status:", reserve.reserveStatus); assertUnreachable(reserve.reserveStatus); break; } } /** * Withdraw coins from a reserve until it is empty. * * When finished, marks the reserve as depleted by setting * the depleted timestamp. */ async function depleteReserve( ws: InternalWalletState, reservePub: string, ): Promise { let reserve: ReserveRecord | undefined; let hist: ReserveHistoryRecord | undefined; await ws.db.runWithReadTransaction( [Stores.reserves, Stores.reserveHistory], async (tx) => { reserve = await tx.get(Stores.reserves, reservePub); hist = await tx.get(Stores.reserveHistory, reservePub); }, ); if (!reserve) { return; } if (!hist) { throw Error("inconsistent database"); } if (reserve.reserveStatus !== ReserveRecordStatus.WITHDRAWING) { return; } logger.trace(`depleting reserve ${reservePub}`); const summary = summarizeReserveHistory( hist.reserveTransactions, reserve.currency, ); const withdrawAmount = summary.unclaimedReserveAmount; const denomsForWithdraw = await selectWithdrawalDenoms( ws, reserve.exchangeBaseUrl, withdrawAmount, ); if (!denomsForWithdraw) { // Only complain about inability to withdraw if we // didn't withdraw before. if (Amounts.isZero(summary.withdrawnAmount)) { const opErr = makeErrorDetails( TalerErrorCode.WALLET_EXCHANGE_DENOMINATIONS_INSUFFICIENT, `Unable to withdraw from reserve, no denominations are available to withdraw.`, {}, ); await incrementReserveRetry(ws, reserve.reservePub, opErr); throw new OperationFailedAndReportedError(opErr); } return; } logger.trace( `Selected coins total cost ${Amounts.stringify( denomsForWithdraw.totalWithdrawCost, )} for withdrawal of ${Amounts.stringify(withdrawAmount)}`, ); logger.trace("selected denominations"); const newWithdrawalGroup = await ws.db.runWithWriteTransaction( [ Stores.withdrawalGroups, Stores.reserves, Stores.reserveHistory, Stores.planchets, ], async (tx) => { const newReserve = await tx.get(Stores.reserves, reservePub); if (!newReserve) { return false; } if (newReserve.reserveStatus !== ReserveRecordStatus.WITHDRAWING) { return false; } const newHist = await tx.get(Stores.reserveHistory, reservePub); if (!newHist) { throw Error("inconsistent database"); } const newSummary = summarizeReserveHistory( newHist.reserveTransactions, newReserve.currency, ); if ( Amounts.cmp( newSummary.unclaimedReserveAmount, denomsForWithdraw.totalWithdrawCost, ) < 0 ) { // Something must have happened concurrently! logger.error( "aborting withdrawal session, likely concurrent withdrawal happened", ); logger.error( `unclaimed reserve amount is ${newSummary.unclaimedReserveAmount}`, ); logger.error( `withdrawal cost is ${denomsForWithdraw.totalWithdrawCost}`, ); return false; } for (let i = 0; i < denomsForWithdraw.selectedDenoms.length; i++) { const sd = denomsForWithdraw.selectedDenoms[i]; for (let j = 0; j < sd.count; j++) { const amt = Amounts.add(sd.denom.value, sd.denom.feeWithdraw).amount; newHist.reserveTransactions.push({ type: WalletReserveHistoryItemType.Withdraw, expectedAmount: amt, }); } } logger.trace("setting reserve status to dormant after depletion"); newReserve.reserveStatus = ReserveRecordStatus.DORMANT; newReserve.retryInfo = initRetryInfo(false); let withdrawalGroupId: string; if (!newReserve.initialWithdrawalStarted) { withdrawalGroupId = newReserve.initialWithdrawalGroupId; newReserve.initialWithdrawalStarted = true; } else { withdrawalGroupId = encodeCrock(randomBytes(32)); } const withdrawalRecord: WithdrawalGroupRecord = { withdrawalGroupId: withdrawalGroupId, exchangeBaseUrl: newReserve.exchangeBaseUrl, reservePub: newReserve.reservePub, rawWithdrawalAmount: withdrawAmount, timestampStart: getTimestampNow(), retryInfo: initRetryInfo(), lastError: undefined, denomsSel: denomSelectionInfoToState(denomsForWithdraw), }; await tx.put(Stores.reserves, newReserve); await tx.put(Stores.reserveHistory, newHist); await tx.put(Stores.withdrawalGroups, withdrawalRecord); return withdrawalRecord; }, ); if (newWithdrawalGroup) { logger.trace("processing new withdraw group"); ws.notify({ type: NotificationType.WithdrawGroupCreated, withdrawalGroupId: newWithdrawalGroup.withdrawalGroupId, }); await processWithdrawGroup(ws, newWithdrawalGroup.withdrawalGroupId); } else { console.trace("withdraw session already existed"); } } export async function createTalerWithdrawReserve( ws: InternalWalletState, talerWithdrawUri: string, selectedExchange: string, ): Promise { const withdrawInfo = await getBankWithdrawalInfo(ws, talerWithdrawUri); const exchangeWire = await getExchangePaytoUri( ws, selectedExchange, withdrawInfo.wireTypes, ); const reserve = await createReserve(ws, { amount: withdrawInfo.amount, bankWithdrawStatusUrl: withdrawInfo.extractedStatusUrl, exchange: selectedExchange, senderWire: withdrawInfo.senderWire, exchangePaytoUri: exchangeWire, }); // 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, reserve.reservePub); const processedReserve = await ws.db.get(Stores.reserves, reserve.reservePub); if (processedReserve?.reserveStatus === ReserveRecordStatus.BANK_ABORTED) { throw OperationFailedError.fromCode( TalerErrorCode.WALLET_WITHDRAWAL_OPERATION_ABORTED_BY_BANK, "withdrawal aborted by bank", {}, ); } return { reservePub: reserve.reservePub, confirmTransferUrl: withdrawInfo.confirmTransferUrl, }; } /** * Get payto URIs needed to fund a reserve. */ export async function getFundingPaytoUris( tx: TransactionHandle, reservePub: string, ): Promise { const r = await tx.get(Stores.reserves, reservePub); if (!r) { logger.error(`reserve ${reservePub} not found (DB corrupted?)`); return []; } const exchange = await tx.get(Stores.exchanges, r.exchangeBaseUrl); if (!exchange) { logger.error(`exchange ${r.exchangeBaseUrl} not found (DB corrupted?)`); return []; } const plainPaytoUris = exchange.wireInfo?.accounts.map((x) => x.payto_uri) ?? []; if (!plainPaytoUris) { logger.error(`exchange ${r.exchangeBaseUrl} has no wire info`); return []; } return plainPaytoUris.map((x) => addPaytoQueryParams(x, { amount: Amounts.stringify(r.instructedAmount), message: `Taler Withdrawal ${r.reservePub}`, }), ); }