wallet-core/packages/taler-wallet-core/src/operations/recoup.ts

531 lines
15 KiB
TypeScript
Raw Normal View History

/*
This file is part of GNU Taler
2020-03-13 09:21:03 +01:00
(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 <http://www.gnu.org/licenses/>
*/
/**
* Implementation of the recoup operation, which allows to recover the
* value of coins held in a revoked denomination.
*
* @author Florian Dold <dold@taler.net>
*/
/**
* Imports.
*/
import {
Amounts,
2022-08-09 15:00:45 +02:00
codecForRecoupConfirmation,
codecForReserveStatus,
CoinStatus,
2022-08-09 15:00:45 +02:00
encodeCrock,
getRandomBytes,
j2s,
Logger,
NotificationType,
RefreshReason,
2022-08-09 15:00:45 +02:00
TalerProtocolTimestamp,
URL,
} from "@gnu-taler/taler-util";
import {
CoinRecord,
CoinSourceType,
2022-08-09 15:00:45 +02:00
RecoupGroupRecord,
RefreshCoinSource,
2022-08-09 15:00:45 +02:00
WalletStoresV1,
WithdrawalGroupStatus,
WithdrawalRecordType,
2022-08-09 15:00:45 +02:00
WithdrawCoinSource,
} from "../db.js";
import { InternalWalletState } from "../internal-wallet-state.js";
2023-02-15 23:32:42 +01:00
import { readSuccessResponseJsonOrThrow } from "@gnu-taler/taler-util/http";
import { checkDbInvariant } from "../util/invariants.js";
import { GetReadWriteAccess } from "../util/query.js";
2022-09-16 19:27:24 +02:00
import {
OperationAttemptResult,
unwrapOperationHandlerResultOrThrow,
2022-09-16 19:27:24 +02:00
} from "../util/retries.js";
2021-06-14 16:08:58 +02:00
import { createRefreshGroup, processRefreshGroup } from "./refresh.js";
2022-08-09 15:00:45 +02:00
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(
2020-03-27 19:07:02 +01:00
ws: InternalWalletState,
2021-06-09 15:14:17 +02:00
tx: GetReadWriteAccess<{
recoupGroups: typeof WalletStoresV1.recoupGroups;
denominations: typeof WalletStoresV1.denominations;
refreshGroups: typeof WalletStoresV1.refreshGroups;
coins: typeof WalletStoresV1.coins;
}>,
recoupGroup: RecoupGroupRecord,
coinIdx: number,
): Promise<void> {
logger.trace(
`setting coin ${coinIdx} of ${recoupGroup.coinPubs.length} as finished`,
);
2020-03-27 19:07:02 +01:00
if (recoupGroup.timestampFinished) {
return;
}
recoupGroup.recoupFinishedPerCoin[coinIdx] = true;
2021-06-09 15:14:17 +02:00
await tx.recoupGroups.put(recoupGroup);
}
async function recoupTipCoin(
ws: InternalWalletState,
recoupGroupId: string,
coinIdx: number,
coin: CoinRecord,
): Promise<void> {
// 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
2021-06-09 15:14:17 +02:00
await ws.db
.mktx((stores) => [
stores.recoupGroups,
stores.denominations,
stores.refreshGroups,
stores.coins,
])
2021-06-09 15:14:17 +02:00
.runReadWrite(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<void> {
const reservePub = cs.reservePub;
2022-08-09 15:00:45 +02:00
const denomInfo = await ws.db
.mktx((x) => [x.denominations])
2021-06-09 15:14:17 +02:00
.runReadOnly(async (tx) => {
const denomInfo = await ws.getDenomInfo(
ws,
tx,
2022-08-09 15:00:45 +02:00
coin.exchangeBaseUrl,
coin.denomPubHash,
);
2022-08-09 15:00:45 +02:00
return denomInfo;
2021-06-09 15:14:17 +02:00
});
2022-08-09 15:00:45 +02:00
if (!denomInfo) {
// FIXME: We should at least emit some pending operation / warning for this?
return;
}
ws.notify({
type: NotificationType.RecoupStarted,
});
2022-01-11 12:48:32 +01:00
const recoupRequest = await ws.cryptoApi.createRecoupRequest({
blindingKey: coin.blindingKey,
coinPriv: coin.coinPriv,
coinPub: coin.coinPub,
denomPub: denomInfo.denomPub,
2022-01-11 12:48:32 +01:00
denomPubHash: coin.denomPubHash,
denomSig: coin.denomSig,
});
const reqUrl = new URL(`/coins/${coin.coinPub}/recoup`, coin.exchangeBaseUrl);
2022-01-11 12:48:32 +01:00
logger.trace(`requesting recoup via ${reqUrl.href}`);
2022-08-09 15:00:45 +02:00
const resp = await ws.http.postJson(reqUrl.href, recoupRequest);
const recoupConfirmation = await readSuccessResponseJsonOrThrow(
resp,
codecForRecoupConfirmation(),
);
2022-01-11 12:48:32 +01:00
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
2021-06-09 15:14:17 +02:00
await ws.db
.mktx((x) => [x.coins, x.denominations, x.recoupGroups, x.refreshGroups])
2021-06-09 15:14:17 +02:00
.runReadWrite(async (tx) => {
const recoupGroup = await tx.recoupGroups.get(recoupGroupId);
if (!recoupGroup) {
return;
}
if (recoupGroup.recoupFinishedPerCoin[coinIdx]) {
return;
}
2021-06-09 15:14:17 +02:00
const updatedCoin = await tx.coins.get(coin.coinPub);
if (!updatedCoin) {
return;
}
updatedCoin.status = CoinStatus.Dormant;
2021-06-09 15:14:17 +02:00
await tx.coins.put(updatedCoin);
2020-03-27 19:07:02 +01:00
await putGroupAsFinished(ws, tx, recoupGroup, coinIdx);
2021-06-09 15:14:17 +02:00
});
2019-12-05 19:38:19 +01:00
ws.notify({
type: NotificationType.RecoupFinished,
2019-12-05 19:38:19 +01:00
});
}
async function recoupRefreshCoin(
ws: InternalWalletState,
recoupGroupId: string,
coinIdx: number,
coin: CoinRecord,
cs: RefreshCoinSource,
): Promise<void> {
const d = await ws.db
.mktx((x) => [x.coins, x.denominations])
.runReadOnly(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;
}
ws.notify({
type: NotificationType.RecoupStarted,
});
2022-01-11 12:48:32 +01:00
const recoupRequest = await ws.cryptoApi.createRecoupRefreshRequest({
blindingKey: coin.blindingKey,
coinPriv: coin.coinPriv,
coinPub: coin.coinPub,
denomPub: d.denomInfo.denomPub,
2022-01-11 12:48:32 +01:00
denomPubHash: coin.denomPubHash,
denomSig: coin.denomSig,
});
2022-01-12 15:51:56 +01:00
const reqUrl = new URL(
`/coins/${coin.coinPub}/recoup-refresh`,
coin.exchangeBaseUrl,
);
logger.trace(`making recoup request for ${coin.coinPub}`);
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`);
}
2021-06-09 15:14:17 +02:00
await ws.db
.mktx((x) => [x.coins, x.denominations, x.recoupGroups, x.refreshGroups])
2021-06-09 15:14:17 +02:00
.runReadWrite(async (tx) => {
const recoupGroup = await tx.recoupGroups.get(recoupGroupId);
if (!recoupGroup) {
return;
}
if (recoupGroup.recoupFinishedPerCoin[coinIdx]) {
return;
}
2021-06-09 15:14:17 +02:00
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,
2022-11-02 17:42:14 +01:00
amount: Amounts.stringify(residualAmount),
});
}
2021-06-09 15:14:17 +02:00
await tx.coins.put(revokedCoin);
await tx.coins.put(oldCoin);
2020-03-27 19:07:02 +01:00
await putGroupAsFinished(ws, tx, recoupGroup, coinIdx);
2021-06-09 15:14:17 +02:00
});
}
export async function processRecoupGroup(
ws: InternalWalletState,
recoupGroupId: string,
options: {
forceNow?: boolean;
} = {},
): Promise<void> {
await unwrapOperationHandlerResultOrThrow(
2022-09-05 18:12:30 +02:00
await processRecoupGroupHandler(ws, recoupGroupId, options),
);
return;
}
2022-09-05 18:12:30 +02:00
export async function processRecoupGroupHandler(
ws: InternalWalletState,
recoupGroupId: string,
options: {
forceNow?: boolean;
} = {},
2022-09-05 18:12:30 +02:00
): Promise<OperationAttemptResult> {
const forceNow = options.forceNow ?? false;
let recoupGroup = await ws.db
.mktx((x) => [x.recoupGroups])
2021-06-09 15:14:17 +02:00
.runReadOnly(async (tx) => {
return tx.recoupGroups.get(recoupGroupId);
});
if (!recoupGroup) {
2022-09-05 18:12:30 +02:00
return OperationAttemptResult.finishedEmpty();
}
if (recoupGroup.timestampFinished) {
logger.trace("recoup group finished");
2022-09-05 18:12:30 +02:00
return OperationAttemptResult.finishedEmpty();
}
2022-01-11 12:48:32 +01:00
const ps = recoupGroup.coinPubs.map(async (x, i) => {
try {
2022-01-12 15:51:56 +01:00
await processRecoup(ws, recoupGroupId, i);
2022-01-11 12:48:32 +01:00
} catch (e) {
logger.warn(`processRecoup failed: ${e}`);
throw e;
}
});
await Promise.all(ps);
recoupGroup = await ws.db
.mktx((x) => [x.recoupGroups])
.runReadOnly(async (tx) => {
return tx.recoupGroups.get(recoupGroupId);
});
if (!recoupGroup) {
2022-09-05 18:12:30 +02:00
return OperationAttemptResult.finishedEmpty();
}
for (const b of recoupGroup.recoupFinishedPerCoin) {
if (!b) {
2022-09-05 18:12:30 +02:00
return OperationAttemptResult.finishedEmpty();
}
}
logger.info("all recoups of recoup group are finished");
const reserveSet = new Set<string>();
const reservePrivMap: Record<string, string> = {};
for (let i = 0; i < recoupGroup.coinPubs.length; i++) {
const coinPub = recoupGroup.coinPubs[i];
await ws.db
.mktx((x) => [x.coins, x.reserves])
2021-06-09 15:14:17 +02:00
.runReadOnly(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) {
2022-09-21 21:47:00 +02:00
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;
}
2021-06-09 15:14:17 +02:00
});
}
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.get(reserveUrl.href);
const result = await readSuccessResponseJsonOrThrow(
resp,
codecForReserveStatus(),
);
await internalCreateWithdrawalGroup(ws, {
amount: Amounts.parseOrThrow(result.balance),
exchangeBaseUrl: recoupGroup.exchangeBaseUrl,
reserveStatus: WithdrawalGroupStatus.QueryingStatus,
reserveKeyPair: {
pub: reservePub,
priv: reservePrivMap[reservePub],
},
wgInfo: {
withdrawalType: WithdrawalRecordType.Recoup,
},
});
}
await ws.db
.mktx((x) => [
x.recoupGroups,
x.coinAvailability,
x.denominations,
x.refreshGroups,
x.coins,
])
.runReadWrite(async (tx) => {
const rg2 = await tx.recoupGroups.get(recoupGroupId);
if (!rg2) {
return;
}
rg2.timestampFinished = TalerProtocolTimestamp.now();
if (rg2.scheduleRefreshCoins.length > 0) {
const refreshGroupId = await createRefreshGroup(
ws,
tx,
Amounts.currencyOf(rg2.scheduleRefreshCoins[0].amount),
rg2.scheduleRefreshCoins,
RefreshReason.Recoup,
);
processRefreshGroup(ws, refreshGroupId.refreshGroupId).catch((e) => {
logger.error(`error while refreshing after recoup ${e}`);
});
}
await tx.recoupGroups.put(rg2);
});
2022-09-05 18:12:30 +02:00
return OperationAttemptResult.finishedEmpty();
}
export async function createRecoupGroup(
ws: InternalWalletState,
2021-06-09 15:14:17 +02:00
tx: GetReadWriteAccess<{
recoupGroups: typeof WalletStoresV1.recoupGroups;
denominations: typeof WalletStoresV1.denominations;
refreshGroups: typeof WalletStoresV1.refreshGroups;
coins: typeof WalletStoresV1.coins;
}>,
exchangeBaseUrl: string,
coinPubs: string[],
): Promise<string> {
const recoupGroupId = encodeCrock(getRandomBytes(32));
const recoupGroup: RecoupGroupRecord = {
recoupGroupId,
exchangeBaseUrl: exchangeBaseUrl,
coinPubs: coinPubs,
timestampFinished: undefined,
2022-03-18 15:32:41 +01:00
timestampStarted: TalerProtocolTimestamp.now(),
recoupFinishedPerCoin: coinPubs.map(() => false),
2020-03-27 19:07:02 +01:00
scheduleRefreshCoins: [],
};
for (let coinIdx = 0; coinIdx < coinPubs.length; coinIdx++) {
const coinPub = coinPubs[coinIdx];
2021-06-09 15:14:17 +02:00
const coin = await tx.coins.get(coinPub);
if (!coin) {
2020-03-27 19:07:02 +01:00
await putGroupAsFinished(ws, tx, recoupGroup, coinIdx);
continue;
}
2021-06-09 15:14:17 +02:00
await tx.coins.put(coin);
}
2021-06-09 15:14:17 +02:00
await tx.recoupGroups.put(recoupGroup);
return recoupGroupId;
}
2022-01-12 15:51:56 +01:00
/**
* Run the recoup protocol for a single coin in a recoup group.
*/
async function processRecoup(
ws: InternalWalletState,
recoupGroupId: string,
coinIdx: number,
): Promise<void> {
2021-06-09 15:14:17 +02:00
const coin = await ws.db
.mktx((x) => [x.recoupGroups, x.coins])
2021-06-09 15:14:17 +02:00
.runReadOnly(async (tx) => {
const recoupGroup = await tx.recoupGroups.get(recoupGroupId);
if (!recoupGroup) {
return;
}
if (recoupGroup.timestampFinished) {
return;
}
if (recoupGroup.recoupFinishedPerCoin[coinIdx]) {
return;
}
2021-06-09 15:14:17 +02:00
const coinPub = recoupGroup.coinPubs[coinIdx];
const coin = await tx.coins.get(coinPub);
if (!coin) {
2022-01-12 15:51:56 +01:00
throw Error(`Coin ${coinPub} not found, can't request recoup`);
2021-06-09 15:14:17 +02:00
}
return coin;
});
if (!coin) {
2021-06-09 15:14:17 +02:00
return;
}
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");
}
}