From 2c52046f0bf358a5e07c53394b3b72d091356cce Mon Sep 17 00:00:00 2001 From: Florian Dold Date: Thu, 12 Mar 2020 00:44:28 +0530 Subject: full recoup, untested/unfinished first attempt --- src/operations/recoup.ts | 372 +++++++++++++++++++++++++++++++++++++++++------ 1 file changed, 327 insertions(+), 45 deletions(-) (limited to 'src/operations/recoup.ts') diff --git a/src/operations/recoup.ts b/src/operations/recoup.ts index 2b646a4d8..842a67b87 100644 --- a/src/operations/recoup.ts +++ b/src/operations/recoup.ts @@ -1,6 +1,6 @@ /* This file is part of GNU Taler - (C) 2019 GNUnet e.V. + (C) 2019-2010 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 @@ -14,76 +14,358 @@ GNU Taler; see the file COPYING. If not, see */ +/** + * Implementation of the recoup operation, which allows to recover the + * value of coins held in a revoked denomination. + * + * @author Florian Dold + */ + /** * Imports. */ -import { - Database -} from "../util/query"; import { InternalWalletState } from "./state"; -import { Stores, TipRecord, CoinStatus } from "../types/dbTypes"; +import { + Stores, + CoinStatus, + CoinSourceType, + CoinRecord, + WithdrawCoinSource, + RefreshCoinSource, + ReserveRecordStatus, + RecoupGroupRecord, + initRetryInfo, + updateRetryInfoTimeout, +} from "../types/dbTypes"; -import { Logger } from "../util/logging"; -import { RecoupConfirmation, codecForRecoupConfirmation } from "../types/talerTypes"; -import { updateExchangeFromUrl } from "./exchanges"; +import { codecForRecoupConfirmation } from "../types/talerTypes"; import { NotificationType } from "../types/notifications"; +import { processReserve } from "./reserves"; -const logger = new Logger("payback.ts"); +import * as Amounts from "../util/amounts"; +import { createRefreshGroup, processRefreshGroup } from "./refresh"; +import { RefreshReason, OperationError } from "../types/walletTypes"; +import { TransactionHandle } from "../util/query"; +import { encodeCrock, getRandomBytes } from "../crypto/talerCrypto"; +import { getTimestampNow } from "../util/time"; +import { guardOperationException } from "./errors"; -export async function recoup( +async function incrementRecoupRetry( ws: InternalWalletState, - coinPub: string, + recoupGroupId: string, + err: OperationError | undefined, ): Promise { - let coin = await ws.db.get(Stores.coins, coinPub); - if (!coin) { - throw Error(`Coin ${coinPub} not found, can't request payback`); + await ws.db.runWithWriteTransaction([Stores.recoupGroups], async tx => { + const r = await tx.get(Stores.recoupGroups, recoupGroupId); + if (!r) { + return; + } + if (!r.retryInfo) { + return; + } + r.retryInfo.retryCounter++; + updateRetryInfoTimeout(r.retryInfo); + r.lastError = err; + await tx.put(Stores.recoupGroups, r); + }); + ws.notify({ type: NotificationType.RecoupOperationError }); +} + +async function putGroupAsFinished( + tx: TransactionHandle, + recoupGroup: RecoupGroupRecord, + coinIdx: number, +): Promise { + recoupGroup.recoupFinishedPerCoin[coinIdx] = true; + let allFinished = true; + for (const b of recoupGroup.recoupFinishedPerCoin) { + if (!b) { + allFinished = false; + } } - const reservePub = coin.reservePub; - if (!reservePub) { - throw Error(`Can't request payback for a refreshed coin`); + if (allFinished) { + recoupGroup.timestampFinished = getTimestampNow(); + recoupGroup.retryInfo = initRetryInfo(false); + recoupGroup.lastError = undefined; } + await tx.put(Stores.recoupGroups, recoupGroup); +} + +async function recoupTipCoin( + ws: InternalWalletState, + recoupGroupId: string, + coinIdx: number, + coin: CoinRecord, +): Promise { + // We can't really recoup a coin we got via tipping. + // Thus we just put the coin to sleep. + // FIXME: somehow report this to the user + await ws.db.runWithWriteTransaction([Stores.recoupGroups], async tx => { + const recoupGroup = await tx.get(Stores.recoupGroups, recoupGroupId); + if (!recoupGroup) { + return; + } + if (recoupGroup.recoupFinishedPerCoin[coinIdx]) { + return; + } + await putGroupAsFinished(tx, recoupGroup, coinIdx); + }); +} + +async function recoupWithdrawCoin( + ws: InternalWalletState, + recoupGroupId: string, + coinIdx: number, + coin: CoinRecord, + cs: WithdrawCoinSource, +): Promise { + const reservePub = cs.reservePub; const reserve = await ws.db.get(Stores.reserves, reservePub); if (!reserve) { - throw Error(`Reserve of coin ${coinPub} not found`); + // FIXME: We should at least emit some pending operation / warning for this? + return; + } + + ws.notify({ + type: NotificationType.RecoupStarted, + }); + + const recoupRequest = await ws.cryptoApi.createRecoupRequest(coin); + const reqUrl = new URL(`/coins/${coin.coinPub}/recoup`, coin.exchangeBaseUrl); + const resp = await ws.http.postJson(reqUrl.href, recoupRequest); + if (resp.status !== 200) { + throw Error("recoup request failed"); } - switch (coin.status) { - case CoinStatus.Dormant: - throw Error(`Can't do payback for coin ${coinPub} since it's dormant`); + const recoupConfirmation = codecForRecoupConfirmation().decode( + await resp.json(), + ); + + if (recoupConfirmation.reserve_pub !== reservePub) { + throw Error(`Coin's reserve doesn't match reserve on recoup`); } - coin.status = CoinStatus.Dormant; - // Even if we didn't get the payback yet, we suspend withdrawal, since - // technically we might update reserve status before we get the response - // from the reserve for the payback request. - reserve.hasPayback = true; + + // FIXME: verify that our expectations about the amount match + await ws.db.runWithWriteTransaction( - [Stores.coins, Stores.reserves], + [Stores.coins, Stores.reserves, Stores.recoupGroups], async tx => { - await tx.put(Stores.coins, coin!!); - await tx.put(Stores.reserves, reserve); + const recoupGroup = await tx.get(Stores.recoupGroups, recoupGroupId); + if (!recoupGroup) { + return; + } + if (recoupGroup.recoupFinishedPerCoin[coinIdx]) { + return; + } + const updatedCoin = await tx.get(Stores.coins, coin.coinPub); + if (!updatedCoin) { + return; + } + const updatedReserve = await tx.get(Stores.reserves, reserve.reservePub); + if (!updatedReserve) { + return; + } + updatedCoin.status = CoinStatus.Dormant; + const currency = updatedCoin.currentAmount.currency; + updatedCoin.currentAmount = Amounts.getZero(currency); + updatedReserve.reserveStatus = ReserveRecordStatus.QUERYING_STATUS; + await tx.put(Stores.coins, updatedCoin); + await tx.put(Stores.reserves, updatedReserve); + await putGroupAsFinished(tx, recoupGroup, coinIdx); }, ); + ws.notify({ - type: NotificationType.PaybackStarted, + type: NotificationType.RecoupFinished, }); - const paybackRequest = await ws.cryptoApi.createPaybackRequest(coin); - const reqUrl = new URL("payback", coin.exchangeBaseUrl); - const resp = await ws.http.postJson(reqUrl.href, paybackRequest); + processReserve(ws, reserve.reservePub).catch(e => { + console.log("processing reserve after recoup failed:", e); + }); +} + +async function recoupRefreshCoin( + ws: InternalWalletState, + recoupGroupId: string, + coinIdx: number, + coin: CoinRecord, + cs: RefreshCoinSource, +): Promise { + ws.notify({ + type: NotificationType.RecoupStarted, + }); + + const recoupRequest = await ws.cryptoApi.createRecoupRequest(coin); + const reqUrl = new URL(`/coins/${coin.coinPub}/recoup`, coin.exchangeBaseUrl); + const resp = await ws.http.postJson(reqUrl.href, recoupRequest); if (resp.status !== 200) { - throw Error(); + throw Error("recoup request failed"); } - const paybackConfirmation = codecForRecoupConfirmation().decode(await resp.json()); - if (paybackConfirmation.reserve_pub !== coin.reservePub) { - throw Error(`Coin's reserve doesn't match reserve on payback`); + const recoupConfirmation = codecForRecoupConfirmation().decode( + await resp.json(), + ); + + if (recoupConfirmation.old_coin_pub != cs.oldCoinPub) { + throw Error(`Coin's oldCoinPub doesn't match reserve on recoup`); } - coin = await ws.db.get(Stores.coins, coinPub); - if (!coin) { - throw Error(`Coin ${coinPub} not found, can't confirm payback`); + + const refreshGroupId = await ws.db.runWithWriteTransaction( + [Stores.coins, Stores.reserves], + async tx => { + const recoupGroup = await tx.get(Stores.recoupGroups, recoupGroupId); + if (!recoupGroup) { + return; + } + if (recoupGroup.recoupFinishedPerCoin[coinIdx]) { + return; + } + const oldCoin = await tx.get(Stores.coins, cs.oldCoinPub); + const updatedCoin = await tx.get(Stores.coins, coin.coinPub); + if (!updatedCoin) { + return; + } + if (!oldCoin) { + return; + } + updatedCoin.status = CoinStatus.Dormant; + oldCoin.currentAmount = Amounts.add( + oldCoin.currentAmount, + updatedCoin.currentAmount, + ).amount; + await tx.put(Stores.coins, updatedCoin); + await putGroupAsFinished(tx, recoupGroup, coinIdx); + return await createRefreshGroup( + tx, + [{ coinPub: oldCoin.coinPub }], + RefreshReason.Recoup, + ); + }, + ); + + if (refreshGroupId) { + processRefreshGroup(ws, refreshGroupId.refreshGroupId).then(e => { + console.error("error while refreshing after recoup", e); + }); } - coin.status = CoinStatus.Dormant; - await ws.db.put(Stores.coins, coin); - ws.notify({ - type: NotificationType.PaybackFinished, +} + +async function resetRecoupGroupRetry( + ws: InternalWalletState, + recoupGroupId: string, +) { + await ws.db.mutate(Stores.recoupGroups, recoupGroupId, x => { + if (x.retryInfo.active) { + x.retryInfo = initRetryInfo(); + } + return x; }); - await updateExchangeFromUrl(ws, coin.exchangeBaseUrl, true); +} + +export async function processRecoupGroup( + ws: InternalWalletState, + recoupGroupId: string, + forceNow: boolean = false, +): Promise { + await ws.memoProcessRecoup.memo(recoupGroupId, async () => { + const onOpErr = (e: OperationError) => + incrementRecoupRetry(ws, recoupGroupId, e); + return await guardOperationException( + async () => await processRecoupGroupImpl(ws, recoupGroupId, forceNow), + onOpErr, + ); + }); +} + +async function processRecoupGroupImpl( + ws: InternalWalletState, + recoupGroupId: string, + forceNow: boolean = false, +): Promise { + if (forceNow) { + await resetRecoupGroupRetry(ws, recoupGroupId); + } + const recoupGroup = await ws.db.get(Stores.recoupGroups, recoupGroupId); + if (!recoupGroup) { + return; + } + if (recoupGroup.timestampFinished) { + return; + } + const ps = recoupGroup.coinPubs.map((x, i) => + processRecoup(ws, recoupGroupId, i), + ); + await Promise.all(ps); +} + +export async function createRecoupGroup( + ws: InternalWalletState, + tx: TransactionHandle, + coinPubs: string[], +): Promise { + const recoupGroupId = encodeCrock(getRandomBytes(32)); + + const recoupGroup: RecoupGroupRecord = { + recoupGroupId, + coinPubs: coinPubs, + lastError: undefined, + timestampFinished: undefined, + timestampStarted: getTimestampNow(), + retryInfo: initRetryInfo(), + recoupFinishedPerCoin: coinPubs.map(() => false), + }; + + for (let coinIdx = 0; coinIdx < coinPubs.length; coinIdx++) { + const coinPub = coinPubs[coinIdx]; + const coin = await tx.get(Stores.coins, coinPub); + if (!coin) { + recoupGroup.recoupFinishedPerCoin[coinIdx] = true; + continue; + } + if (Amounts.isZero(coin.currentAmount)) { + recoupGroup.recoupFinishedPerCoin[coinIdx] = true; + continue; + } + coin.currentAmount = Amounts.getZero(coin.currentAmount.currency); + await tx.put(Stores.coins, coin); + } + + await tx.put(Stores.recoupGroups, recoupGroup); + + return recoupGroupId; +} + +async function processRecoup( + ws: InternalWalletState, + recoupGroupId: string, + coinIdx: number, +): Promise { + const recoupGroup = await ws.db.get(Stores.recoupGroups, recoupGroupId); + if (!recoupGroup) { + return; + } + if (recoupGroup.timestampFinished) { + return; + } + if (recoupGroup.recoupFinishedPerCoin[coinIdx]) { + return; + } + + const coinPub = recoupGroup.coinPubs[coinIdx]; + + let coin = await ws.db.get(Stores.coins, coinPub); + if (!coin) { + throw Error(`Coin ${coinPub} not found, can't request payback`); + } + + const cs = coin.coinSource; + + switch (cs.type) { + case CoinSourceType.Tip: + return recoupTipCoin(ws, recoupGroupId, coinIdx, coin); + case CoinSourceType.Refresh: + return recoupRefreshCoin(ws, recoupGroupId, coinIdx, coin, cs); + case CoinSourceType.Withdraw: + return recoupWithdrawCoin(ws, recoupGroupId, coinIdx, coin, cs); + default: + throw Error("unknown coin source type"); + } } -- cgit v1.2.3