From ffd2a62c3f7df94365980302fef3bc3376b48182 Mon Sep 17 00:00:00 2001 From: Florian Dold Date: Mon, 3 Aug 2020 13:00:48 +0530 Subject: modularize repo, use pnpm, improve typechecking --- .../taler-wallet-core/src/operations/refresh.ts | 573 +++++++++++++++++++++ 1 file changed, 573 insertions(+) create mode 100644 packages/taler-wallet-core/src/operations/refresh.ts (limited to 'packages/taler-wallet-core/src/operations/refresh.ts') diff --git a/packages/taler-wallet-core/src/operations/refresh.ts b/packages/taler-wallet-core/src/operations/refresh.ts new file mode 100644 index 000000000..646bc2edf --- /dev/null +++ b/packages/taler-wallet-core/src/operations/refresh.ts @@ -0,0 +1,573 @@ +/* + 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 { Amounts, AmountJson } from "../util/amounts"; +import { + DenominationRecord, + Stores, + CoinStatus, + RefreshPlanchetRecord, + CoinRecord, + RefreshSessionRecord, + initRetryInfo, + updateRetryInfoTimeout, + RefreshGroupRecord, + CoinSourceType, +} from "../types/dbTypes"; +import { amountToPretty } from "../util/helpers"; +import { TransactionHandle } from "../util/query"; +import { InternalWalletState } from "./state"; +import { Logger } from "../util/logging"; +import { getWithdrawDenomList } from "./withdraw"; +import { updateExchangeFromUrl } from "./exchanges"; +import { + OperationErrorDetails, + CoinPublicKey, + RefreshReason, + RefreshGroupId, +} from "../types/walletTypes"; +import { guardOperationException } from "./errors"; +import { NotificationType } from "../types/notifications"; +import { getRandomBytes, encodeCrock } from "../crypto/talerCrypto"; +import { getTimestampNow } from "../util/time"; +import { readSuccessResponseJsonOrThrow } from "../util/http"; +import { + codecForExchangeMeltResponse, + codecForExchangeRevealResponse, +} from "../types/talerTypes"; +import { URL } from "../util/url"; + +const logger = new Logger("refresh.ts"); + +/** + * Get the amount that we lose when refreshing a coin of the given denomination + * with a certain amount left. + * + * If the amount left is zero, then the refresh cost + * is also considered to be zero. If a refresh isn't possible (e.g. due to lack of + * the right denominations), then the cost is the full amount left. + * + * Considers refresh fees, withdrawal fees after refresh and amounts too small + * to refresh. + */ +export function getTotalRefreshCost( + denoms: DenominationRecord[], + refreshedDenom: DenominationRecord, + amountLeft: AmountJson, +): AmountJson { + const withdrawAmount = Amounts.sub(amountLeft, refreshedDenom.feeRefresh) + .amount; + const withdrawDenoms = getWithdrawDenomList(withdrawAmount, denoms); + const resultingAmount = Amounts.add( + Amounts.getZero(withdrawAmount.currency), + ...withdrawDenoms.selectedDenoms.map( + (d) => Amounts.mult(d.denom.value, d.count).amount, + ), + ).amount; + const totalCost = Amounts.sub(amountLeft, resultingAmount).amount; + logger.trace( + `total refresh cost for ${amountToPretty(amountLeft)} is ${amountToPretty( + totalCost, + )}`, + ); + return totalCost; +} + +/** + * Create a refresh session inside a refresh group. + */ +async function refreshCreateSession( + ws: InternalWalletState, + refreshGroupId: string, + coinIndex: number, +): Promise { + logger.trace( + `creating refresh session for coin ${coinIndex} in refresh group ${refreshGroupId}`, + ); + const refreshGroup = await ws.db.get(Stores.refreshGroups, refreshGroupId); + if (!refreshGroup) { + return; + } + if (refreshGroup.finishedPerCoin[coinIndex]) { + return; + } + const existingRefreshSession = refreshGroup.refreshSessionPerCoin[coinIndex]; + if (existingRefreshSession) { + return; + } + const oldCoinPub = refreshGroup.oldCoinPubs[coinIndex]; + const coin = await ws.db.get(Stores.coins, oldCoinPub); + if (!coin) { + throw Error("Can't refresh, coin not found"); + } + + const exchange = await updateExchangeFromUrl(ws, coin.exchangeBaseUrl); + if (!exchange) { + throw Error("db inconsistent: exchange of coin not found"); + } + + const oldDenom = await ws.db.get(Stores.denominations, [ + exchange.baseUrl, + coin.denomPub, + ]); + + if (!oldDenom) { + throw Error("db inconsistent: denomination for coin not found"); + } + + const availableDenoms: DenominationRecord[] = await ws.db + .iterIndex(Stores.denominations.exchangeBaseUrlIndex, exchange.baseUrl) + .toArray(); + + const availableAmount = Amounts.sub(coin.currentAmount, oldDenom.feeRefresh) + .amount; + + const newCoinDenoms = getWithdrawDenomList(availableAmount, availableDenoms); + + if (newCoinDenoms.selectedDenoms.length === 0) { + logger.trace( + `not refreshing, available amount ${amountToPretty( + availableAmount, + )} too small`, + ); + await ws.db.runWithWriteTransaction( + [Stores.coins, Stores.refreshGroups], + async (tx) => { + const rg = await tx.get(Stores.refreshGroups, refreshGroupId); + if (!rg) { + return; + } + rg.finishedPerCoin[coinIndex] = true; + let allDone = true; + for (const f of rg.finishedPerCoin) { + if (!f) { + allDone = false; + break; + } + } + if (allDone) { + rg.timestampFinished = getTimestampNow(); + rg.retryInfo = initRetryInfo(false); + } + await tx.put(Stores.refreshGroups, rg); + }, + ); + ws.notify({ type: NotificationType.RefreshUnwarranted }); + return; + } + + const refreshSession: RefreshSessionRecord = await ws.cryptoApi.createRefreshSession( + exchange.baseUrl, + 3, + coin, + newCoinDenoms, + oldDenom.feeRefresh, + ); + + // Store refresh session and subtract refreshed amount from + // coin in the same transaction. + await ws.db.runWithWriteTransaction( + [Stores.refreshGroups, Stores.coins], + async (tx) => { + const c = await tx.get(Stores.coins, coin.coinPub); + if (!c) { + throw Error("coin not found, but marked for refresh"); + } + const r = Amounts.sub(c.currentAmount, refreshSession.amountRefreshInput); + if (r.saturated) { + console.log("can't refresh coin, no amount left"); + return; + } + c.currentAmount = r.amount; + c.status = CoinStatus.Dormant; + const rg = await tx.get(Stores.refreshGroups, refreshGroupId); + if (!rg) { + return; + } + if (rg.refreshSessionPerCoin[coinIndex]) { + return; + } + rg.refreshSessionPerCoin[coinIndex] = refreshSession; + await tx.put(Stores.refreshGroups, rg); + await tx.put(Stores.coins, c); + }, + ); + logger.info( + `created refresh session for coin #${coinIndex} in ${refreshGroupId}`, + ); + ws.notify({ type: NotificationType.RefreshStarted }); +} + +async function refreshMelt( + ws: InternalWalletState, + refreshGroupId: string, + coinIndex: number, +): Promise { + const refreshGroup = await ws.db.get(Stores.refreshGroups, refreshGroupId); + if (!refreshGroup) { + return; + } + const refreshSession = refreshGroup.refreshSessionPerCoin[coinIndex]; + if (!refreshSession) { + return; + } + if (refreshSession.norevealIndex !== undefined) { + return; + } + + const coin = await ws.db.get(Stores.coins, refreshSession.meltCoinPub); + + if (!coin) { + console.error("can't melt coin, it does not exist"); + return; + } + + const reqUrl = new URL( + `coins/${coin.coinPub}/melt`, + refreshSession.exchangeBaseUrl, + ); + const meltReq = { + coin_pub: coin.coinPub, + confirm_sig: refreshSession.confirmSig, + denom_pub_hash: coin.denomPubHash, + denom_sig: coin.denomSig, + rc: refreshSession.hash, + value_with_fee: Amounts.stringify(refreshSession.amountRefreshInput), + }; + logger.trace(`melt request for coin:`, meltReq); + const resp = await ws.http.postJson(reqUrl.href, meltReq); + const meltResponse = await readSuccessResponseJsonOrThrow( + resp, + codecForExchangeMeltResponse(), + ); + + const norevealIndex = meltResponse.noreveal_index; + + refreshSession.norevealIndex = norevealIndex; + + await ws.db.mutate(Stores.refreshGroups, refreshGroupId, (rg) => { + const rs = rg.refreshSessionPerCoin[coinIndex]; + if (!rs) { + return; + } + if (rs.norevealIndex !== undefined) { + return; + } + if (rs.finishedTimestamp) { + return; + } + rs.norevealIndex = norevealIndex; + return rg; + }); + + ws.notify({ + type: NotificationType.RefreshMelted, + }); +} + +async function refreshReveal( + ws: InternalWalletState, + refreshGroupId: string, + coinIndex: number, +): Promise { + const refreshGroup = await ws.db.get(Stores.refreshGroups, refreshGroupId); + if (!refreshGroup) { + return; + } + const refreshSession = refreshGroup.refreshSessionPerCoin[coinIndex]; + if (!refreshSession) { + return; + } + const norevealIndex = refreshSession.norevealIndex; + if (norevealIndex === undefined) { + throw Error("can't reveal without melting first"); + } + const privs = Array.from(refreshSession.transferPrivs); + privs.splice(norevealIndex, 1); + + const planchets = refreshSession.planchetsForGammas[norevealIndex]; + if (!planchets) { + throw Error("refresh index error"); + } + + const meltCoinRecord = await ws.db.get( + Stores.coins, + refreshSession.meltCoinPub, + ); + if (!meltCoinRecord) { + throw Error("inconsistent database"); + } + + const evs = planchets.map((x: RefreshPlanchetRecord) => x.coinEv); + + const linkSigs: string[] = []; + for (let i = 0; i < refreshSession.newDenoms.length; i++) { + const linkSig = await ws.cryptoApi.signCoinLink( + meltCoinRecord.coinPriv, + refreshSession.newDenomHashes[i], + refreshSession.meltCoinPub, + refreshSession.transferPubs[norevealIndex], + planchets[i].coinEv, + ); + linkSigs.push(linkSig); + } + + const req = { + coin_evs: evs, + new_denoms_h: refreshSession.newDenomHashes, + rc: refreshSession.hash, + transfer_privs: privs, + transfer_pub: refreshSession.transferPubs[norevealIndex], + link_sigs: linkSigs, + }; + + const reqUrl = new URL( + `refreshes/${refreshSession.hash}/reveal`, + refreshSession.exchangeBaseUrl, + ); + + const resp = await ws.http.postJson(reqUrl.href, req); + const reveal = await readSuccessResponseJsonOrThrow( + resp, + codecForExchangeRevealResponse(), + ); + + const coins: CoinRecord[] = []; + + for (let i = 0; i < reveal.ev_sigs.length; i++) { + const denom = await ws.db.get(Stores.denominations, [ + refreshSession.exchangeBaseUrl, + refreshSession.newDenoms[i], + ]); + if (!denom) { + console.error("denom not found"); + continue; + } + const pc = refreshSession.planchetsForGammas[norevealIndex][i]; + const denomSig = await ws.cryptoApi.rsaUnblind( + reveal.ev_sigs[i].ev_sig, + pc.blindingKey, + denom.denomPub, + ); + const coin: CoinRecord = { + blindingKey: pc.blindingKey, + coinPriv: pc.privateKey, + coinPub: pc.publicKey, + currentAmount: denom.value, + denomPub: denom.denomPub, + denomPubHash: denom.denomPubHash, + denomSig, + exchangeBaseUrl: refreshSession.exchangeBaseUrl, + status: CoinStatus.Fresh, + coinSource: { + type: CoinSourceType.Refresh, + oldCoinPub: refreshSession.meltCoinPub, + }, + suspended: false, + }; + + coins.push(coin); + } + + await ws.db.runWithWriteTransaction( + [Stores.coins, Stores.refreshGroups], + async (tx) => { + const rg = await tx.get(Stores.refreshGroups, refreshGroupId); + if (!rg) { + console.log("no refresh session found"); + return; + } + const rs = rg.refreshSessionPerCoin[coinIndex]; + if (!rs) { + return; + } + if (rs.finishedTimestamp) { + console.log("refresh session already finished"); + return; + } + rs.finishedTimestamp = getTimestampNow(); + rg.finishedPerCoin[coinIndex] = true; + let allDone = true; + for (const f of rg.finishedPerCoin) { + if (!f) { + allDone = false; + break; + } + } + if (allDone) { + rg.timestampFinished = getTimestampNow(); + rg.retryInfo = initRetryInfo(false); + } + for (const coin of coins) { + await tx.put(Stores.coins, coin); + } + await tx.put(Stores.refreshGroups, rg); + }, + ); + console.log("refresh finished (end of reveal)"); + ws.notify({ + type: NotificationType.RefreshRevealed, + }); +} + +async function incrementRefreshRetry( + ws: InternalWalletState, + refreshGroupId: string, + err: OperationErrorDetails | undefined, +): Promise { + await ws.db.runWithWriteTransaction([Stores.refreshGroups], async (tx) => { + const r = await tx.get(Stores.refreshGroups, refreshGroupId); + if (!r) { + return; + } + if (!r.retryInfo) { + return; + } + r.retryInfo.retryCounter++; + updateRetryInfoTimeout(r.retryInfo); + r.lastError = err; + await tx.put(Stores.refreshGroups, r); + }); + if (err) { + ws.notify({ type: NotificationType.RefreshOperationError, error: err }); + } +} + +export async function processRefreshGroup( + ws: InternalWalletState, + refreshGroupId: string, + forceNow = false, +): Promise { + await ws.memoProcessRefresh.memo(refreshGroupId, async () => { + const onOpErr = (e: OperationErrorDetails): Promise => + incrementRefreshRetry(ws, refreshGroupId, e); + return await guardOperationException( + async () => await processRefreshGroupImpl(ws, refreshGroupId, forceNow), + onOpErr, + ); + }); +} + +async function resetRefreshGroupRetry( + ws: InternalWalletState, + refreshSessionId: string, +): Promise { + await ws.db.mutate(Stores.refreshGroups, refreshSessionId, (x) => { + if (x.retryInfo.active) { + x.retryInfo = initRetryInfo(); + } + return x; + }); +} + +async function processRefreshGroupImpl( + ws: InternalWalletState, + refreshGroupId: string, + forceNow: boolean, +): Promise { + if (forceNow) { + await resetRefreshGroupRetry(ws, refreshGroupId); + } + const refreshGroup = await ws.db.get(Stores.refreshGroups, refreshGroupId); + if (!refreshGroup) { + return; + } + if (refreshGroup.timestampFinished) { + return; + } + const ps = refreshGroup.oldCoinPubs.map((x, i) => + processRefreshSession(ws, refreshGroupId, i), + ); + await Promise.all(ps); + logger.trace("refresh finished"); +} + +async function processRefreshSession( + ws: InternalWalletState, + refreshGroupId: string, + coinIndex: number, +): Promise { + logger.trace( + `processing refresh session for coin ${coinIndex} of group ${refreshGroupId}`, + ); + let refreshGroup = await ws.db.get(Stores.refreshGroups, refreshGroupId); + if (!refreshGroup) { + return; + } + if (refreshGroup.finishedPerCoin[coinIndex]) { + return; + } + if (!refreshGroup.refreshSessionPerCoin[coinIndex]) { + await refreshCreateSession(ws, refreshGroupId, coinIndex); + refreshGroup = await ws.db.get(Stores.refreshGroups, refreshGroupId); + if (!refreshGroup) { + return; + } + } + const refreshSession = refreshGroup.refreshSessionPerCoin[coinIndex]; + if (!refreshSession) { + if (!refreshGroup.finishedPerCoin[coinIndex]) { + throw Error( + "BUG: refresh session was not created and coin not marked as finished", + ); + } + return; + } + if (refreshSession.norevealIndex === undefined) { + await refreshMelt(ws, refreshGroupId, coinIndex); + } + await refreshReveal(ws, refreshGroupId, coinIndex); +} + +/** + * Create a refresh group for a list of coins. + */ +export async function createRefreshGroup( + ws: InternalWalletState, + tx: TransactionHandle, + oldCoinPubs: CoinPublicKey[], + reason: RefreshReason, +): Promise { + const refreshGroupId = encodeCrock(getRandomBytes(32)); + + const refreshGroup: RefreshGroupRecord = { + timestampFinished: undefined, + finishedPerCoin: oldCoinPubs.map((x) => false), + lastError: undefined, + lastErrorPerCoin: {}, + oldCoinPubs: oldCoinPubs.map((x) => x.coinPub), + reason, + refreshGroupId, + refreshSessionPerCoin: oldCoinPubs.map((x) => undefined), + retryInfo: initRetryInfo(), + }; + + await tx.put(Stores.refreshGroups, refreshGroup); + + const processAsync = async (): Promise => { + try { + await processRefreshGroup(ws, refreshGroupId); + } catch (e) { + logger.trace(`Error during refresh: ${e}`); + } + }; + + processAsync(); + + return { + refreshGroupId, + }; +} -- cgit v1.2.3