diff options
author | Florian Dold <florian.dold@gmail.com> | 2019-12-02 00:42:40 +0100 |
---|---|---|
committer | Florian Dold <florian.dold@gmail.com> | 2019-12-02 00:42:40 +0100 |
commit | e1369ff7e8fc02116b9c4261036f0e42e3423cf4 (patch) | |
tree | c621067ebda8977a888bfed34b7bbecf64b3b0f0 /src/wallet-impl/refresh.ts | |
parent | aaf7e1338d6cdb1b4e01ad318938b3eaea2f922b (diff) | |
download | wallet-core-e1369ff7e8fc02116b9c4261036f0e42e3423cf4.tar.xz |
the giant refactoring: split wallet into multiple parts
Diffstat (limited to 'src/wallet-impl/refresh.ts')
-rw-r--r-- | src/wallet-impl/refresh.ts | 416 |
1 files changed, 416 insertions, 0 deletions
diff --git a/src/wallet-impl/refresh.ts b/src/wallet-impl/refresh.ts new file mode 100644 index 000000000..7e7270ed3 --- /dev/null +++ b/src/wallet-impl/refresh.ts @@ -0,0 +1,416 @@ +/* + 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 <http://www.gnu.org/licenses/> + */ + +import { AmountJson } from "../util/amounts"; +import * as Amounts from "../util/amounts"; +import { + DenominationRecord, + Stores, + CoinStatus, + RefreshPlanchetRecord, + CoinRecord, + RefreshSessionRecord, +} from "../dbTypes"; +import { amountToPretty } from "../util/helpers"; +import { + oneShotGet, + oneShotMutate, + runWithWriteTransaction, + TransactionAbort, + oneShotIterIndex, +} from "../util/query"; +import { InternalWalletState } from "./state"; +import { Logger } from "../util/logging"; +import { getWithdrawDenomList } from "./withdraw"; +import { updateExchangeFromUrl } from "./exchanges"; + +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.map(d => d.value), + ).amount; + const totalCost = Amounts.sub(amountLeft, resultingAmount).amount; + logger.trace( + "total refresh cost for", + amountToPretty(amountLeft), + "is", + amountToPretty(totalCost), + ); + return totalCost; +} + +async function refreshMelt( + ws: InternalWalletState, + refreshSessionId: string, +): Promise<void> { + const refreshSession = await oneShotGet( + ws.db, + Stores.refresh, + refreshSessionId, + ); + if (!refreshSession) { + return; + } + if (refreshSession.norevealIndex !== undefined) { + return; + } + + const coin = await oneShotGet( + ws.db, + Stores.coins, + refreshSession.meltCoinPub, + ); + + if (!coin) { + console.error("can't melt coin, it does not exist"); + return; + } + + const reqUrl = new URL("refresh/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: refreshSession.valueWithFee, + }; + logger.trace("melt request:", meltReq); + const resp = await ws.http.postJson(reqUrl.href, meltReq); + + logger.trace("melt response:", resp.responseJson); + + if (resp.status !== 200) { + console.error(resp.responseJson); + throw Error("refresh failed"); + } + + const respJson = resp.responseJson; + + const norevealIndex = respJson.noreveal_index; + + if (typeof norevealIndex !== "number") { + throw Error("invalid response"); + } + + refreshSession.norevealIndex = norevealIndex; + + await oneShotMutate(ws.db, Stores.refresh, refreshSessionId, rs => { + if (rs.norevealIndex !== undefined) { + return; + } + if (rs.finished) { + return; + } + rs.norevealIndex = norevealIndex; + return rs; + }); + + ws.notifier.notify(); +} + +async function refreshReveal( + ws: InternalWalletState, + refreshSessionId: string, +): Promise<void> { + const refreshSession = await oneShotGet( + ws.db, + Stores.refresh, + refreshSessionId, + ); + 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 oneShotGet( + ws.db, + 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("refresh/reveal", refreshSession.exchangeBaseUrl); + logger.trace("reveal request:", req); + + let resp; + try { + resp = await ws.http.postJson(reqUrl.href, req); + } catch (e) { + console.error("got error during /refresh/reveal request"); + console.error(e); + return; + } + + logger.trace("session:", refreshSession); + logger.trace("reveal response:", resp); + + if (resp.status !== 200) { + console.error("error: /refresh/reveal returned status " + resp.status); + return; + } + + const respJson = resp.responseJson; + + if (!respJson.ev_sigs || !Array.isArray(respJson.ev_sigs)) { + console.error("/refresh/reveal did not contain ev_sigs"); + return; + } + + const exchange = oneShotGet( + ws.db, + Stores.exchanges, + refreshSession.exchangeBaseUrl, + ); + if (!exchange) { + console.error(`exchange ${refreshSession.exchangeBaseUrl} not found`); + return; + } + + const coins: CoinRecord[] = []; + + for (let i = 0; i < respJson.ev_sigs.length; i++) { + const denom = await oneShotGet(ws.db, Stores.denominations, [ + refreshSession.exchangeBaseUrl, + refreshSession.newDenoms[i], + ]); + if (!denom) { + console.error("denom not found"); + continue; + } + const pc = + refreshSession.planchetsForGammas[refreshSession.norevealIndex!][i]; + const denomSig = await ws.cryptoApi.rsaUnblind( + respJson.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, + reservePub: undefined, + status: CoinStatus.Fresh, + coinIndex: -1, + withdrawSessionId: "", + }; + + coins.push(coin); + } + + refreshSession.finished = true; + + await runWithWriteTransaction( + ws.db, + [Stores.coins, Stores.refresh], + async tx => { + const rs = await tx.get(Stores.refresh, refreshSessionId); + if (!rs) { + return; + } + if (rs.finished) { + return; + } + for (let coin of coins) { + await tx.put(Stores.coins, coin); + } + await tx.put(Stores.refresh, refreshSession); + }, + ); + ws.notifier.notify(); +} + +export async function processRefreshSession( + ws: InternalWalletState, + refreshSessionId: string, +) { + const refreshSession = await oneShotGet( + ws.db, + Stores.refresh, + refreshSessionId, + ); + if (!refreshSession) { + return; + } + if (refreshSession.finished) { + return; + } + if (typeof refreshSession.norevealIndex !== "number") { + await refreshMelt(ws, refreshSession.refreshSessionId); + } + await refreshReveal(ws, refreshSession.refreshSessionId); + logger.trace("refresh finished"); +} + +export async function refresh( + ws: InternalWalletState, + oldCoinPub: string, + force: boolean = false, +): Promise<void> { + const coin = await oneShotGet(ws.db, Stores.coins, oldCoinPub); + if (!coin) { + console.warn("can't refresh, coin not in database"); + return; + } + switch (coin.status) { + case CoinStatus.Dirty: + break; + case CoinStatus.Dormant: + return; + case CoinStatus.Fresh: + if (!force) { + return; + } + break; + } + + const exchange = await updateExchangeFromUrl(ws, coin.exchangeBaseUrl); + if (!exchange) { + throw Error("db inconsistent: exchange of coin not found"); + } + + const oldDenom = await oneShotGet(ws.db, Stores.denominations, [ + exchange.baseUrl, + coin.denomPub, + ]); + + if (!oldDenom) { + throw Error("db inconsistent: denomination for coin not found"); + } + + const availableDenoms: DenominationRecord[] = await oneShotIterIndex( + ws.db, + Stores.denominations.exchangeBaseUrlIndex, + exchange.baseUrl, + ).toArray(); + + const availableAmount = Amounts.sub(coin.currentAmount, oldDenom.feeRefresh) + .amount; + + const newCoinDenoms = getWithdrawDenomList(availableAmount, availableDenoms); + + if (newCoinDenoms.length === 0) { + logger.trace( + `not refreshing, available amount ${amountToPretty( + availableAmount, + )} too small`, + ); + await oneShotMutate(ws.db, Stores.coins, oldCoinPub, x => { + if (x.status != coin.status) { + // Concurrent modification? + return; + } + x.status = CoinStatus.Dormant; + return x; + }); + ws.notifier.notify(); + return; + } + + const refreshSession: RefreshSessionRecord = await ws.cryptoApi.createRefreshSession( + exchange.baseUrl, + 3, + coin, + newCoinDenoms, + oldDenom.feeRefresh, + ); + + function mutateCoin(c: CoinRecord): CoinRecord { + const r = Amounts.sub(c.currentAmount, refreshSession.valueWithFee); + if (r.saturated) { + // Something else must have written the coin value + throw TransactionAbort; + } + c.currentAmount = r.amount; + c.status = CoinStatus.Dormant; + return c; + } + + // Store refresh session and subtract refreshed amount from + // coin in the same transaction. + await runWithWriteTransaction( + ws.db, + [Stores.refresh, Stores.coins], + async tx => { + await tx.put(Stores.refresh, refreshSession); + await tx.mutate(Stores.coins, coin.coinPub, mutateCoin); + }, + ); + logger.info(`created refresh session ${refreshSession.refreshSessionId}`); + ws.notifier.notify(); + + await processRefreshSession(ws, refreshSession.refreshSessionId); +} |