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/exchanges.ts | 43 +++++ src/operations/history.ts | 11 ++ src/operations/pending.ts | 28 ++++ src/operations/recoup.ts | 372 ++++++++++++++++++++++++++++++++++++++------ src/operations/refresh.ts | 8 +- src/operations/reserves.ts | 1 - src/operations/state.ts | 1 + src/operations/withdraw.ts | 26 ++-- 8 files changed, 431 insertions(+), 59 deletions(-) (limited to 'src/operations') diff --git a/src/operations/exchanges.ts b/src/operations/exchanges.ts index cf6b06868..ed13a1e5b 100644 --- a/src/operations/exchanges.ts +++ b/src/operations/exchanges.ts @@ -31,6 +31,7 @@ import { WireFee, ExchangeUpdateReason, ExchangeUpdatedEventRecord, + CoinStatus, } from "../types/dbTypes"; import { canonicalizeBaseUrl } from "../util/helpers"; import * as Amounts from "../util/amounts"; @@ -45,6 +46,7 @@ import { } from "./versions"; import { getTimestampNow } from "../util/time"; import { compare } from "../util/libtoolVersion"; +import { createRecoupGroup, processRecoupGroup } from "./recoup"; async function denominationRecordFromKeys( ws: InternalWalletState, @@ -61,6 +63,7 @@ async function denominationRecordFromKeys( feeRefund: Amounts.parseOrThrow(denomIn.fee_refund), feeWithdraw: Amounts.parseOrThrow(denomIn.fee_withdraw), isOffered: true, + isRevoked: false, masterSig: denomIn.master_sig, stampExpireDeposit: denomIn.stamp_expire_deposit, stampExpireLegal: denomIn.stamp_expire_legal, @@ -189,6 +192,8 @@ async function updateExchangeWithKeys( ), ); + let recoupGroupId: string | undefined = undefined; + await ws.db.runWithWriteTransaction( [Stores.exchanges, Stores.denominations], async tx => { @@ -222,8 +227,46 @@ async function updateExchangeWithKeys( await tx.put(Stores.denominations, newDenom); } } + + // Handle recoup + const recoupDenomList = exchangeKeysJson.recoup ?? []; + const newlyRevokedCoinPubs: string[] = []; + for (const recoupDenomPubHash of recoupDenomList) { + const oldDenom = await tx.getIndexed( + Stores.denominations.denomPubHashIndex, + recoupDenomPubHash, + ); + if (!oldDenom) { + // We never even knew about the revoked denomination, all good. + continue; + } + if (oldDenom.isRevoked) { + // We already marked the denomination as revoked, + // this implies we revoked all coins + continue; + } + oldDenom.isRevoked = true; + await tx.put(Stores.denominations, oldDenom); + const affectedCoins = await tx + .iterIndexed(Stores.coins.denomPubIndex) + .toArray(); + for (const ac of affectedCoins) { + newlyRevokedCoinPubs.push(ac.coinPub); + } + } + if (newlyRevokedCoinPubs.length != 0) { + await createRecoupGroup(ws, tx, newlyRevokedCoinPubs); + } }, ); + + if (recoupGroupId) { + // Asynchronously start recoup. This doesn't need to finish + // for the exchange update to be considered finished. + processRecoupGroup(ws, recoupGroupId).catch((e) => { + console.log("error while recouping coins:", e); + }); + } } async function updateExchangeFinalize( diff --git a/src/operations/history.ts b/src/operations/history.ts index 2fb7854d2..2cf215a5a 100644 --- a/src/operations/history.ts +++ b/src/operations/history.ts @@ -181,6 +181,7 @@ export async function getHistory( Stores.payEvents, Stores.refundEvents, Stores.reserveUpdatedEvents, + Stores.recoupGroups, ], async tx => { tx.iter(Stores.exchanges).forEach(exchange => { @@ -485,6 +486,16 @@ export async function getHistory( amountRefundedInvalid: Amounts.toString(amountRefundedInvalid), }); }); + + tx.iter(Stores.recoupGroups).forEach(rg => { + if (rg.timestampFinished) { + history.push({ + type: HistoryEventType.FundsRecouped, + timestamp: rg.timestampFinished, + eventId: makeEventId(HistoryEventType.FundsRecouped, rg.recoupGroupId), + }); + } + }); }, ); diff --git a/src/operations/pending.ts b/src/operations/pending.ts index fce9a3bfb..08ec3fc9e 100644 --- a/src/operations/pending.ts +++ b/src/operations/pending.ts @@ -405,6 +405,32 @@ async function gatherPurchasePending( }); } +async function gatherRecoupPending( + tx: TransactionHandle, + now: Timestamp, + resp: PendingOperationsResponse, + onlyDue: boolean = false, +): Promise { + await tx.iter(Stores.recoupGroups).forEach(rg => { + if (rg.timestampFinished) { + return; + } + resp.nextRetryDelay = updateRetryDelay( + resp.nextRetryDelay, + now, + rg.retryInfo.nextRetry, + ); + if (onlyDue && rg.retryInfo.nextRetry.t_ms > now.t_ms) { + return; + } + resp.pendingOperations.push({ + type: PendingOperationType.Recoup, + givesLifeness: true, + recoupGroupId: rg.recoupGroupId, + }); + }); +} + export async function getPendingOperations( ws: InternalWalletState, { onlyDue = false } = {}, @@ -420,6 +446,7 @@ export async function getPendingOperations( Stores.proposals, Stores.tips, Stores.purchases, + Stores.recoupGroups, ], async tx => { const walletBalance = await getBalancesInsideTransaction(ws, tx); @@ -436,6 +463,7 @@ export async function getPendingOperations( await gatherProposalPending(tx, now, resp, onlyDue); await gatherTipPending(tx, now, resp, onlyDue); await gatherPurchasePending(tx, now, resp, onlyDue); + await gatherRecoupPending(tx, now, resp, onlyDue); return resp; }, ); 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"); + } } diff --git a/src/operations/refresh.ts b/src/operations/refresh.ts index 6dd16d61a..092d9f154 100644 --- a/src/operations/refresh.ts +++ b/src/operations/refresh.ts @@ -26,6 +26,7 @@ import { initRetryInfo, updateRetryInfoTimeout, RefreshGroupRecord, + CoinSourceType, } from "../types/dbTypes"; import { amountToPretty } from "../util/helpers"; import { Database, TransactionHandle } from "../util/query"; @@ -407,10 +408,11 @@ async function refreshReveal( denomPubHash: denom.denomPubHash, denomSig, exchangeBaseUrl: refreshSession.exchangeBaseUrl, - reservePub: undefined, status: CoinStatus.Fresh, - coinIndex: -1, - withdrawSessionId: "", + coinSource: { + type: CoinSourceType.Refresh, + oldCoinPub: refreshSession.meltCoinPub, + } }; coins.push(coin); diff --git a/src/operations/reserves.ts b/src/operations/reserves.ts index 1f9cc3053..c909555fe 100644 --- a/src/operations/reserves.ts +++ b/src/operations/reserves.ts @@ -103,7 +103,6 @@ export async function createReserve( amountWithdrawCompleted: Amounts.getZero(currency), amountWithdrawRemaining: Amounts.getZero(currency), exchangeBaseUrl: canonExchange, - hasPayback: false, amountInitiallyRequested: req.amount, reservePriv: keypair.priv, reservePub: keypair.pub, diff --git a/src/operations/state.ts b/src/operations/state.ts index 3e4936c98..ae32db2b3 100644 --- a/src/operations/state.ts +++ b/src/operations/state.ts @@ -39,6 +39,7 @@ export class InternalWalletState { > = new AsyncOpMemoSingle(); memoGetBalance: AsyncOpMemoSingle = new AsyncOpMemoSingle(); memoProcessRefresh: AsyncOpMemoMap = new AsyncOpMemoMap(); + memoProcessRecoup: AsyncOpMemoMap = new AsyncOpMemoMap(); cryptoApi: CryptoApi; listeners: NotificationListener[] = []; diff --git a/src/operations/withdraw.ts b/src/operations/withdraw.ts index 0c58f5f2f..478aa4ceb 100644 --- a/src/operations/withdraw.ts +++ b/src/operations/withdraw.ts @@ -24,6 +24,7 @@ import { PlanchetRecord, initRetryInfo, updateRetryInfoTimeout, + CoinSourceType, } from "../types/dbTypes"; import * as Amounts from "../util/amounts"; import { @@ -48,6 +49,7 @@ import { timestampCmp, timestampSubtractDuraction, } from "../util/time"; +import { Store } from "../util/query"; const logger = new Logger("withdraw.ts"); @@ -229,10 +231,13 @@ async function processPlanchet( denomPubHash: planchet.denomPubHash, denomSig, exchangeBaseUrl: withdrawalSession.exchangeBaseUrl, - reservePub: planchet.reservePub, status: CoinStatus.Fresh, - coinIndex: coinIdx, - withdrawSessionId: withdrawalSessionId, + coinSource: { + type: CoinSourceType.Withdraw, + coinIndex: coinIdx, + reservePub: planchet.reservePub, + withdrawSessionId: withdrawalSessionId + } }; let withdrawSessionFinished = false; @@ -449,14 +454,15 @@ async function processWithdrawCoin( return; } - const coin = await ws.db.getIndexed(Stores.coins.byWithdrawalWithIdx, [ - withdrawalSessionId, - coinIndex, - ]); + const planchet = withdrawalSession.planchets[coinIndex]; - if (coin) { - console.log("coin already exists"); - return; + if (planchet) { + const coin = await ws.db.get(Stores.coins, planchet.coinPub); + + if (coin) { + console.log("coin already exists"); + return; + } } if (!withdrawalSession.planchets[coinIndex]) { -- cgit v1.2.3