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/recoup.ts | 412 +++++++++++++++++++++ 1 file changed, 412 insertions(+) create mode 100644 packages/taler-wallet-core/src/operations/recoup.ts (limited to 'packages/taler-wallet-core/src/operations/recoup.ts') diff --git a/packages/taler-wallet-core/src/operations/recoup.ts b/packages/taler-wallet-core/src/operations/recoup.ts new file mode 100644 index 000000000..cc91ab0e9 --- /dev/null +++ b/packages/taler-wallet-core/src/operations/recoup.ts @@ -0,0 +1,412 @@ +/* + 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 + */ + +/** + * Implementation of the recoup operation, which allows to recover the + * value of coins held in a revoked denomination. + * + * @author Florian Dold + */ + +/** + * Imports. + */ +import { InternalWalletState } from "./state"; +import { + Stores, + CoinStatus, + CoinSourceType, + CoinRecord, + WithdrawCoinSource, + RefreshCoinSource, + ReserveRecordStatus, + RecoupGroupRecord, + initRetryInfo, + updateRetryInfoTimeout, +} from "../types/dbTypes"; + +import { codecForRecoupConfirmation } from "../types/talerTypes"; +import { NotificationType } from "../types/notifications"; +import { forceQueryReserve } from "./reserves"; + +import { Amounts } from "../util/amounts"; +import { createRefreshGroup, processRefreshGroup } from "./refresh"; +import { RefreshReason, OperationErrorDetails } from "../types/walletTypes"; +import { TransactionHandle } from "../util/query"; +import { encodeCrock, getRandomBytes } from "../crypto/talerCrypto"; +import { getTimestampNow } from "../util/time"; +import { guardOperationException } from "./errors"; +import { readSuccessResponseJsonOrThrow } from "../util/http"; +import { URL } from "../util/url"; + +async function incrementRecoupRetry( + ws: InternalWalletState, + recoupGroupId: string, + err: OperationErrorDetails | undefined, +): Promise { + 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); + }); + if (err) { + ws.notify({ type: NotificationType.RecoupOperationError, error: err }); + } +} + +async function putGroupAsFinished( + ws: InternalWalletState, + tx: TransactionHandle, + recoupGroup: RecoupGroupRecord, + coinIdx: number, +): Promise { + if (recoupGroup.timestampFinished) { + return; + } + recoupGroup.recoupFinishedPerCoin[coinIdx] = true; + let allFinished = true; + for (const b of recoupGroup.recoupFinishedPerCoin) { + if (!b) { + allFinished = false; + } + } + if (allFinished) { + recoupGroup.timestampFinished = getTimestampNow(); + recoupGroup.retryInfo = initRetryInfo(false); + recoupGroup.lastError = undefined; + if (recoupGroup.scheduleRefreshCoins.length > 0) { + const refreshGroupId = await createRefreshGroup( + ws, + tx, + recoupGroup.scheduleRefreshCoins.map((x) => ({ coinPub: x })), + RefreshReason.Recoup, + ); + processRefreshGroup(ws, refreshGroupId.refreshGroupId).then((e) => { + console.error("error while refreshing after recoup", e); + }); + } + } + 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(ws, 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) { + // 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); + const recoupConfirmation = await readSuccessResponseJsonOrThrow( + resp, + codecForRecoupConfirmation(), + ); + + if (recoupConfirmation.reserve_pub !== reservePub) { + throw Error(`Coin's reserve doesn't match reserve on recoup`); + } + + const exchange = await ws.db.get(Stores.exchanges, coin.exchangeBaseUrl); + if (!exchange) { + // FIXME: report inconsistency? + return; + } + const exchangeDetails = exchange.details; + if (!exchangeDetails) { + // FIXME: report inconsistency? + return; + } + + // FIXME: verify that our expectations about the amount match + + await ws.db.runWithWriteTransaction( + [Stores.coins, Stores.reserves, Stores.recoupGroups], + async (tx) => { + 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(ws, tx, recoupGroup, coinIdx); + }, + ); + + ws.notify({ + type: NotificationType.RecoupFinished, + }); + + forceQueryReserve(ws, reserve.reservePub).catch((e) => { + console.log("re-querying 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); + console.log("making recoup request"); + + const resp = await ws.http.postJson(reqUrl.href, recoupRequest); + const recoupConfirmation = await readSuccessResponseJsonOrThrow( + resp, + codecForRecoupConfirmation(), + ); + + if (recoupConfirmation.old_coin_pub != cs.oldCoinPub) { + throw Error(`Coin's oldCoinPub doesn't match reserve on recoup`); + } + + const exchange = await ws.db.get(Stores.exchanges, coin.exchangeBaseUrl); + if (!exchange) { + // FIXME: report inconsistency? + return; + } + const exchangeDetails = exchange.details; + if (!exchangeDetails) { + // FIXME: report inconsistency? + return; + } + + await ws.db.runWithWriteTransaction( + [Stores.coins, Stores.reserves, Stores.recoupGroups, Stores.refreshGroups], + 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 revokedCoin = await tx.get(Stores.coins, coin.coinPub); + if (!revokedCoin) { + return; + } + if (!oldCoin) { + return; + } + revokedCoin.status = CoinStatus.Dormant; + oldCoin.currentAmount = Amounts.add( + oldCoin.currentAmount, + recoupGroup.oldAmountPerCoin[coinIdx], + ).amount; + console.log( + "recoup: setting old coin amount to", + Amounts.stringify(oldCoin.currentAmount), + ); + recoupGroup.scheduleRefreshCoins.push(oldCoin.coinPub); + await tx.put(Stores.coins, revokedCoin); + await tx.put(Stores.coins, oldCoin); + await putGroupAsFinished(ws, tx, recoupGroup, coinIdx); + }, + ); +} + +async function resetRecoupGroupRetry( + ws: InternalWalletState, + recoupGroupId: string, +): Promise { + await ws.db.mutate(Stores.recoupGroups, recoupGroupId, (x) => { + if (x.retryInfo.active) { + x.retryInfo = initRetryInfo(); + } + return x; + }); +} + +export async function processRecoupGroup( + ws: InternalWalletState, + recoupGroupId: string, + forceNow = false, +): Promise { + await ws.memoProcessRecoup.memo(recoupGroupId, async () => { + const onOpErr = (e: OperationErrorDetails): Promise => + incrementRecoupRetry(ws, recoupGroupId, e); + return await guardOperationException( + async () => await processRecoupGroupImpl(ws, recoupGroupId, forceNow), + onOpErr, + ); + }); +} + +async function processRecoupGroupImpl( + ws: InternalWalletState, + recoupGroupId: string, + forceNow = false, +): Promise { + if (forceNow) { + await resetRecoupGroupRetry(ws, recoupGroupId); + } + console.log("in processRecoupGroupImpl"); + const recoupGroup = await ws.db.get(Stores.recoupGroups, recoupGroupId); + if (!recoupGroup) { + return; + } + console.log(recoupGroup); + if (recoupGroup.timestampFinished) { + console.log("recoup group finished"); + 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), + // Will be populated later + oldAmountPerCoin: [], + scheduleRefreshCoins: [], + }; + + for (let coinIdx = 0; coinIdx < coinPubs.length; coinIdx++) { + const coinPub = coinPubs[coinIdx]; + const coin = await tx.get(Stores.coins, coinPub); + if (!coin) { + await putGroupAsFinished(ws, tx, recoupGroup, coinIdx); + continue; + } + if (Amounts.isZero(coin.currentAmount)) { + await putGroupAsFinished(ws, tx, recoupGroup, coinIdx); + continue; + } + recoupGroup.oldAmountPerCoin[coinIdx] = coin.currentAmount; + 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]; + + const 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