summaryrefslogtreecommitdiff
path: root/packages/taler-wallet-core/src/recoup.ts
diff options
context:
space:
mode:
authorFlorian Dold <florian@dold.me>2024-02-19 18:05:48 +0100
committerFlorian Dold <florian@dold.me>2024-02-19 18:05:48 +0100
commite951075d2ef52fa8e9e7489c62031777c3a7e66b (patch)
tree64208c09a9162f3a99adccf30edc36de1ef884ef /packages/taler-wallet-core/src/recoup.ts
parente975740ac4e9ba4bc531226784d640a018c00833 (diff)
downloadwallet-core-e951075d2ef52fa8e9e7489c62031777c3a7e66b.tar.gz
wallet-core-e951075d2ef52fa8e9e7489c62031777c3a7e66b.tar.bz2
wallet-core-e951075d2ef52fa8e9e7489c62031777c3a7e66b.zip
wallet-core: flatten directory structure
Diffstat (limited to 'packages/taler-wallet-core/src/recoup.ts')
-rw-r--r--packages/taler-wallet-core/src/recoup.ts535
1 files changed, 535 insertions, 0 deletions
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 <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,
+ 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<void> {
+ 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<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
+ 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<void> {
+ 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<void> {
+ 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<TaskRunResult> {
+ 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<string>();
+ const reservePrivMap: Record<string, string> = {};
+ 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<void> {
+ throw new Error("Method not implemented.");
+ }
+ suspendTransaction(): Promise<void> {
+ throw new Error("Method not implemented.");
+ }
+ resumeTransaction(): Promise<void> {
+ throw new Error("Method not implemented.");
+ }
+ failTransaction(): Promise<void> {
+ throw new Error("Method not implemented.");
+ }
+ deleteTransaction(): Promise<void> {
+ 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<string> {
+ 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<void> {
+ 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");
+ }
+}