/*
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,
Transaction,
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,
WalletDbAllStoresReadOnlyTransaction,
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",
"exchanges",
"transactionsMeta",
]
>,
recoupGroup: RecoupGroupRecord,
coinIdx: number,
): Promise {
const ctx = new RecoupTransactionContext(wex, recoupGroup.recoupGroupId);
logger.trace(
`setting coin ${coinIdx} of ${recoupGroup.coinPubs.length} as finished`,
);
if (recoupGroup.timestampFinished) {
return;
}
recoupGroup.recoupFinishedPerCoin[coinIdx] = true;
await tx.recoupGroups.put(recoupGroup);
await ctx.updateTransactionMeta(tx);
}
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",
"exchanges",
"transactionsMeta",
],
},
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",
"transactionsMeta",
"exchanges",
],
},
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,
`no denom for coin, hash ${oldCoin.denomPubHash}`,
);
checkDbInvariant(
!!revokedCoinDenom,
`no revoked denom for coin, hash ${revokedCoin.denomPubHash}`,
);
revokedCoin.status = CoinStatus.Dormant;
// FIXME: Schedule recoup for the sum of refreshes, based on the coin event history.
// recoupGroup.scheduleRefreshCoins.push({
// coinPub: oldCoin.coinPub,
// amount: Amounts.stringify(refreshAmount),
// });
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",
"transactionsMeta",
"exchanges",
],
},
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 {
if (!wex.ws.networkAvailable) {
return TaskRunResult.networkRequired();
}
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,
},
});
}
const ctx = new RecoupTransactionContext(wex, recoupGroupId);
await wex.db.runReadWriteTx(
{
storeNames: [
"coinAvailability",
"coinHistory",
"coins",
"denominations",
"recoupGroups",
"refreshGroups",
"refreshSessions",
"transactionsMeta",
"exchanges",
],
},
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);
await ctx.updateTransactionMeta(tx);
},
);
return TaskRunResult.finished();
}
export class RecoupTransactionContext implements TransactionContext {
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,
});
}
async updateTransactionMeta(
tx: WalletDbReadWriteTransaction<
["recoupGroups", "exchanges", "transactionsMeta"]
>,
): Promise {
const recoupRec = await tx.recoupGroups.get(this.recoupGroupId);
if (!recoupRec) {
await tx.transactionsMeta.delete(this.transactionId);
return;
}
const exch = await tx.exchanges.get(recoupRec.exchangeBaseUrl);
if (!exch || !exch.detailsPointer) {
await tx.transactionsMeta.delete(this.transactionId);
return;
}
await tx.transactionsMeta.put({
transactionId: this.transactionId,
status: recoupRec.operationStatus,
timestamp: recoupRec.timestampStarted,
currency: exch.detailsPointer?.currency,
exchanges: [recoupRec.exchangeBaseUrl],
});
}
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.");
}
lookupFullTransaction(
tx: WalletDbAllStoresReadOnlyTransaction,
): Promise {
throw new Error("Method not implemented.");
}
}
export async function createRecoupGroup(
wex: WalletExecutionContext,
tx: WalletDbReadWriteTransaction<
[
"recoupGroups",
"denominations",
"refreshGroups",
"coins",
"exchanges",
"transactionsMeta",
]
>,
exchangeBaseUrl: string,
coinPubs: string[],
): Promise {
const recoupGroupId = encodeCrock(getRandomBytes(32));
const ctx = new RecoupTransactionContext(wex, recoupGroupId);
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);
await ctx.updateTransactionMeta(tx);
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");
}
}