diff options
Diffstat (limited to 'src/operations/withdraw.ts')
-rw-r--r-- | src/operations/withdraw.ts | 756 |
1 files changed, 0 insertions, 756 deletions
diff --git a/src/operations/withdraw.ts b/src/operations/withdraw.ts deleted file mode 100644 index 486375300..000000000 --- a/src/operations/withdraw.ts +++ /dev/null @@ -1,756 +0,0 @@ -/* - This file is part of GNU Taler - (C) 2019-2020 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/> - */ - -import { AmountJson, Amounts } from "../util/amounts"; -import { - DenominationRecord, - Stores, - DenominationStatus, - CoinStatus, - CoinRecord, - initRetryInfo, - updateRetryInfoTimeout, - CoinSourceType, - DenominationSelectionInfo, - PlanchetRecord, - WithdrawalSourceType, - DenomSelectionState, -} from "../types/dbTypes"; -import { - BankWithdrawDetails, - ExchangeWithdrawDetails, - OperationErrorDetails, - ExchangeListItem, -} from "../types/walletTypes"; -import { - codecForWithdrawOperationStatusResponse, - codecForWithdrawResponse, - WithdrawUriInfoResponse, -} from "../types/talerTypes"; -import { InternalWalletState } from "./state"; -import { parseWithdrawUri } from "../util/taleruri"; -import { Logger } from "../util/logging"; -import { updateExchangeFromUrl, getExchangeTrust } from "./exchanges"; -import { WALLET_EXCHANGE_PROTOCOL_VERSION } from "./versions"; - -import * as LibtoolVersion from "../util/libtoolVersion"; -import { guardOperationException } from "./errors"; -import { NotificationType } from "../types/notifications"; -import { - getTimestampNow, - getDurationRemaining, - timestampCmp, - timestampSubtractDuraction, -} from "../util/time"; -import { readSuccessResponseJsonOrThrow } from "../util/http"; - -const logger = new Logger("withdraw.ts"); - -function isWithdrawableDenom(d: DenominationRecord): boolean { - const now = getTimestampNow(); - const started = timestampCmp(now, d.stampStart) >= 0; - const lastPossibleWithdraw = timestampSubtractDuraction( - d.stampExpireWithdraw, - { d_ms: 50 * 1000 }, - ); - const remaining = getDurationRemaining(lastPossibleWithdraw, now); - const stillOkay = remaining.d_ms !== 0; - return started && stillOkay && !d.isRevoked; -} - -/** - * Get a list of denominations (with repetitions possible) - * whose total value is as close as possible to the available - * amount, but never larger. - */ -export function getWithdrawDenomList( - amountAvailable: AmountJson, - denoms: DenominationRecord[], -): DenominationSelectionInfo { - let remaining = Amounts.copy(amountAvailable); - - const selectedDenoms: { - count: number; - denom: DenominationRecord; - }[] = []; - - let totalCoinValue = Amounts.getZero(amountAvailable.currency); - let totalWithdrawCost = Amounts.getZero(amountAvailable.currency); - - denoms = denoms.filter(isWithdrawableDenom); - denoms.sort((d1, d2) => Amounts.cmp(d2.value, d1.value)); - - for (const d of denoms) { - let count = 0; - const cost = Amounts.add(d.value, d.feeWithdraw).amount; - for (;;) { - if (Amounts.cmp(remaining, cost) < 0) { - break; - } - remaining = Amounts.sub(remaining, cost).amount; - count++; - } - if (count > 0) { - totalCoinValue = Amounts.add( - totalCoinValue, - Amounts.mult(d.value, count).amount, - ).amount; - totalWithdrawCost = Amounts.add( - totalWithdrawCost, - Amounts.mult(cost, count).amount, - ).amount; - selectedDenoms.push({ - count, - denom: d, - }); - } - - if (Amounts.isZero(remaining)) { - break; - } - } - - return { - selectedDenoms, - totalCoinValue, - totalWithdrawCost, - }; -} - -/** - * Get information about a withdrawal from - * a taler://withdraw URI by asking the bank. - */ -export async function getBankWithdrawalInfo( - ws: InternalWalletState, - talerWithdrawUri: string, -): Promise<BankWithdrawDetails> { - const uriResult = parseWithdrawUri(talerWithdrawUri); - if (!uriResult) { - throw Error(`can't parse URL ${talerWithdrawUri}`); - } - const reqUrl = new URL( - `api/withdraw-operation/${uriResult.withdrawalOperationId}`, - uriResult.bankIntegrationApiBaseUrl, - ); - const resp = await ws.http.get(reqUrl.href); - const status = await readSuccessResponseJsonOrThrow( - resp, - codecForWithdrawOperationStatusResponse(), - ); - - return { - amount: Amounts.parseOrThrow(status.amount), - confirmTransferUrl: status.confirm_transfer_url, - extractedStatusUrl: reqUrl.href, - 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. - */ -async function getPossibleDenoms( - ws: InternalWalletState, - exchangeBaseUrl: string, -): Promise<DenominationRecord[]> { - return await ws.db - .iterIndex(Stores.denominations.exchangeBaseUrlIndex, exchangeBaseUrl) - .filter((d) => { - return ( - (d.status === DenominationStatus.Unverified || - d.status === DenominationStatus.VerifiedGood) && - !d.isRevoked - ); - }); -} - -/** - * Given a planchet, withdraw a coin from the exchange. - */ -async function processPlanchet( - ws: InternalWalletState, - withdrawalGroupId: string, - coinIdx: number, -): Promise<void> { - const withdrawalGroup = await ws.db.get( - Stores.withdrawalGroups, - withdrawalGroupId, - ); - if (!withdrawalGroup) { - return; - } - let planchet = await ws.db.getIndexed(Stores.planchets.byGroupAndIndex, [ - withdrawalGroupId, - coinIdx, - ]); - if (!planchet) { - let ci = 0; - let denomPubHash: 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) { - denomPubHash = d.denomPubHash; - break; - } - ci += d.count; - } - if (!denomPubHash) { - throw Error("invariant violated"); - } - const denom = await ws.db.getIndexed( - Stores.denominations.denomPubHashIndex, - denomPubHash, - ); - if (!denom) { - throw Error("invariant violated"); - } - if (withdrawalGroup.source.type != WithdrawalSourceType.Reserve) { - throw Error("invariant violated"); - } - const reserve = await ws.db.get( - Stores.reserves, - withdrawalGroup.source.reservePub, - ); - if (!reserve) { - throw Error("invariant violated"); - } - const r = await ws.cryptoApi.createPlanchet({ - denomPub: denom.denomPub, - feeWithdraw: denom.feeWithdraw, - reservePriv: reserve.reservePriv, - reservePub: reserve.reservePub, - value: denom.value, - }); - const newPlanchet: PlanchetRecord = { - blindingKey: r.blindingKey, - coinEv: r.coinEv, - coinEvHash: r.coinEvHash, - coinIdx, - coinPriv: r.coinPriv, - coinPub: r.coinPub, - coinValue: r.coinValue, - denomPub: r.denomPub, - denomPubHash: r.denomPubHash, - isFromTip: false, - reservePub: r.reservePub, - withdrawalDone: false, - withdrawSig: r.withdrawSig, - withdrawalGroupId: withdrawalGroupId, - }; - await ws.db.runWithWriteTransaction([Stores.planchets], async (tx) => { - const p = await tx.getIndexed(Stores.planchets.byGroupAndIndex, [ - withdrawalGroupId, - coinIdx, - ]); - if (p) { - planchet = p; - return; - } - await tx.put(Stores.planchets, newPlanchet); - planchet = newPlanchet; - }); - } - if (!planchet) { - throw Error("invariant violated"); - } - if (planchet.withdrawalDone) { - logger.warn("processPlanchet: planchet already withdrawn"); - return; - } - const exchange = await ws.db.get( - Stores.exchanges, - withdrawalGroup.exchangeBaseUrl, - ); - if (!exchange) { - logger.error("db inconsistent: exchange for planchet not found"); - return; - } - - const denom = await ws.db.get(Stores.denominations, [ - withdrawalGroup.exchangeBaseUrl, - planchet.denomPub, - ]); - - if (!denom) { - console.error("db inconsistent: denom for planchet not found"); - return; - } - - logger.trace( - `processing planchet #${coinIdx} in withdrawal ${withdrawalGroupId}`, - ); - - const wd: any = {}; - wd.denom_pub_hash = planchet.denomPubHash; - wd.reserve_pub = planchet.reservePub; - wd.reserve_sig = planchet.withdrawSig; - wd.coin_ev = planchet.coinEv; - const reqUrl = new URL( - `reserves/${planchet.reservePub}/withdraw`, - exchange.baseUrl, - ).href; - - const resp = await ws.http.postJson(reqUrl, wd); - const r = await readSuccessResponseJsonOrThrow( - resp, - codecForWithdrawResponse(), - ); - - logger.trace(`got response for /withdraw`); - - const denomSig = await ws.cryptoApi.rsaUnblind( - r.ev_sig, - planchet.blindingKey, - planchet.denomPub, - ); - - const isValid = await ws.cryptoApi.rsaVerify( - planchet.coinPub, - denomSig, - planchet.denomPub, - ); - - if (!isValid) { - throw Error("invalid RSA signature by the exchange"); - } - - logger.trace(`unblinded and verified`); - - const coin: CoinRecord = { - blindingKey: planchet.blindingKey, - coinPriv: planchet.coinPriv, - coinPub: planchet.coinPub, - currentAmount: planchet.coinValue, - denomPub: planchet.denomPub, - denomPubHash: planchet.denomPubHash, - denomSig, - exchangeBaseUrl: withdrawalGroup.exchangeBaseUrl, - status: CoinStatus.Fresh, - coinSource: { - type: CoinSourceType.Withdraw, - coinIndex: coinIdx, - reservePub: planchet.reservePub, - withdrawalGroupId: withdrawalGroupId, - }, - suspended: false, - }; - - let withdrawalGroupFinished = false; - - const planchetCoinPub = planchet.coinPub; - - const success = await ws.db.runWithWriteTransaction( - [Stores.coins, Stores.withdrawalGroups, Stores.reserves, Stores.planchets], - async (tx) => { - const ws = await tx.get(Stores.withdrawalGroups, withdrawalGroupId); - if (!ws) { - return false; - } - const p = await tx.get(Stores.planchets, planchetCoinPub); - if (!p) { - return false; - } - if (p.withdrawalDone) { - // Already withdrawn - return false; - } - p.withdrawalDone = true; - await tx.put(Stores.planchets, p); - - let numTotal = 0; - - for (const ds of ws.denomsSel.selectedDenoms) { - numTotal += ds.count; - } - - let numDone = 0; - - await tx - .iterIndexed(Stores.planchets.byGroup, withdrawalGroupId) - .forEach((x) => { - if (x.withdrawalDone) { - numDone++; - } - }); - - if (numDone > numTotal) { - throw Error( - "invariant violated (created more planchets than expected)", - ); - } - - if (numDone == numTotal) { - ws.timestampFinish = getTimestampNow(); - ws.lastError = undefined; - ws.retryInfo = initRetryInfo(false); - withdrawalGroupFinished = true; - } - await tx.put(Stores.withdrawalGroups, ws); - await tx.add(Stores.coins, coin); - return true; - }, - ); - - logger.trace(`withdrawal result stored in DB`); - - if (success) { - ws.notify({ - type: NotificationType.CoinWithdrawn, - }); - } - - if (withdrawalGroupFinished) { - ws.notify({ - type: NotificationType.WithdrawGroupFinished, - withdrawalSource: withdrawalGroup.source, - }); - } -} - -export function denomSelectionInfoToState( - dsi: DenominationSelectionInfo, -): DenomSelectionState { - return { - selectedDenoms: dsi.selectedDenoms.map((x) => { - return { - count: x.count, - denomPubHash: x.denom.denomPubHash, - }; - }), - totalCoinValue: dsi.totalCoinValue, - totalWithdrawCost: dsi.totalWithdrawCost, - }; -} - -/** - * Get a list of denominations to withdraw from the given exchange for the - * given amount, making sure that all denominations' signatures are verified. - * - * Writes to the DB in order to record the result from verifying - * denominations. - */ -export async function selectWithdrawalDenoms( - ws: InternalWalletState, - exchangeBaseUrl: string, - amount: AmountJson, -): Promise<DenominationSelectionInfo> { - const exchange = await ws.db.get(Stores.exchanges, exchangeBaseUrl); - if (!exchange) { - logger.error("exchange not found"); - throw Error(`exchange ${exchangeBaseUrl} not found`); - } - const exchangeDetails = exchange.details; - if (!exchangeDetails) { - logger.error("exchange details not available"); - throw Error(`exchange ${exchangeBaseUrl} details not available`); - } - - let allValid = false; - let selectedDenoms: DenominationSelectionInfo; - - // Find a denomination selection for the requested amount. - // If a selected denomination has not been validated yet - // and turns our to be invalid, we try again with the - // reduced set of denominations. - do { - allValid = true; - const nextPossibleDenoms = await getPossibleDenoms(ws, exchange.baseUrl); - selectedDenoms = getWithdrawDenomList(amount, nextPossibleDenoms); - for (const denomSel of selectedDenoms.selectedDenoms) { - const denom = denomSel.denom; - if (denom.status === DenominationStatus.Unverified) { - const valid = await ws.cryptoApi.isValidDenom( - denom, - exchangeDetails.masterPublicKey, - ); - if (!valid) { - denom.status = DenominationStatus.VerifiedBad; - allValid = false; - } else { - denom.status = DenominationStatus.VerifiedGood; - } - await ws.db.put(Stores.denominations, denom); - } - } - } while (selectedDenoms.selectedDenoms.length > 0 && !allValid); - - if (Amounts.cmp(selectedDenoms.totalWithdrawCost, amount) > 0) { - throw Error("Bug: withdrawal coin selection is wrong"); - } - - return selectedDenoms; -} - -async function incrementWithdrawalRetry( - ws: InternalWalletState, - withdrawalGroupId: string, - err: OperationErrorDetails | undefined, -): Promise<void> { - await ws.db.runWithWriteTransaction([Stores.withdrawalGroups], async (tx) => { - const wsr = await tx.get(Stores.withdrawalGroups, withdrawalGroupId); - if (!wsr) { - return; - } - if (!wsr.retryInfo) { - return; - } - wsr.retryInfo.retryCounter++; - updateRetryInfoTimeout(wsr.retryInfo); - wsr.lastError = err; - await tx.put(Stores.withdrawalGroups, wsr); - }); - if (err) { - ws.notify({ type: NotificationType.WithdrawOperationError, error: err }); - } -} - -export async function processWithdrawGroup( - ws: InternalWalletState, - withdrawalGroupId: string, - forceNow = false, -): Promise<void> { - const onOpErr = (e: OperationErrorDetails): Promise<void> => - incrementWithdrawalRetry(ws, withdrawalGroupId, e); - await guardOperationException( - () => processWithdrawGroupImpl(ws, withdrawalGroupId, forceNow), - onOpErr, - ); -} - -async function resetWithdrawalGroupRetry( - ws: InternalWalletState, - withdrawalGroupId: string, -): Promise<void> { - await ws.db.mutate(Stores.withdrawalGroups, withdrawalGroupId, (x) => { - if (x.retryInfo.active) { - x.retryInfo = initRetryInfo(); - } - return x; - }); -} - -async function processInBatches( - workGen: Iterator<Promise<void>>, - batchSize: number, -): Promise<void> { - for (;;) { - const batch: Promise<void>[] = []; - for (let i = 0; i < batchSize; i++) { - const wn = workGen.next(); - if (wn.done) { - break; - } - batch.push(wn.value); - } - if (batch.length == 0) { - break; - } - logger.trace(`processing withdrawal batch of ${batch.length} elements`); - await Promise.all(batch); - } -} - -async function processWithdrawGroupImpl( - ws: InternalWalletState, - withdrawalGroupId: string, - forceNow: boolean, -): Promise<void> { - logger.trace("processing withdraw group", withdrawalGroupId); - if (forceNow) { - await resetWithdrawalGroupRetry(ws, withdrawalGroupId); - } - const withdrawalGroup = await ws.db.get( - Stores.withdrawalGroups, - withdrawalGroupId, - ); - if (!withdrawalGroup) { - logger.trace("withdraw session doesn't exist"); - return; - } - - const numDenoms = withdrawalGroup.denomsSel.selectedDenoms.length; - const genWork = function* (): Iterator<Promise<void>> { - let coinIdx = 0; - for (let i = 0; i < numDenoms; i++) { - const count = withdrawalGroup.denomsSel.selectedDenoms[i].count; - for (let j = 0; j < count; j++) { - yield processPlanchet(ws, withdrawalGroupId, coinIdx); - coinIdx++; - } - } - }; - - // Withdraw coins in batches. - // The batch size is relatively large - await processInBatches(genWork(), 10); -} - -export async function getExchangeWithdrawalInfo( - ws: InternalWalletState, - baseUrl: string, - amount: AmountJson, -): Promise<ExchangeWithdrawDetails> { - const exchangeInfo = await updateExchangeFromUrl(ws, baseUrl); - const exchangeDetails = exchangeInfo.details; - if (!exchangeDetails) { - throw Error(`exchange ${exchangeInfo.baseUrl} details not available`); - } - const exchangeWireInfo = exchangeInfo.wireInfo; - if (!exchangeWireInfo) { - throw Error(`exchange ${exchangeInfo.baseUrl} wire details not available`); - } - - const selectedDenoms = await selectWithdrawalDenoms(ws, baseUrl, amount); - const exchangeWireAccounts: string[] = []; - for (const account of exchangeWireInfo.accounts) { - exchangeWireAccounts.push(account.payto_uri); - } - - const { isTrusted, isAudited } = await getExchangeTrust(ws, exchangeInfo); - - let earliestDepositExpiration = - selectedDenoms.selectedDenoms[0].denom.stampExpireDeposit; - for (let i = 1; i < selectedDenoms.selectedDenoms.length; i++) { - const expireDeposit = - selectedDenoms.selectedDenoms[i].denom.stampExpireDeposit; - if (expireDeposit.t_ms < earliestDepositExpiration.t_ms) { - earliestDepositExpiration = expireDeposit; - } - } - - const possibleDenoms = await ws.db - .iterIndex(Stores.denominations.exchangeBaseUrlIndex, baseUrl) - .filter((d) => d.isOffered); - - const trustedAuditorPubs = []; - const currencyRecord = await ws.db.get(Stores.currencies, amount.currency); - if (currencyRecord) { - trustedAuditorPubs.push( - ...currencyRecord.auditors.map((a) => a.auditorPub), - ); - } - - let versionMatch; - if (exchangeDetails.protocolVersion) { - versionMatch = LibtoolVersion.compare( - WALLET_EXCHANGE_PROTOCOL_VERSION, - exchangeDetails.protocolVersion, - ); - - if ( - versionMatch && - !versionMatch.compatible && - versionMatch.currentCmp === -1 - ) { - console.warn( - `wallet's support for exchange protocol version ${WALLET_EXCHANGE_PROTOCOL_VERSION} might be outdated ` + - `(exchange has ${exchangeDetails.protocolVersion}), checking for updates`, - ); - } - } - - let tosAccepted = false; - - if (exchangeInfo.termsOfServiceAcceptedTimestamp) { - if ( - exchangeInfo.termsOfServiceAcceptedEtag == - exchangeInfo.termsOfServiceLastEtag - ) { - tosAccepted = true; - } - } - - const withdrawFee = Amounts.sub( - selectedDenoms.totalWithdrawCost, - selectedDenoms.totalCoinValue, - ).amount; - - const ret: ExchangeWithdrawDetails = { - earliestDepositExpiration, - exchangeInfo, - exchangeWireAccounts, - exchangeVersion: exchangeDetails.protocolVersion || "unknown", - isAudited, - isTrusted, - numOfferedDenoms: possibleDenoms.length, - overhead: Amounts.sub(amount, selectedDenoms.totalWithdrawCost).amount, - selectedDenoms, - trustedAuditorPubs, - versionMatch, - walletVersion: WALLET_EXCHANGE_PROTOCOL_VERSION, - wireFees: exchangeWireInfo, - withdrawFee, - termsOfServiceAccepted: tosAccepted, - }; - return ret; -} - -export async function getWithdrawalDetailsForUri( - ws: InternalWalletState, - talerWithdrawUri: string, -): Promise<WithdrawUriInfoResponse> { - const info = await getBankWithdrawalInfo(ws, talerWithdrawUri); - if (info.suggestedExchange) { - // FIXME: right now the exchange gets permanently added, - // we might want to only temporarily add it. - try { - await 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`) - } - } - - const exchangesRes: (ExchangeListItem | undefined)[] = await ws.db - .iter(Stores.exchanges) - .map((x) => { - const details = x.details; - if (!details) { - return undefined; - } - if (!x.addComplete) { - return undefined; - } - if (!x.wireInfo) { - return undefined; - } - if (details.currency !== info.amount.currency) { - return undefined; - } - return { - exchangeBaseUrl: x.baseUrl, - currency: details.currency, - paytoUris: x.wireInfo.accounts.map((x) => x.payto_uri), - }; - }); - const exchanges = exchangesRes.filter((x) => !!x) as ExchangeListItem[]; - - return { - amount: Amounts.stringify(info.amount), - defaultExchangeBaseUrl: info.suggestedExchange, - possibleExchanges: exchanges, - } -} |