summaryrefslogtreecommitdiff
path: root/src/operations/refresh.ts
diff options
context:
space:
mode:
Diffstat (limited to 'src/operations/refresh.ts')
-rw-r--r--src/operations/refresh.ts479
1 files changed, 479 insertions, 0 deletions
diff --git a/src/operations/refresh.ts b/src/operations/refresh.ts
new file mode 100644
index 000000000..4e4449d96
--- /dev/null
+++ b/src/operations/refresh.ts
@@ -0,0 +1,479 @@
+/*
+ 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,
+ initRetryInfo,
+ updateRetryInfoTimeout,
+} from "../types/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";
+import {
+ getTimestampNow,
+ OperationError,
+} from "../types/walletTypes";
+import { guardOperationException } from "./errors";
+import { NotificationType } from "../types/notifications";
+
+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);
+ if (resp.status !== 200) {
+ throw Error(`unexpected status code ${resp.status} for refresh/melt`);
+ }
+
+ const respJson = await resp.json();
+
+ logger.trace("melt response:", respJson);
+
+ if (resp.status !== 200) {
+ console.error(respJson);
+ throw Error("refresh failed");
+ }
+
+ 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.finishedTimestamp) {
+ return;
+ }
+ rs.norevealIndex = norevealIndex;
+ return rs;
+ });
+
+ ws.notify({
+ type: NotificationType.RefreshMelted,
+ });
+}
+
+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 = await resp.json();
+
+ if (!respJson.ev_sigs || !Array.isArray(respJson.ev_sigs)) {
+ console.error("/refresh/reveal did not contain ev_sigs");
+ 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);
+ }
+
+ await runWithWriteTransaction(
+ ws.db,
+ [Stores.coins, Stores.refresh],
+ async tx => {
+ const rs = await tx.get(Stores.refresh, refreshSessionId);
+ if (!rs) {
+ console.log("no refresh session found");
+ return;
+ }
+ if (rs.finishedTimestamp) {
+ console.log("refresh session already finished");
+ return;
+ }
+ rs.finishedTimestamp = getTimestampNow();
+ rs.retryInfo = initRetryInfo(false);
+ for (let coin of coins) {
+ await tx.put(Stores.coins, coin);
+ }
+ await tx.put(Stores.refresh, rs);
+ },
+ );
+ console.log("refresh finished (end of reveal)");
+ ws.notify({
+ type: NotificationType.RefreshRevealed,
+ });
+}
+
+async function incrementRefreshRetry(
+ ws: InternalWalletState,
+ refreshSessionId: string,
+ err: OperationError | undefined,
+): Promise<void> {
+ await runWithWriteTransaction(ws.db, [Stores.refresh], async tx => {
+ const r = await tx.get(Stores.refresh, refreshSessionId);
+ if (!r) {
+ return;
+ }
+ if (!r.retryInfo) {
+ return;
+ }
+ r.retryInfo.retryCounter++;
+ updateRetryInfoTimeout(r.retryInfo);
+ r.lastError = err;
+ await tx.put(Stores.refresh, r);
+ });
+ ws.notify({ type: NotificationType.RefreshOperationError });
+}
+
+export async function processRefreshSession(
+ ws: InternalWalletState,
+ refreshSessionId: string,
+ forceNow: boolean = false,
+) {
+ return ws.memoProcessRefresh.memo(refreshSessionId, async () => {
+ const onOpErr = (e: OperationError) =>
+ incrementRefreshRetry(ws, refreshSessionId, e);
+ return guardOperationException(
+ () => processRefreshSessionImpl(ws, refreshSessionId, forceNow),
+ onOpErr,
+ );
+ });
+}
+
+async function resetRefreshSessionRetry(
+ ws: InternalWalletState,
+ refreshSessionId: string,
+) {
+ await oneShotMutate(ws.db, Stores.refresh, refreshSessionId, (x) => {
+ if (x.retryInfo.active) {
+ x.retryInfo = initRetryInfo();
+ }
+ return x;
+ });
+}
+
+async function processRefreshSessionImpl(
+ ws: InternalWalletState,
+ refreshSessionId: string,
+ forceNow: boolean,
+) {
+ if (forceNow) {
+ await resetRefreshSessionRetry(ws, refreshSessionId);
+ }
+ const refreshSession = await oneShotGet(
+ ws.db,
+ Stores.refresh,
+ refreshSessionId,
+ );
+ if (!refreshSession) {
+ return;
+ }
+ if (refreshSession.finishedTimestamp) {
+ 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.notify({ type: NotificationType.RefreshRefused });
+ 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 runWithWriteTransaction(
+ ws.db,
+ [Stores.refresh, Stores.coins],
+ async tx => {
+ const c = await tx.get(Stores.coins, coin.coinPub);
+ if (!c) {
+ return;
+ }
+ if (c.status !== CoinStatus.Dirty) {
+ return;
+ }
+ const r = Amounts.sub(c.currentAmount, refreshSession.valueWithFee);
+ if (r.saturated) {
+ console.log("can't refresh coin, no amount left");
+ return;
+ }
+ c.currentAmount = r.amount;
+ c.status = CoinStatus.Dormant;
+ await tx.put(Stores.refresh, refreshSession);
+ await tx.put(Stores.coins, c);
+ },
+ );
+ logger.info(`created refresh session ${refreshSession.refreshSessionId}`);
+ ws.notify({ type: NotificationType.RefreshStarted });
+
+ await processRefreshSession(ws, refreshSession.refreshSessionId);
+}