/* 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, TransactionIdStr, TransactionType, URL, checkDbInvariant, codecForRecoupConfirmation, codecForReserveStatus, encodeCrock, getRandomBytes, j2s, } from "@gnu-taler/taler-util"; import { readSuccessResponseJsonOrThrow } from "@gnu-taler/taler-util/http"; import { PendingTaskType, TaskIdStr, TaskRunResult, TransactionContext, constructTaskIdentifier, } from "./common.js"; import { CoinRecord, CoinSourceType, RecoupGroupRecord, RecoupOperationStatus, RefreshCoinSource, WalletDbReadWriteTransaction, WithdrawCoinSource, WithdrawalGroupStatus, WithdrawalRecordType, timestampPreciseToDb, } from "./db.js"; import { createRefreshGroup } from "./refresh.js"; import { constructTransactionIdentifier } from "./transactions.js"; import { WalletExecutionContext, getDenomInfo } from "./wallet.js"; import { internalCreateWithdrawalGroup } from "./withdraw.js"; export const logger = new Logger("operations/recoup.ts"); /** * Store a recoup group record in the database after marking * a coin in the group as finished. */ export async function putGroupAsFinished( wex: WalletExecutionContext, 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( wex: WalletExecutionContext, 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 wex.db.runReadWriteTx( { storeNames: ["recoupGroups", "denominations", "refreshGroups", "coins"] }, async (tx) => { const recoupGroup = await tx.recoupGroups.get(recoupGroupId); if (!recoupGroup) { return; } if (recoupGroup.recoupFinishedPerCoin[coinIdx]) { return; } await putGroupAsFinished(wex, tx, recoupGroup, coinIdx); }, ); } async function recoupRefreshCoin( wex: WalletExecutionContext, recoupGroupId: string, coinIdx: number, coin: CoinRecord, cs: RefreshCoinSource, ): Promise { const d = await wex.db.runReadOnlyTx( { storeNames: ["coins", "denominations"] }, async (tx) => { const denomInfo = await getDenomInfo( wex, 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 wex.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 wex.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 wex.db.runReadWriteTx( { storeNames: ["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 getDenomInfo( wex, tx, oldCoin.exchangeBaseUrl, oldCoin.denomPubHash, ); const revokedCoinDenom = await getDenomInfo( wex, 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(wex, tx, recoupGroup, coinIdx); }, ); } export async function recoupWithdrawCoin( wex: WalletExecutionContext, recoupGroupId: string, coinIdx: number, coin: CoinRecord, cs: WithdrawCoinSource, ): Promise { const reservePub = cs.reservePub; const denomInfo = await wex.db.runReadOnlyTx( { storeNames: ["denominations"] }, async (tx) => { const denomInfo = await getDenomInfo( wex, 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 wex.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 wex.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 wex.db.runReadWriteTx( { storeNames: ["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(wex, tx, recoupGroup, coinIdx); }, ); } export async function processRecoupGroup( wex: WalletExecutionContext, recoupGroupId: string, ): Promise { let recoupGroup = await wex.db.runReadOnlyTx( { storeNames: ["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(wex, recoupGroupId, i); } catch (e) { logger.warn(`processRecoup failed: ${e}`); throw e; } }); await Promise.all(ps); recoupGroup = await wex.db.runReadOnlyTx( { storeNames: ["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 wex.db.runReadOnlyTx( { storeNames: ["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 wex.http.fetch(reserveUrl.href); const result = await readSuccessResponseJsonOrThrow( resp, codecForReserveStatus(), ); await internalCreateWithdrawalGroup(wex, { amount: Amounts.parseOrThrow(result.balance), exchangeBaseUrl: recoupGroup.exchangeBaseUrl, reserveStatus: WithdrawalGroupStatus.PendingQueryingStatus, reserveKeyPair: { pub: reservePub, priv: reservePrivMap[reservePub], }, wgInfo: { withdrawalType: WithdrawalRecordType.Recoup, }, }); } await wex.db.runReadWriteTx( { storeNames: [ "recoupGroups", "coinAvailability", "denominations", "refreshGroups", "refreshSessions", "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( wex, 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 RecoupTransactionContext 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: TransactionIdStr; public taskId: TaskIdStr; constructor( public wex: WalletExecutionContext, private recoupGroupId: string, ) { this.transactionId = constructTransactionIdentifier({ tag: TransactionType.Recoup, recoupGroupId, }); this.taskId = constructTaskIdentifier({ tag: PendingTaskType.Recoup, recoupGroupId, }); } } export async function createRecoupGroup( wex: WalletExecutionContext, 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(wex, tx, recoupGroup, coinIdx); continue; } await tx.coins.put(coin); } await tx.recoupGroups.put(recoupGroup); const ctx = new RecoupTransactionContext(wex, recoupGroupId); wex.taskScheduler.startShepherdTask(ctx.taskId); return recoupGroupId; } /** * Run the recoup protocol for a single coin in a recoup group. */ async function processRecoupForCoin( wex: WalletExecutionContext, recoupGroupId: string, coinIdx: number, ): Promise { const coin = await wex.db.runReadOnlyTx( { storeNames: ["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(wex, recoupGroupId, coinIdx, coin); case CoinSourceType.Refresh: return recoupRefreshCoin(wex, recoupGroupId, coinIdx, coin, cs); case CoinSourceType.Withdraw: return recoupWithdrawCoin(wex, recoupGroupId, coinIdx, coin, cs); default: throw Error("unknown coin source type"); } }