From e951075d2ef52fa8e9e7489c62031777c3a7e66b Mon Sep 17 00:00:00 2001 From: Florian Dold Date: Mon, 19 Feb 2024 18:05:48 +0100 Subject: wallet-core: flatten directory structure --- packages/taler-wallet-core/src/recoup.ts | 535 +++++++++++++++++++++++++++++++ 1 file changed, 535 insertions(+) create mode 100644 packages/taler-wallet-core/src/recoup.ts (limited to 'packages/taler-wallet-core/src/recoup.ts') diff --git a/packages/taler-wallet-core/src/recoup.ts b/packages/taler-wallet-core/src/recoup.ts new file mode 100644 index 000000000..a2ffa4132 --- /dev/null +++ b/packages/taler-wallet-core/src/recoup.ts @@ -0,0 +1,535 @@ +/* + 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 { + Amounts, + CoinStatus, + Logger, + RefreshReason, + TalerPreciseTimestamp, + TransactionType, + URL, + codecForRecoupConfirmation, + codecForReserveStatus, + encodeCrock, + getRandomBytes, + j2s, +} from "@gnu-taler/taler-util"; +import { readSuccessResponseJsonOrThrow } from "@gnu-taler/taler-util/http"; +import { + CoinRecord, + CoinSourceType, + RecoupGroupRecord, + RecoupOperationStatus, + RefreshCoinSource, + WalletDbReadWriteTransaction, + WithdrawCoinSource, + WithdrawalGroupStatus, + WithdrawalRecordType, + timestampPreciseToDb, +} from "./db.js"; +import { InternalWalletState } from "./internal-wallet-state.js"; +import { PendingTaskType } from "./pending-types.js"; +import { checkDbInvariant } from "./util/invariants.js"; +import { + TaskRunResult, + TransactionContext, + constructTaskIdentifier, +} from "./common.js"; +import { createRefreshGroup } from "./refresh.js"; +import { constructTransactionIdentifier } from "./transactions.js"; +import { internalCreateWithdrawalGroup } from "./withdraw.js"; + +const logger = new Logger("operations/recoup.ts"); + +/** + * Store a recoup group record in the database after marking + * a coin in the group as finished. + */ +async function putGroupAsFinished( + ws: InternalWalletState, + tx: WalletDbReadWriteTransaction< + ["recoupGroups", "denominations", "refreshGroups", "coins"] + >, + recoupGroup: RecoupGroupRecord, + coinIdx: number, +): Promise { + logger.trace( + `setting coin ${coinIdx} of ${recoupGroup.coinPubs.length} as finished`, + ); + if (recoupGroup.timestampFinished) { + return; + } + recoupGroup.recoupFinishedPerCoin[coinIdx] = true; + await tx.recoupGroups.put(recoupGroup); +} + +async function recoupRewardCoin( + 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.runReadWriteTx( + ["recoupGroups", "denominations", "refreshGroups", "coins"], + async (tx) => { + const recoupGroup = await tx.recoupGroups.get(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 denomInfo = await ws.db.runReadOnlyTx(["denominations"], async (tx) => { + const denomInfo = await ws.getDenomInfo( + ws, + tx, + coin.exchangeBaseUrl, + coin.denomPubHash, + ); + return denomInfo; + }); + if (!denomInfo) { + // FIXME: We should at least emit some pending operation / warning for this? + return; + } + + const recoupRequest = await ws.cryptoApi.createRecoupRequest({ + blindingKey: coin.blindingKey, + coinPriv: coin.coinPriv, + coinPub: coin.coinPub, + denomPub: denomInfo.denomPub, + denomPubHash: coin.denomPubHash, + denomSig: coin.denomSig, + }); + const reqUrl = new URL(`/coins/${coin.coinPub}/recoup`, coin.exchangeBaseUrl); + logger.trace(`requesting recoup via ${reqUrl.href}`); + const resp = await ws.http.fetch(reqUrl.href, { + method: "POST", + body: recoupRequest, + }); + const recoupConfirmation = await readSuccessResponseJsonOrThrow( + resp, + codecForRecoupConfirmation(), + ); + + logger.trace(`got recoup confirmation ${j2s(recoupConfirmation)}`); + + if (recoupConfirmation.reserve_pub !== reservePub) { + throw Error(`Coin's reserve doesn't match reserve on recoup`); + } + + // FIXME: verify that our expectations about the amount match + + await ws.db.runReadWriteTx( + ["coins", "denominations", "recoupGroups", "refreshGroups"], + async (tx) => { + const recoupGroup = await tx.recoupGroups.get(recoupGroupId); + if (!recoupGroup) { + return; + } + if (recoupGroup.recoupFinishedPerCoin[coinIdx]) { + return; + } + const updatedCoin = await tx.coins.get(coin.coinPub); + if (!updatedCoin) { + return; + } + updatedCoin.status = CoinStatus.Dormant; + await tx.coins.put(updatedCoin); + await putGroupAsFinished(ws, tx, recoupGroup, coinIdx); + }, + ); +} + +async function recoupRefreshCoin( + ws: InternalWalletState, + recoupGroupId: string, + coinIdx: number, + coin: CoinRecord, + cs: RefreshCoinSource, +): Promise { + const d = await ws.db.runReadOnlyTx( + ["coins", "denominations"], + async (tx) => { + const denomInfo = await ws.getDenomInfo( + ws, + tx, + coin.exchangeBaseUrl, + coin.denomPubHash, + ); + if (!denomInfo) { + return; + } + return { denomInfo }; + }, + ); + if (!d) { + // FIXME: We should at least emit some pending operation / warning for this? + return; + } + + const recoupRequest = await ws.cryptoApi.createRecoupRefreshRequest({ + blindingKey: coin.blindingKey, + coinPriv: coin.coinPriv, + coinPub: coin.coinPub, + denomPub: d.denomInfo.denomPub, + denomPubHash: coin.denomPubHash, + denomSig: coin.denomSig, + }); + const reqUrl = new URL( + `/coins/${coin.coinPub}/recoup-refresh`, + coin.exchangeBaseUrl, + ); + logger.trace(`making recoup request for ${coin.coinPub}`); + + const resp = await ws.http.fetch(reqUrl.href, { + method: "POST", + body: 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`); + } + + await ws.db.runReadWriteTx( + ["coins", "denominations", "recoupGroups", "refreshGroups"], + async (tx) => { + const recoupGroup = await tx.recoupGroups.get(recoupGroupId); + if (!recoupGroup) { + return; + } + if (recoupGroup.recoupFinishedPerCoin[coinIdx]) { + return; + } + const oldCoin = await tx.coins.get(cs.oldCoinPub); + const revokedCoin = await tx.coins.get(coin.coinPub); + if (!revokedCoin) { + logger.warn("revoked coin for recoup not found"); + return; + } + if (!oldCoin) { + logger.warn("refresh old coin for recoup not found"); + return; + } + const oldCoinDenom = await ws.getDenomInfo( + ws, + tx, + oldCoin.exchangeBaseUrl, + oldCoin.denomPubHash, + ); + const revokedCoinDenom = await ws.getDenomInfo( + ws, + tx, + revokedCoin.exchangeBaseUrl, + revokedCoin.denomPubHash, + ); + checkDbInvariant(!!oldCoinDenom); + checkDbInvariant(!!revokedCoinDenom); + revokedCoin.status = CoinStatus.Dormant; + if (!revokedCoin.spendAllocation) { + // We don't know what happened to this coin + logger.error( + `can't refresh-recoup coin ${revokedCoin.coinPub}, no spendAllocation known`, + ); + } else { + let residualAmount = Amounts.sub( + revokedCoinDenom.value, + revokedCoin.spendAllocation.amount, + ).amount; + recoupGroup.scheduleRefreshCoins.push({ + coinPub: oldCoin.coinPub, + amount: Amounts.stringify(residualAmount), + }); + } + await tx.coins.put(revokedCoin); + await tx.coins.put(oldCoin); + await putGroupAsFinished(ws, tx, recoupGroup, coinIdx); + }, + ); +} + +export async function processRecoupGroup( + ws: InternalWalletState, + recoupGroupId: string, +): Promise { + let recoupGroup = await ws.db.runReadOnlyTx(["recoupGroups"], async (tx) => { + return tx.recoupGroups.get(recoupGroupId); + }); + if (!recoupGroup) { + return TaskRunResult.finished(); + } + if (recoupGroup.timestampFinished) { + logger.trace("recoup group finished"); + return TaskRunResult.finished(); + } + const ps = recoupGroup.coinPubs.map(async (x, i) => { + try { + await processRecoupForCoin(ws, recoupGroupId, i); + } catch (e) { + logger.warn(`processRecoup failed: ${e}`); + throw e; + } + }); + await Promise.all(ps); + + recoupGroup = await ws.db.runReadOnlyTx(["recoupGroups"], async (tx) => { + return tx.recoupGroups.get(recoupGroupId); + }); + if (!recoupGroup) { + return TaskRunResult.finished(); + } + + for (const b of recoupGroup.recoupFinishedPerCoin) { + if (!b) { + return TaskRunResult.finished(); + } + } + + logger.info("all recoups of recoup group are finished"); + + const reserveSet = new Set(); + const reservePrivMap: Record = {}; + for (let i = 0; i < recoupGroup.coinPubs.length; i++) { + const coinPub = recoupGroup.coinPubs[i]; + await ws.db.runReadOnlyTx(["coins", "reserves"], async (tx) => { + const coin = await tx.coins.get(coinPub); + if (!coin) { + throw Error(`Coin ${coinPub} not found, can't request recoup`); + } + if (coin.coinSource.type === CoinSourceType.Withdraw) { + const reserve = await tx.reserves.indexes.byReservePub.get( + coin.coinSource.reservePub, + ); + if (!reserve) { + return; + } + reserveSet.add(coin.coinSource.reservePub); + reservePrivMap[coin.coinSource.reservePub] = reserve.reservePriv; + } + }); + } + + for (const reservePub of reserveSet) { + const reserveUrl = new URL( + `reserves/${reservePub}`, + recoupGroup.exchangeBaseUrl, + ); + logger.info(`querying reserve status for recoup via ${reserveUrl}`); + + const resp = await ws.http.fetch(reserveUrl.href); + + const result = await readSuccessResponseJsonOrThrow( + resp, + codecForReserveStatus(), + ); + await internalCreateWithdrawalGroup(ws, { + amount: Amounts.parseOrThrow(result.balance), + exchangeBaseUrl: recoupGroup.exchangeBaseUrl, + reserveStatus: WithdrawalGroupStatus.PendingQueryingStatus, + reserveKeyPair: { + pub: reservePub, + priv: reservePrivMap[reservePub], + }, + wgInfo: { + withdrawalType: WithdrawalRecordType.Recoup, + }, + }); + } + + await ws.db.runReadWriteTx( + [ + "recoupGroups", + "coinAvailability", + "denominations", + "refreshGroups", + "coins", + ], + async (tx) => { + const rg2 = await tx.recoupGroups.get(recoupGroupId); + if (!rg2) { + return; + } + rg2.timestampFinished = timestampPreciseToDb(TalerPreciseTimestamp.now()); + rg2.operationStatus = RecoupOperationStatus.Finished; + if (rg2.scheduleRefreshCoins.length > 0) { + await createRefreshGroup( + ws, + tx, + Amounts.currencyOf(rg2.scheduleRefreshCoins[0].amount), + rg2.scheduleRefreshCoins, + RefreshReason.Recoup, + constructTransactionIdentifier({ + tag: TransactionType.Recoup, + recoupGroupId: rg2.recoupGroupId, + }), + ); + } + await tx.recoupGroups.put(rg2); + }, + ); + return TaskRunResult.finished(); +} + +export class RewardTransactionContext implements TransactionContext { + abortTransaction(): Promise { + throw new Error("Method not implemented."); + } + suspendTransaction(): Promise { + throw new Error("Method not implemented."); + } + resumeTransaction(): Promise { + throw new Error("Method not implemented."); + } + failTransaction(): Promise { + throw new Error("Method not implemented."); + } + deleteTransaction(): Promise { + throw new Error("Method not implemented."); + } + public transactionId: string; + public retryTag: string; + + constructor( + public ws: InternalWalletState, + private recoupGroupId: string, + ) { + this.transactionId = constructTransactionIdentifier({ + tag: TransactionType.Recoup, + recoupGroupId, + }); + this.retryTag = constructTaskIdentifier({ + tag: PendingTaskType.Recoup, + recoupGroupId, + }); + } +} + +export async function createRecoupGroup( + ws: InternalWalletState, + tx: WalletDbReadWriteTransaction< + ["recoupGroups", "denominations", "refreshGroups", "coins"] + >, + exchangeBaseUrl: string, + coinPubs: string[], +): Promise { + const recoupGroupId = encodeCrock(getRandomBytes(32)); + + const recoupGroup: RecoupGroupRecord = { + recoupGroupId, + exchangeBaseUrl: exchangeBaseUrl, + coinPubs: coinPubs, + timestampFinished: undefined, + timestampStarted: timestampPreciseToDb(TalerPreciseTimestamp.now()), + recoupFinishedPerCoin: coinPubs.map(() => false), + scheduleRefreshCoins: [], + operationStatus: RecoupOperationStatus.Pending, + }; + + for (let coinIdx = 0; coinIdx < coinPubs.length; coinIdx++) { + const coinPub = coinPubs[coinIdx]; + const coin = await tx.coins.get(coinPub); + if (!coin) { + await putGroupAsFinished(ws, tx, recoupGroup, coinIdx); + continue; + } + await tx.coins.put(coin); + } + + await tx.recoupGroups.put(recoupGroup); + + return recoupGroupId; +} + +/** + * Run the recoup protocol for a single coin in a recoup group. + */ +async function processRecoupForCoin( + ws: InternalWalletState, + recoupGroupId: string, + coinIdx: number, +): Promise { + const coin = await ws.db.runReadOnlyTx( + ["coins", "recoupGroups"], + async (tx) => { + const recoupGroup = await tx.recoupGroups.get(recoupGroupId); + if (!recoupGroup) { + return; + } + if (recoupGroup.timestampFinished) { + return; + } + if (recoupGroup.recoupFinishedPerCoin[coinIdx]) { + return; + } + + const coinPub = recoupGroup.coinPubs[coinIdx]; + + const coin = await tx.coins.get(coinPub); + if (!coin) { + throw Error(`Coin ${coinPub} not found, can't request recoup`); + } + return coin; + }, + ); + + if (!coin) { + return; + } + + const cs = coin.coinSource; + + switch (cs.type) { + case CoinSourceType.Reward: + return recoupRewardCoin(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