aboutsummaryrefslogtreecommitdiff
path: root/src/operations
diff options
context:
space:
mode:
Diffstat (limited to 'src/operations')
-rw-r--r--src/operations/balance.ts153
-rw-r--r--src/operations/errors.ts121
-rw-r--r--src/operations/exchanges.ts554
-rw-r--r--src/operations/pay.ts1147
-rw-r--r--src/operations/pending.ts458
-rw-r--r--src/operations/recoup.ts411
-rw-r--r--src/operations/refresh.ts572
-rw-r--r--src/operations/refund.ts425
-rw-r--r--src/operations/reserves.ts840
-rw-r--r--src/operations/state.ts65
-rw-r--r--src/operations/tip.ts342
-rw-r--r--src/operations/transactions.ts292
-rw-r--r--src/operations/versions.ts38
-rw-r--r--src/operations/withdraw-test.ts332
-rw-r--r--src/operations/withdraw.ts756
15 files changed, 0 insertions, 6506 deletions
diff --git a/src/operations/balance.ts b/src/operations/balance.ts
deleted file mode 100644
index 26f0aaeee..000000000
--- a/src/operations/balance.ts
+++ /dev/null
@@ -1,153 +0,0 @@
-/*
- This file is part of GNU Taler
- (C) 2019 GNUnet e.V.
-
- 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/>
- */
-
-/**
- * Imports.
- */
-import { BalancesResponse } from "../types/walletTypes";
-import { TransactionHandle } from "../util/query";
-import { InternalWalletState } from "./state";
-import { Stores, CoinStatus } from "../types/dbTypes";
-import * as Amounts from "../util/amounts";
-import { AmountJson } from "../util/amounts";
-import { Logger } from "../util/logging";
-
-const logger = new Logger("withdraw.ts");
-
-interface WalletBalance {
- available: AmountJson;
- pendingIncoming: AmountJson;
- pendingOutgoing: AmountJson;
-}
-
-/**
- * Get balance information.
- */
-export async function getBalancesInsideTransaction(
- ws: InternalWalletState,
- tx: TransactionHandle,
-): Promise<BalancesResponse> {
- const balanceStore: Record<string, WalletBalance> = {};
-
- /**
- * Add amount to a balance field, both for
- * the slicing by exchange and currency.
- */
- const initBalance = (currency: string): WalletBalance => {
- const b = balanceStore[currency];
- if (!b) {
- balanceStore[currency] = {
- available: Amounts.getZero(currency),
- pendingIncoming: Amounts.getZero(currency),
- pendingOutgoing: Amounts.getZero(currency),
- };
- }
- return balanceStore[currency];
- };
-
- // Initialize balance to zero, even if we didn't start withdrawing yet.
- await tx.iter(Stores.reserves).forEach((r) => {
- const b = initBalance(r.currency);
- if (!r.initialWithdrawalStarted) {
- b.pendingIncoming = Amounts.add(
- b.pendingIncoming,
- r.initialDenomSel.totalCoinValue,
- ).amount;
- }
- });
-
- await tx.iter(Stores.coins).forEach((c) => {
- // Only count fresh coins, as dormant coins will
- // already be in a refresh session.
- if (c.status === CoinStatus.Fresh) {
- const b = initBalance(c.currentAmount.currency);
- b.available = Amounts.add(b.available, c.currentAmount).amount;
- }
- });
-
- await tx.iter(Stores.refreshGroups).forEach((r) => {
- // Don't count finished refreshes, since the refresh already resulted
- // in coins being added to the wallet.
- if (r.timestampFinished) {
- return;
- }
- for (let i = 0; i < r.oldCoinPubs.length; i++) {
- const session = r.refreshSessionPerCoin[i];
- if (session) {
- const b = initBalance(session.amountRefreshOutput.currency);
- // We are always assuming the refresh will succeed, thus we
- // report the output as available balance.
- b.available = Amounts.add(session.amountRefreshOutput).amount;
- }
- }
- });
-
- await tx.iter(Stores.withdrawalGroups).forEach((wds) => {
- if (wds.timestampFinish) {
- return;
- }
- const b = initBalance(wds.denomsSel.totalWithdrawCost.currency);
- b.pendingIncoming = Amounts.add(
- b.pendingIncoming,
- wds.denomsSel.totalCoinValue,
- ).amount;
- });
-
- const balancesResponse: BalancesResponse = {
- balances: [],
- };
-
- Object.keys(balanceStore)
- .sort()
- .forEach((c) => {
- const v = balanceStore[c];
- balancesResponse.balances.push({
- available: Amounts.stringify(v.available),
- pendingIncoming: Amounts.stringify(v.pendingIncoming),
- pendingOutgoing: Amounts.stringify(v.pendingOutgoing),
- hasPendingTransactions: false,
- requiresUserInput: false,
- });
- });
-
- return balancesResponse;
-}
-
-/**
- * Get detailed balance information, sliced by exchange and by currency.
- */
-export async function getBalances(
- ws: InternalWalletState,
-): Promise<BalancesResponse> {
- logger.trace("starting to compute balance");
-
- const wbal = await ws.db.runWithReadTransaction(
- [
- Stores.coins,
- Stores.refreshGroups,
- Stores.reserves,
- Stores.purchases,
- Stores.withdrawalGroups,
- ],
- async (tx) => {
- return getBalancesInsideTransaction(ws, tx);
- },
- );
-
- logger.trace("finished computing wallet balance");
-
- return wbal;
-}
diff --git a/src/operations/errors.ts b/src/operations/errors.ts
deleted file mode 100644
index 198d3f8c5..000000000
--- a/src/operations/errors.ts
+++ /dev/null
@@ -1,121 +0,0 @@
-/*
- 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/>
- */
-
-/**
- * Classes and helpers for error handling specific to wallet operations.
- *
- * @author Florian Dold <dold@taler.net>
- */
-
-/**
- * Imports.
- */
-import { OperationErrorDetails } from "../types/walletTypes";
-import { TalerErrorCode } from "../TalerErrorCode";
-
-/**
- * This exception is there to let the caller know that an error happened,
- * but the error has already been reported by writing it to the database.
- */
-export class OperationFailedAndReportedError extends Error {
- constructor(public operationError: OperationErrorDetails) {
- super(operationError.message);
-
- // Set the prototype explicitly.
- Object.setPrototypeOf(this, OperationFailedAndReportedError.prototype);
- }
-}
-
-/**
- * This exception is thrown when an error occured and the caller is
- * responsible for recording the failure in the database.
- */
-export class OperationFailedError extends Error {
- static fromCode(
- ec: TalerErrorCode,
- message: string,
- details: Record<string, unknown>,
- ): OperationFailedError {
- return new OperationFailedError(makeErrorDetails(ec, message, details));
- }
-
- constructor(public operationError: OperationErrorDetails) {
- super(operationError.message);
-
- // Set the prototype explicitly.
- Object.setPrototypeOf(this, OperationFailedError.prototype);
- }
-}
-
-export function makeErrorDetails(
- ec: TalerErrorCode,
- message: string,
- details: Record<string, unknown>,
-): OperationErrorDetails {
- return {
- talerErrorCode: ec,
- talerErrorHint: `Error: ${TalerErrorCode[ec]}`,
- details: details,
- message,
- };
-}
-
-/**
- * Run an operation and call the onOpError callback
- * when there was an exception or operation error that must be reported.
- * The cause will be re-thrown to the caller.
- */
-export async function guardOperationException<T>(
- op: () => Promise<T>,
- onOpError: (e: OperationErrorDetails) => Promise<void>,
-): Promise<T> {
- try {
- return await op();
- } catch (e) {
- if (e instanceof OperationFailedAndReportedError) {
- throw e;
- }
- if (e instanceof OperationFailedError) {
- await onOpError(e.operationError);
- throw new OperationFailedAndReportedError(e.operationError);
- }
- if (e instanceof Error) {
- const opErr = makeErrorDetails(
- TalerErrorCode.WALLET_UNEXPECTED_EXCEPTION,
- `unexpected exception (message: ${e.message})`,
- {},
- );
- await onOpError(opErr);
- throw new OperationFailedAndReportedError(opErr);
- }
- // Something was thrown that is not even an exception!
- // Try to stringify it.
- let excString: string;
- try {
- excString = e.toString();
- } catch (e) {
- // Something went horribly wrong.
- excString = "can't stringify exception";
- }
- const opErr = makeErrorDetails(
- TalerErrorCode.WALLET_UNEXPECTED_EXCEPTION,
- `unexpected exception (not an exception, ${excString})`,
- {},
- );
- await onOpError(opErr);
- throw new OperationFailedAndReportedError(opErr);
- }
-}
diff --git a/src/operations/exchanges.ts b/src/operations/exchanges.ts
deleted file mode 100644
index 6b995b5e9..000000000
--- a/src/operations/exchanges.ts
+++ /dev/null
@@ -1,554 +0,0 @@
-/*
- This file is part of GNU Taler
- (C) 2019 GNUnet e.V.
-
- 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/>
- */
-
-import { InternalWalletState } from "./state";
-import {
- Denomination,
- codecForExchangeKeysJson,
- codecForExchangeWireJson,
-} from "../types/talerTypes";
-import { OperationErrorDetails } from "../types/walletTypes";
-import {
- ExchangeRecord,
- ExchangeUpdateStatus,
- Stores,
- DenominationRecord,
- DenominationStatus,
- WireFee,
- ExchangeUpdateReason,
- ExchangeUpdatedEventRecord,
-} from "../types/dbTypes";
-import { canonicalizeBaseUrl } from "../util/helpers";
-import * as Amounts from "../util/amounts";
-import { parsePaytoUri } from "../util/payto";
-import {
- OperationFailedAndReportedError,
- guardOperationException,
- makeErrorDetails,
-} from "./errors";
-import {
- WALLET_CACHE_BREAKER_CLIENT_VERSION,
- WALLET_EXCHANGE_PROTOCOL_VERSION,
-} from "./versions";
-import { getTimestampNow } from "../util/time";
-import { compare } from "../util/libtoolVersion";
-import { createRecoupGroup, processRecoupGroup } from "./recoup";
-import { TalerErrorCode } from "../TalerErrorCode";
-import {
- readSuccessResponseJsonOrThrow,
- readSuccessResponseTextOrThrow,
-} from "../util/http";
-import { Logger } from "../util/logging";
-
-const logger = new Logger("exchanges.ts");
-
-async function denominationRecordFromKeys(
- ws: InternalWalletState,
- exchangeBaseUrl: string,
- denomIn: Denomination,
-): Promise<DenominationRecord> {
- const denomPubHash = await ws.cryptoApi.hashEncoded(denomIn.denom_pub);
- const d: DenominationRecord = {
- denomPub: denomIn.denom_pub,
- denomPubHash,
- exchangeBaseUrl,
- feeDeposit: Amounts.parseOrThrow(denomIn.fee_deposit),
- feeRefresh: Amounts.parseOrThrow(denomIn.fee_refresh),
- feeRefund: Amounts.parseOrThrow(denomIn.fee_refund),
- feeWithdraw: Amounts.parseOrThrow(denomIn.fee_withdraw),
- isOffered: true,
- isRevoked: false,
- masterSig: denomIn.master_sig,
- stampExpireDeposit: denomIn.stamp_expire_deposit,
- stampExpireLegal: denomIn.stamp_expire_legal,
- stampExpireWithdraw: denomIn.stamp_expire_withdraw,
- stampStart: denomIn.stamp_start,
- status: DenominationStatus.Unverified,
- value: Amounts.parseOrThrow(denomIn.value),
- };
- return d;
-}
-
-async function setExchangeError(
- ws: InternalWalletState,
- baseUrl: string,
- err: OperationErrorDetails,
-): Promise<void> {
- console.log(`last error for exchange ${baseUrl}:`, err);
- const mut = (exchange: ExchangeRecord): ExchangeRecord => {
- exchange.lastError = err;
- return exchange;
- };
- await ws.db.mutate(Stores.exchanges, baseUrl, mut);
-}
-
-/**
- * Fetch the exchange's /keys and update our database accordingly.
- *
- * Exceptions thrown in this method must be caught and reported
- * in the pending operations.
- */
-async function updateExchangeWithKeys(
- ws: InternalWalletState,
- baseUrl: string,
-): Promise<void> {
- const existingExchangeRecord = await ws.db.get(Stores.exchanges, baseUrl);
-
- if (existingExchangeRecord?.updateStatus != ExchangeUpdateStatus.FetchKeys) {
- return;
- }
-
- const keysUrl = new URL("keys", baseUrl);
- keysUrl.searchParams.set("cacheBreaker", WALLET_CACHE_BREAKER_CLIENT_VERSION);
-
- const resp = await ws.http.get(keysUrl.href);
- const exchangeKeysJson = await readSuccessResponseJsonOrThrow(
- resp,
- codecForExchangeKeysJson(),
- );
-
- if (exchangeKeysJson.denoms.length === 0) {
- const opErr = makeErrorDetails(
- TalerErrorCode.WALLET_EXCHANGE_DENOMINATIONS_INSUFFICIENT,
- "exchange doesn't offer any denominations",
- {
- exchangeBaseUrl: baseUrl,
- },
- );
- await setExchangeError(ws, baseUrl, opErr);
- throw new OperationFailedAndReportedError(opErr);
- }
-
- const protocolVersion = exchangeKeysJson.version;
-
- const versionRes = compare(WALLET_EXCHANGE_PROTOCOL_VERSION, protocolVersion);
- if (versionRes?.compatible != true) {
- const opErr = makeErrorDetails(
- TalerErrorCode.WALLET_EXCHANGE_PROTOCOL_VERSION_INCOMPATIBLE,
- "exchange protocol version not compatible with wallet",
- {
- exchangeProtocolVersion: protocolVersion,
- walletProtocolVersion: WALLET_EXCHANGE_PROTOCOL_VERSION,
- },
- );
- await setExchangeError(ws, baseUrl, opErr);
- throw new OperationFailedAndReportedError(opErr);
- }
-
- const currency = Amounts.parseOrThrow(exchangeKeysJson.denoms[0].value)
- .currency;
-
- const newDenominations = await Promise.all(
- exchangeKeysJson.denoms.map((d) =>
- denominationRecordFromKeys(ws, baseUrl, d),
- ),
- );
-
- const lastUpdateTimestamp = getTimestampNow();
-
- const recoupGroupId: string | undefined = undefined;
-
- await ws.db.runWithWriteTransaction(
- [Stores.exchanges, Stores.denominations, Stores.recoupGroups, Stores.coins],
- async (tx) => {
- const r = await tx.get(Stores.exchanges, baseUrl);
- if (!r) {
- console.warn(`exchange ${baseUrl} no longer present`);
- return;
- }
- if (r.details) {
- // FIXME: We need to do some consistency checks!
- }
- // FIXME: validate signing keys and merge with old set
- r.details = {
- auditors: exchangeKeysJson.auditors,
- currency: currency,
- lastUpdateTime: lastUpdateTimestamp,
- masterPublicKey: exchangeKeysJson.master_public_key,
- protocolVersion: protocolVersion,
- signingKeys: exchangeKeysJson.signkeys,
- };
- r.updateStatus = ExchangeUpdateStatus.FetchWire;
- r.lastError = undefined;
- await tx.put(Stores.exchanges, r);
-
- for (const newDenom of newDenominations) {
- const oldDenom = await tx.get(Stores.denominations, [
- baseUrl,
- newDenom.denomPub,
- ]);
- if (oldDenom) {
- // FIXME: Do consistency check
- } else {
- await tx.put(Stores.denominations, newDenom);
- }
- }
-
- // Handle recoup
- const recoupDenomList = exchangeKeysJson.recoup ?? [];
- const newlyRevokedCoinPubs: string[] = [];
- logger.trace("recoup list from exchange", recoupDenomList);
- for (const recoupInfo of recoupDenomList) {
- const oldDenom = await tx.getIndexed(
- Stores.denominations.denomPubHashIndex,
- recoupInfo.h_denom_pub,
- );
- if (!oldDenom) {
- // We never even knew about the revoked denomination, all good.
- continue;
- }
- if (oldDenom.isRevoked) {
- // We already marked the denomination as revoked,
- // this implies we revoked all coins
- console.log("denom already revoked");
- continue;
- }
- console.log("revoking denom", recoupInfo.h_denom_pub);
- oldDenom.isRevoked = true;
- await tx.put(Stores.denominations, oldDenom);
- const affectedCoins = await tx
- .iterIndexed(Stores.coins.denomPubHashIndex, recoupInfo.h_denom_pub)
- .toArray();
- for (const ac of affectedCoins) {
- newlyRevokedCoinPubs.push(ac.coinPub);
- }
- }
- if (newlyRevokedCoinPubs.length != 0) {
- console.log("recouping coins", newlyRevokedCoinPubs);
- await createRecoupGroup(ws, tx, newlyRevokedCoinPubs);
- }
- },
- );
-
- if (recoupGroupId) {
- // Asynchronously start recoup. This doesn't need to finish
- // for the exchange update to be considered finished.
- processRecoupGroup(ws, recoupGroupId).catch((e) => {
- console.log("error while recouping coins:", e);
- });
- }
-}
-
-async function updateExchangeFinalize(
- ws: InternalWalletState,
- exchangeBaseUrl: string,
-): Promise<void> {
- const exchange = await ws.db.get(Stores.exchanges, exchangeBaseUrl);
- if (!exchange) {
- return;
- }
- if (exchange.updateStatus != ExchangeUpdateStatus.FinalizeUpdate) {
- return;
- }
- await ws.db.runWithWriteTransaction(
- [Stores.exchanges, Stores.exchangeUpdatedEvents],
- async (tx) => {
- const r = await tx.get(Stores.exchanges, exchangeBaseUrl);
- if (!r) {
- return;
- }
- if (r.updateStatus != ExchangeUpdateStatus.FinalizeUpdate) {
- return;
- }
- r.addComplete = true;
- r.updateStatus = ExchangeUpdateStatus.Finished;
- await tx.put(Stores.exchanges, r);
- const updateEvent: ExchangeUpdatedEventRecord = {
- exchangeBaseUrl: exchange.baseUrl,
- timestamp: getTimestampNow(),
- };
- await tx.put(Stores.exchangeUpdatedEvents, updateEvent);
- },
- );
-}
-
-async function updateExchangeWithTermsOfService(
- ws: InternalWalletState,
- exchangeBaseUrl: string,
-): Promise<void> {
- const exchange = await ws.db.get(Stores.exchanges, exchangeBaseUrl);
- if (!exchange) {
- return;
- }
- if (exchange.updateStatus != ExchangeUpdateStatus.FetchTerms) {
- return;
- }
- const reqUrl = new URL("terms", exchangeBaseUrl);
- reqUrl.searchParams.set("cacheBreaker", WALLET_CACHE_BREAKER_CLIENT_VERSION);
- const headers = {
- Accept: "text/plain",
- };
-
- const resp = await ws.http.get(reqUrl.href, { headers });
- const tosText = await readSuccessResponseTextOrThrow(resp);
- const tosEtag = resp.headers.get("etag") || undefined;
-
- await ws.db.runWithWriteTransaction([Stores.exchanges], async (tx) => {
- const r = await tx.get(Stores.exchanges, exchangeBaseUrl);
- if (!r) {
- return;
- }
- if (r.updateStatus != ExchangeUpdateStatus.FetchTerms) {
- return;
- }
- r.termsOfServiceText = tosText;
- r.termsOfServiceLastEtag = tosEtag;
- r.updateStatus = ExchangeUpdateStatus.FinalizeUpdate;
- await tx.put(Stores.exchanges, r);
- });
-}
-
-export async function acceptExchangeTermsOfService(
- ws: InternalWalletState,
- exchangeBaseUrl: string,
- etag: string | undefined,
-): Promise<void> {
- await ws.db.runWithWriteTransaction([Stores.exchanges], async (tx) => {
- const r = await tx.get(Stores.exchanges, exchangeBaseUrl);
- if (!r) {
- return;
- }
- r.termsOfServiceAcceptedEtag = etag;
- r.termsOfServiceAcceptedTimestamp = getTimestampNow();
- await tx.put(Stores.exchanges, r);
- });
-}
-
-/**
- * Fetch wire information for an exchange and store it in the database.
- *
- * @param exchangeBaseUrl Exchange base URL, assumed to be already normalized.
- */
-async function updateExchangeWithWireInfo(
- ws: InternalWalletState,
- exchangeBaseUrl: string,
-): Promise<void> {
- const exchange = await ws.db.get(Stores.exchanges, exchangeBaseUrl);
- if (!exchange) {
- return;
- }
- if (exchange.updateStatus != ExchangeUpdateStatus.FetchWire) {
- return;
- }
- const details = exchange.details;
- if (!details) {
- throw Error("invalid exchange state");
- }
- const reqUrl = new URL("wire", exchangeBaseUrl);
- reqUrl.searchParams.set("cacheBreaker", WALLET_CACHE_BREAKER_CLIENT_VERSION);
-
- const resp = await ws.http.get(reqUrl.href);
- const wireInfo = await readSuccessResponseJsonOrThrow(
- resp,
- codecForExchangeWireJson(),
- );
-
- for (const a of wireInfo.accounts) {
- logger.trace("validating exchange acct");
- const isValid = await ws.cryptoApi.isValidWireAccount(
- a.payto_uri,
- a.master_sig,
- details.masterPublicKey,
- );
- if (!isValid) {
- throw Error("exchange acct signature invalid");
- }
- }
- const feesForType: { [wireMethod: string]: WireFee[] } = {};
- for (const wireMethod of Object.keys(wireInfo.fees)) {
- const feeList: WireFee[] = [];
- for (const x of wireInfo.fees[wireMethod]) {
- const startStamp = x.start_date;
- const endStamp = x.end_date;
- const fee: WireFee = {
- closingFee: Amounts.parseOrThrow(x.closing_fee),
- endStamp,
- sig: x.sig,
- startStamp,
- wireFee: Amounts.parseOrThrow(x.wire_fee),
- };
- const isValid = await ws.cryptoApi.isValidWireFee(
- wireMethod,
- fee,
- details.masterPublicKey,
- );
- if (!isValid) {
- throw Error("exchange wire fee signature invalid");
- }
- feeList.push(fee);
- }
- feesForType[wireMethod] = feeList;
- }
-
- await ws.db.runWithWriteTransaction([Stores.exchanges], async (tx) => {
- const r = await tx.get(Stores.exchanges, exchangeBaseUrl);
- if (!r) {
- return;
- }
- if (r.updateStatus != ExchangeUpdateStatus.FetchWire) {
- return;
- }
- r.wireInfo = {
- accounts: wireInfo.accounts,
- feesForType: feesForType,
- };
- r.updateStatus = ExchangeUpdateStatus.FetchTerms;
- r.lastError = undefined;
- await tx.put(Stores.exchanges, r);
- });
-}
-
-export async function updateExchangeFromUrl(
- ws: InternalWalletState,
- baseUrl: string,
- forceNow = false,
-): Promise<ExchangeRecord> {
- const onOpErr = (e: OperationErrorDetails): Promise<void> =>
- setExchangeError(ws, baseUrl, e);
- return await guardOperationException(
- () => updateExchangeFromUrlImpl(ws, baseUrl, forceNow),
- onOpErr,
- );
-}
-
-/**
- * Update or add exchange DB entry by fetching the /keys and /wire information.
- * Optionally link the reserve entry to the new or existing
- * exchange entry in then DB.
- */
-async function updateExchangeFromUrlImpl(
- ws: InternalWalletState,
- baseUrl: string,
- forceNow = false,
-): Promise<ExchangeRecord> {
- const now = getTimestampNow();
- baseUrl = canonicalizeBaseUrl(baseUrl);
-
- const r = await ws.db.get(Stores.exchanges, baseUrl);
- if (!r) {
- const newExchangeRecord: ExchangeRecord = {
- builtIn: false,
- addComplete: false,
- permanent: true,
- baseUrl: baseUrl,
- details: undefined,
- wireInfo: undefined,
- updateStatus: ExchangeUpdateStatus.FetchKeys,
- updateStarted: now,
- updateReason: ExchangeUpdateReason.Initial,
- timestampAdded: getTimestampNow(),
- termsOfServiceAcceptedEtag: undefined,
- termsOfServiceAcceptedTimestamp: undefined,
- termsOfServiceLastEtag: undefined,
- termsOfServiceText: undefined,
- updateDiff: undefined,
- };
- await ws.db.put(Stores.exchanges, newExchangeRecord);
- } else {
- await ws.db.runWithWriteTransaction([Stores.exchanges], async (t) => {
- const rec = await t.get(Stores.exchanges, baseUrl);
- if (!rec) {
- return;
- }
- if (rec.updateStatus != ExchangeUpdateStatus.FetchKeys && !forceNow) {
- return;
- }
- if (rec.updateStatus != ExchangeUpdateStatus.FetchKeys && forceNow) {
- rec.updateReason = ExchangeUpdateReason.Forced;
- }
- rec.updateStarted = now;
- rec.updateStatus = ExchangeUpdateStatus.FetchKeys;
- rec.lastError = undefined;
- t.put(Stores.exchanges, rec);
- });
- }
-
- await updateExchangeWithKeys(ws, baseUrl);
- await updateExchangeWithWireInfo(ws, baseUrl);
- await updateExchangeWithTermsOfService(ws, baseUrl);
- await updateExchangeFinalize(ws, baseUrl);
-
- const updatedExchange = await ws.db.get(Stores.exchanges, baseUrl);
-
- if (!updatedExchange) {
- // This should practically never happen
- throw Error("exchange not found");
- }
- return updatedExchange;
-}
-
-/**
- * Check if and how an exchange is trusted and/or audited.
- */
-export async function getExchangeTrust(
- ws: InternalWalletState,
- exchangeInfo: ExchangeRecord,
-): Promise<{ isTrusted: boolean; isAudited: boolean }> {
- let isTrusted = false;
- let isAudited = false;
- const exchangeDetails = exchangeInfo.details;
- if (!exchangeDetails) {
- throw Error(`exchange ${exchangeInfo.baseUrl} details not available`);
- }
- const currencyRecord = await ws.db.get(
- Stores.currencies,
- exchangeDetails.currency,
- );
- if (currencyRecord) {
- for (const trustedExchange of currencyRecord.exchanges) {
- if (trustedExchange.exchangePub === exchangeDetails.masterPublicKey) {
- isTrusted = true;
- break;
- }
- }
- for (const trustedAuditor of currencyRecord.auditors) {
- for (const exchangeAuditor of exchangeDetails.auditors) {
- if (trustedAuditor.auditorPub === exchangeAuditor.auditor_pub) {
- isAudited = true;
- break;
- }
- }
- }
- }
- return { isTrusted, isAudited };
-}
-
-export async function getExchangePaytoUri(
- ws: InternalWalletState,
- exchangeBaseUrl: string,
- supportedTargetTypes: string[],
-): Promise<string> {
- // We do the update here, since the exchange might not even exist
- // yet in our database.
- const exchangeRecord = await updateExchangeFromUrl(ws, exchangeBaseUrl);
- if (!exchangeRecord) {
- throw Error(`Exchange '${exchangeBaseUrl}' not found.`);
- }
- const exchangeWireInfo = exchangeRecord.wireInfo;
- if (!exchangeWireInfo) {
- throw Error(`Exchange wire info for '${exchangeBaseUrl}' not found.`);
- }
- for (const account of exchangeWireInfo.accounts) {
- const res = parsePaytoUri(account.payto_uri);
- if (!res) {
- continue;
- }
- if (supportedTargetTypes.includes(res.targetType)) {
- return account.payto_uri;
- }
- }
- throw Error("no matching exchange account found");
-}
diff --git a/src/operations/pay.ts b/src/operations/pay.ts
deleted file mode 100644
index 9cbda5ba5..000000000
--- a/src/operations/pay.ts
+++ /dev/null
@@ -1,1147 +0,0 @@
-/*
- This file is part of GNU Taler
- (C) 2019 Taler Systems S.A.
-
- 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 payment operation, including downloading and
- * claiming of proposals.
- *
- * @author Florian Dold
- */
-
-/**
- * Imports.
- */
-import { encodeCrock, getRandomBytes } from "../crypto/talerCrypto";
-import {
- CoinStatus,
- initRetryInfo,
- ProposalRecord,
- ProposalStatus,
- PurchaseRecord,
- Stores,
- updateRetryInfoTimeout,
- PayEventRecord,
- WalletContractData,
-} from "../types/dbTypes";
-import { NotificationType } from "../types/notifications";
-import {
- codecForProposal,
- codecForContractTerms,
- CoinDepositPermission,
- codecForMerchantPayResponse,
-} from "../types/talerTypes";
-import {
- ConfirmPayResult,
- OperationErrorDetails,
- PreparePayResult,
- RefreshReason,
- PreparePayResultType,
-} from "../types/walletTypes";
-import * as Amounts from "../util/amounts";
-import { AmountJson } from "../util/amounts";
-import { Logger } from "../util/logging";
-import { parsePayUri } from "../util/taleruri";
-import { guardOperationException, OperationFailedError } from "./errors";
-import { createRefreshGroup, getTotalRefreshCost } from "./refresh";
-import { InternalWalletState } from "./state";
-import { getTimestampNow, timestampAddDuration } from "../util/time";
-import { strcmp, canonicalJson } from "../util/helpers";
-import { readSuccessResponseJsonOrThrow } from "../util/http";
-import { TalerErrorCode } from "../TalerErrorCode";
-
-/**
- * Logger.
- */
-const logger = new Logger("pay.ts");
-
-/**
- * Result of selecting coins, contains the exchange, and selected
- * coins with their denomination.
- */
-export interface PayCoinSelection {
- /**
- * Amount requested by the merchant.
- */
- paymentAmount: AmountJson;
-
- /**
- * Public keys of the coins that were selected.
- */
- coinPubs: string[];
-
- /**
- * Amount that each coin contributes.
- */
- coinContributions: AmountJson[];
-
- /**
- * How much of the wire fees is the customer paying?
- */
- customerWireFees: AmountJson;
-
- /**
- * How much of the deposit fees is the customer paying?
- */
- customerDepositFees: AmountJson;
-}
-
-/**
- * Structure to describe a coin that is available to be
- * used in a payment.
- */
-export interface AvailableCoinInfo {
- /**
- * Public key of the coin.
- */
- coinPub: string;
-
- /**
- * Coin's denomination public key.
- */
- denomPub: string;
-
- /**
- * Amount still remaining (typically the full amount,
- * as coins are always refreshed after use.)
- */
- availableAmount: AmountJson;
-
- /**
- * Deposit fee for the coin.
- */
- feeDeposit: AmountJson;
-}
-
-export interface PayCostInfo {
- totalCost: AmountJson;
-}
-
-/**
- * Compute the total cost of a payment to the customer.
- *
- * This includes the amount taken by the merchant, fees (wire/deposit) contributed
- * by the customer, refreshing fees, fees for withdraw-after-refresh and "trimmings"
- * of coins that are too small to spend.
- */
-export async function getTotalPaymentCost(
- ws: InternalWalletState,
- pcs: PayCoinSelection,
-): Promise<PayCostInfo> {
- const costs = [];
- for (let i = 0; i < pcs.coinPubs.length; i++) {
- const coin = await ws.db.get(Stores.coins, pcs.coinPubs[i]);
- if (!coin) {
- throw Error("can't calculate payment cost, coin not found");
- }
- const denom = await ws.db.get(Stores.denominations, [
- coin.exchangeBaseUrl,
- coin.denomPub,
- ]);
- if (!denom) {
- throw Error(
- "can't calculate payment cost, denomination for coin not found",
- );
- }
- const allDenoms = await ws.db
- .iterIndex(
- Stores.denominations.exchangeBaseUrlIndex,
- coin.exchangeBaseUrl,
- )
- .toArray();
- const amountLeft = Amounts.sub(denom.value, pcs.coinContributions[i])
- .amount;
- const refreshCost = getTotalRefreshCost(allDenoms, denom, amountLeft);
- costs.push(pcs.coinContributions[i]);
- costs.push(refreshCost);
- }
- return {
- totalCost: Amounts.sum(costs).amount,
- };
-}
-
-/**
- * Given a list of available coins, select coins to spend under the merchant's
- * constraints.
- *
- * This function is only exported for the sake of unit tests.
- */
-export function selectPayCoins(
- acis: AvailableCoinInfo[],
- contractTermsAmount: AmountJson,
- customerWireFees: AmountJson,
- depositFeeLimit: AmountJson,
-): PayCoinSelection | undefined {
- if (acis.length === 0) {
- return undefined;
- }
- const coinPubs: string[] = [];
- const coinContributions: AmountJson[] = [];
- // Sort by available amount (descending), deposit fee (ascending) and
- // denomPub (ascending) if deposit fee is the same
- // (to guarantee deterministic results)
- acis.sort(
- (o1, o2) =>
- -Amounts.cmp(o1.availableAmount, o2.availableAmount) ||
- Amounts.cmp(o1.feeDeposit, o2.feeDeposit) ||
- strcmp(o1.denomPub, o2.denomPub),
- );
- const paymentAmount = Amounts.add(contractTermsAmount, customerWireFees)
- .amount;
- const currency = paymentAmount.currency;
- let amountPayRemaining = paymentAmount;
- let amountDepositFeeLimitRemaining = depositFeeLimit;
- const customerDepositFees = Amounts.getZero(currency);
- for (const aci of acis) {
- // Don't use this coin if depositing it is more expensive than
- // the amount it would give the merchant.
- if (Amounts.cmp(aci.feeDeposit, aci.availableAmount) >= 0) {
- continue;
- }
- if (amountPayRemaining.value === 0 && amountPayRemaining.fraction === 0) {
- // We have spent enough!
- break;
- }
-
- // How much does the user spend on deposit fees for this coin?
- const depositFeeSpend = Amounts.sub(
- aci.feeDeposit,
- amountDepositFeeLimitRemaining,
- ).amount;
-
- if (Amounts.isZero(depositFeeSpend)) {
- // Fees are still covered by the merchant.
- amountDepositFeeLimitRemaining = Amounts.sub(
- amountDepositFeeLimitRemaining,
- aci.feeDeposit,
- ).amount;
- } else {
- amountDepositFeeLimitRemaining = Amounts.getZero(currency);
- }
-
- let coinSpend: AmountJson;
- const amountActualAvailable = Amounts.sub(
- aci.availableAmount,
- depositFeeSpend,
- ).amount;
-
- if (Amounts.cmp(amountActualAvailable, amountPayRemaining) > 0) {
- // Partial spending, as the coin is worth more than the remaining
- // amount to pay.
- coinSpend = Amounts.add(amountPayRemaining, depositFeeSpend).amount;
- // Make sure we contribute at least the deposit fee, otherwise
- // contributing this coin would cause a loss for the merchant.
- if (Amounts.cmp(coinSpend, aci.feeDeposit) < 0) {
- coinSpend = aci.feeDeposit;
- }
- amountPayRemaining = Amounts.getZero(currency);
- } else {
- // Spend the full remaining amount on the coin
- coinSpend = aci.availableAmount;
- amountPayRemaining = Amounts.add(amountPayRemaining, depositFeeSpend)
- .amount;
- amountPayRemaining = Amounts.sub(amountPayRemaining, aci.availableAmount)
- .amount;
- }
-
- coinPubs.push(aci.coinPub);
- coinContributions.push(coinSpend);
- }
- if (Amounts.isZero(amountPayRemaining)) {
- return {
- paymentAmount: contractTermsAmount,
- coinContributions,
- coinPubs,
- customerDepositFees,
- customerWireFees,
- };
- }
- return undefined;
-}
-
-/**
- * Select coins from the wallet's database that can be used
- * to pay for the given contract.
- *
- * If payment is impossible, undefined is returned.
- */
-async function getCoinsForPayment(
- ws: InternalWalletState,
- contractData: WalletContractData,
-): Promise<PayCoinSelection | undefined> {
- const remainingAmount = contractData.amount;
-
- const exchanges = await ws.db.iter(Stores.exchanges).toArray();
-
- for (const exchange of exchanges) {
- let isOkay = false;
- const exchangeDetails = exchange.details;
- if (!exchangeDetails) {
- continue;
- }
- const exchangeFees = exchange.wireInfo;
- if (!exchangeFees) {
- continue;
- }
-
- // is the exchange explicitly allowed?
- for (const allowedExchange of contractData.allowedExchanges) {
- if (allowedExchange.exchangePub === exchangeDetails.masterPublicKey) {
- isOkay = true;
- break;
- }
- }
-
- // is the exchange allowed because of one of its auditors?
- if (!isOkay) {
- for (const allowedAuditor of contractData.allowedAuditors) {
- for (const auditor of exchangeDetails.auditors) {
- if (auditor.auditor_pub === allowedAuditor.auditorPub) {
- isOkay = true;
- break;
- }
- }
- if (isOkay) {
- break;
- }
- }
- }
-
- if (!isOkay) {
- continue;
- }
-
- const coins = await ws.db
- .iterIndex(Stores.coins.exchangeBaseUrlIndex, exchange.baseUrl)
- .toArray();
-
- if (!coins || coins.length === 0) {
- continue;
- }
-
- // Denomination of the first coin, we assume that all other
- // coins have the same currency
- const firstDenom = await ws.db.get(Stores.denominations, [
- exchange.baseUrl,
- coins[0].denomPub,
- ]);
- if (!firstDenom) {
- throw Error("db inconsistent");
- }
- const currency = firstDenom.value.currency;
- const acis: AvailableCoinInfo[] = [];
- for (const coin of coins) {
- const denom = await ws.db.get(Stores.denominations, [
- exchange.baseUrl,
- coin.denomPub,
- ]);
- if (!denom) {
- throw Error("db inconsistent");
- }
- if (denom.value.currency !== currency) {
- console.warn(
- `same pubkey for different currencies at exchange ${exchange.baseUrl}`,
- );
- continue;
- }
- if (coin.suspended) {
- continue;
- }
- if (coin.status !== CoinStatus.Fresh) {
- continue;
- }
- acis.push({
- availableAmount: coin.currentAmount,
- coinPub: coin.coinPub,
- denomPub: coin.denomPub,
- feeDeposit: denom.feeDeposit,
- });
- }
-
- let wireFee: AmountJson | undefined;
- for (const fee of exchangeFees.feesForType[contractData.wireMethod] || []) {
- if (
- fee.startStamp <= contractData.timestamp &&
- fee.endStamp >= contractData.timestamp
- ) {
- wireFee = fee.wireFee;
- break;
- }
- }
-
- let customerWireFee: AmountJson;
-
- if (wireFee) {
- const amortizedWireFee = Amounts.divide(
- wireFee,
- contractData.wireFeeAmortization,
- );
- if (Amounts.cmp(contractData.maxWireFee, amortizedWireFee) < 0) {
- customerWireFee = amortizedWireFee;
- } else {
- customerWireFee = Amounts.getZero(currency);
- }
- } else {
- customerWireFee = Amounts.getZero(currency);
- }
-
- // Try if paying using this exchange works
- const res = selectPayCoins(
- acis,
- remainingAmount,
- customerWireFee,
- contractData.maxDepositFee,
- );
- if (res) {
- return res;
- }
- }
- return undefined;
-}
-
-/**
- * Record all information that is necessary to
- * pay for a proposal in the wallet's database.
- */
-async function recordConfirmPay(
- ws: InternalWalletState,
- proposal: ProposalRecord,
- coinSelection: PayCoinSelection,
- coinDepositPermissions: CoinDepositPermission[],
- sessionIdOverride: string | undefined,
-): Promise<PurchaseRecord> {
- const d = proposal.download;
- if (!d) {
- throw Error("proposal is in invalid state");
- }
- let sessionId;
- if (sessionIdOverride) {
- sessionId = sessionIdOverride;
- } else {
- sessionId = proposal.downloadSessionId;
- }
- logger.trace(`recording payment with session ID ${sessionId}`);
- const payCostInfo = await getTotalPaymentCost(ws, coinSelection);
- const t: PurchaseRecord = {
- abortDone: false,
- abortRequested: false,
- contractTermsRaw: d.contractTermsRaw,
- contractData: d.contractData,
- lastSessionId: sessionId,
- payCoinSelection: coinSelection,
- payCostInfo,
- coinDepositPermissions,
- timestampAccept: getTimestampNow(),
- timestampLastRefundStatus: undefined,
- proposalId: proposal.proposalId,
- lastPayError: undefined,
- lastRefundStatusError: undefined,
- payRetryInfo: initRetryInfo(),
- refundStatusRetryInfo: initRetryInfo(),
- refundStatusRequested: false,
- timestampFirstSuccessfulPay: undefined,
- autoRefundDeadline: undefined,
- paymentSubmitPending: true,
- refunds: {},
- };
-
- await ws.db.runWithWriteTransaction(
- [Stores.coins, Stores.purchases, Stores.proposals, Stores.refreshGroups],
- async (tx) => {
- const p = await tx.get(Stores.proposals, proposal.proposalId);
- if (p) {
- p.proposalStatus = ProposalStatus.ACCEPTED;
- p.lastError = undefined;
- p.retryInfo = initRetryInfo(false);
- await tx.put(Stores.proposals, p);
- }
- await tx.put(Stores.purchases, t);
- for (let i = 0; i < coinSelection.coinPubs.length; i++) {
- const coin = await tx.get(Stores.coins, coinSelection.coinPubs[i]);
- if (!coin) {
- throw Error("coin allocated for payment doesn't exist anymore");
- }
- coin.status = CoinStatus.Dormant;
- const remaining = Amounts.sub(
- coin.currentAmount,
- coinSelection.coinContributions[i],
- );
- if (remaining.saturated) {
- throw Error("not enough remaining balance on coin for payment");
- }
- coin.currentAmount = remaining.amount;
- await tx.put(Stores.coins, coin);
- }
- const refreshCoinPubs = coinSelection.coinPubs.map((x) => ({
- coinPub: x,
- }));
- await createRefreshGroup(ws, tx, refreshCoinPubs, RefreshReason.Pay);
- },
- );
-
- ws.notify({
- type: NotificationType.ProposalAccepted,
- proposalId: proposal.proposalId,
- });
- return t;
-}
-
-function getNextUrl(contractData: WalletContractData): string {
- const f = contractData.fulfillmentUrl;
- if (f.startsWith("http://") || f.startsWith("https://")) {
- const fu = new URL(contractData.fulfillmentUrl);
- fu.searchParams.set("order_id", contractData.orderId);
- return fu.href;
- } else {
- return f;
- }
-}
-
-async function incrementProposalRetry(
- ws: InternalWalletState,
- proposalId: string,
- err: OperationErrorDetails | undefined,
-): Promise<void> {
- await ws.db.runWithWriteTransaction([Stores.proposals], async (tx) => {
- const pr = await tx.get(Stores.proposals, proposalId);
- if (!pr) {
- return;
- }
- if (!pr.retryInfo) {
- return;
- }
- pr.retryInfo.retryCounter++;
- updateRetryInfoTimeout(pr.retryInfo);
- pr.lastError = err;
- await tx.put(Stores.proposals, pr);
- });
- if (err) {
- ws.notify({ type: NotificationType.ProposalOperationError, error: err });
- }
-}
-
-async function incrementPurchasePayRetry(
- ws: InternalWalletState,
- proposalId: string,
- err: OperationErrorDetails | undefined,
-): Promise<void> {
- console.log("incrementing purchase pay retry with error", err);
- await ws.db.runWithWriteTransaction([Stores.purchases], async (tx) => {
- const pr = await tx.get(Stores.purchases, proposalId);
- if (!pr) {
- return;
- }
- if (!pr.payRetryInfo) {
- return;
- }
- pr.payRetryInfo.retryCounter++;
- updateRetryInfoTimeout(pr.payRetryInfo);
- pr.lastPayError = err;
- await tx.put(Stores.purchases, pr);
- });
- if (err) {
- ws.notify({ type: NotificationType.PayOperationError, error: err });
- }
-}
-
-export async function processDownloadProposal(
- ws: InternalWalletState,
- proposalId: string,
- forceNow = false,
-): Promise<void> {
- const onOpErr = (err: OperationErrorDetails): Promise<void> =>
- incrementProposalRetry(ws, proposalId, err);
- await guardOperationException(
- () => processDownloadProposalImpl(ws, proposalId, forceNow),
- onOpErr,
- );
-}
-
-async function resetDownloadProposalRetry(
- ws: InternalWalletState,
- proposalId: string,
-): Promise<void> {
- await ws.db.mutate(Stores.proposals, proposalId, (x) => {
- if (x.retryInfo.active) {
- x.retryInfo = initRetryInfo();
- }
- return x;
- });
-}
-
-async function processDownloadProposalImpl(
- ws: InternalWalletState,
- proposalId: string,
- forceNow: boolean,
-): Promise<void> {
- if (forceNow) {
- await resetDownloadProposalRetry(ws, proposalId);
- }
- const proposal = await ws.db.get(Stores.proposals, proposalId);
- if (!proposal) {
- return;
- }
- if (proposal.proposalStatus != ProposalStatus.DOWNLOADING) {
- return;
- }
-
- const orderClaimUrl = new URL(
- `orders/${proposal.orderId}/claim`,
- proposal.merchantBaseUrl,
- ).href;
- logger.trace("downloading contract from '" + orderClaimUrl + "'");
-
- const requestBody: {
- nonce: string,
- token?: string;
- } = {
- nonce: proposal.noncePub,
- };
- if (proposal.claimToken) {
- requestBody.token = proposal.claimToken;
- }
-
- const resp = await ws.http.postJson(orderClaimUrl, requestBody);
- const proposalResp = await readSuccessResponseJsonOrThrow(
- resp,
- codecForProposal(),
- );
-
- // The proposalResp contains the contract terms as raw JSON,
- // as the coded to parse them doesn't necessarily round-trip.
- // We need this raw JSON to compute the contract terms hash.
-
- const contractTermsHash = await ws.cryptoApi.hashString(
- canonicalJson(proposalResp.contract_terms),
- );
-
- const parsedContractTerms = codecForContractTerms().decode(
- proposalResp.contract_terms,
- );
- const fulfillmentUrl = parsedContractTerms.fulfillment_url;
-
- await ws.db.runWithWriteTransaction(
- [Stores.proposals, Stores.purchases],
- async (tx) => {
- const p = await tx.get(Stores.proposals, proposalId);
- if (!p) {
- return;
- }
- if (p.proposalStatus !== ProposalStatus.DOWNLOADING) {
- return;
- }
- const amount = Amounts.parseOrThrow(parsedContractTerms.amount);
- let maxWireFee: AmountJson;
- if (parsedContractTerms.max_wire_fee) {
- maxWireFee = Amounts.parseOrThrow(parsedContractTerms.max_wire_fee);
- } else {
- maxWireFee = Amounts.getZero(amount.currency);
- }
- p.download = {
- contractData: {
- amount,
- contractTermsHash: contractTermsHash,
- fulfillmentUrl: parsedContractTerms.fulfillment_url,
- merchantBaseUrl: parsedContractTerms.merchant_base_url,
- merchantPub: parsedContractTerms.merchant_pub,
- merchantSig: proposalResp.sig,
- orderId: parsedContractTerms.order_id,
- summary: parsedContractTerms.summary,
- autoRefund: parsedContractTerms.auto_refund,
- maxWireFee,
- payDeadline: parsedContractTerms.pay_deadline,
- refundDeadline: parsedContractTerms.refund_deadline,
- wireFeeAmortization: parsedContractTerms.wire_fee_amortization || 1,
- allowedAuditors: parsedContractTerms.auditors.map((x) => ({
- auditorBaseUrl: x.url,
- auditorPub: x.master_pub,
- })),
- allowedExchanges: parsedContractTerms.exchanges.map((x) => ({
- exchangeBaseUrl: x.url,
- exchangePub: x.master_pub,
- })),
- timestamp: parsedContractTerms.timestamp,
- wireMethod: parsedContractTerms.wire_method,
- wireInfoHash: parsedContractTerms.h_wire,
- maxDepositFee: Amounts.parseOrThrow(parsedContractTerms.max_fee),
- merchant: parsedContractTerms.merchant,
- products: parsedContractTerms.products,
- summaryI18n: parsedContractTerms.summary_i18n,
- },
- contractTermsRaw: JSON.stringify(proposalResp.contract_terms),
- };
- if (
- fulfillmentUrl.startsWith("http://") ||
- fulfillmentUrl.startsWith("https://")
- ) {
- const differentPurchase = await tx.getIndexed(
- Stores.purchases.fulfillmentUrlIndex,
- fulfillmentUrl,
- );
- if (differentPurchase) {
- console.log("repurchase detected");
- p.proposalStatus = ProposalStatus.REPURCHASE;
- p.repurchaseProposalId = differentPurchase.proposalId;
- await tx.put(Stores.proposals, p);
- return;
- }
- }
- p.proposalStatus = ProposalStatus.PROPOSED;
- await tx.put(Stores.proposals, p);
- },
- );
-
- ws.notify({
- type: NotificationType.ProposalDownloaded,
- proposalId: proposal.proposalId,
- });
-}
-
-/**
- * Download a proposal and store it in the database.
- * Returns an id for it to retrieve it later.
- *
- * @param sessionId Current session ID, if the proposal is being
- * downloaded in the context of a session ID.
- */
-async function startDownloadProposal(
- ws: InternalWalletState,
- merchantBaseUrl: string,
- orderId: string,
- sessionId: string | undefined,
- claimToken: string | undefined,
-): Promise<string> {
- const oldProposal = await ws.db.getIndexed(
- Stores.proposals.urlAndOrderIdIndex,
- [merchantBaseUrl, orderId],
- );
- if (oldProposal) {
- await processDownloadProposal(ws, oldProposal.proposalId);
- return oldProposal.proposalId;
- }
-
- const { priv, pub } = await ws.cryptoApi.createEddsaKeypair();
- const proposalId = encodeCrock(getRandomBytes(32));
-
- const proposalRecord: ProposalRecord = {
- download: undefined,
- noncePriv: priv,
- noncePub: pub,
- claimToken,
- timestamp: getTimestampNow(),
- merchantBaseUrl,
- orderId,
- proposalId: proposalId,
- proposalStatus: ProposalStatus.DOWNLOADING,
- repurchaseProposalId: undefined,
- retryInfo: initRetryInfo(),
- lastError: undefined,
- downloadSessionId: sessionId,
- };
-
- await ws.db.runWithWriteTransaction([Stores.proposals], async (tx) => {
- const existingRecord = await tx.getIndexed(
- Stores.proposals.urlAndOrderIdIndex,
- [merchantBaseUrl, orderId],
- );
- if (existingRecord) {
- // Created concurrently
- return;
- }
- await tx.put(Stores.proposals, proposalRecord);
- });
-
- await processDownloadProposal(ws, proposalId);
- return proposalId;
-}
-
-export async function submitPay(
- ws: InternalWalletState,
- proposalId: string,
-): Promise<ConfirmPayResult> {
- const purchase = await ws.db.get(Stores.purchases, proposalId);
- if (!purchase) {
- throw Error("Purchase not found: " + proposalId);
- }
- if (purchase.abortRequested) {
- throw Error("not submitting payment for aborted purchase");
- }
- const sessionId = purchase.lastSessionId;
-
- console.log("paying with session ID", sessionId);
-
- const payUrl = new URL(
- `orders/${purchase.contractData.orderId}/pay`,
- purchase.contractData.merchantBaseUrl,
- ).href;
-
- const reqBody = {
- coins: purchase.coinDepositPermissions,
- session_id: purchase.lastSessionId,
- };
-
- logger.trace("making pay request", JSON.stringify(reqBody, undefined, 2));
-
- const resp = await ws.http.postJson(payUrl, reqBody);
-
- const merchantResp = await readSuccessResponseJsonOrThrow(
- resp,
- codecForMerchantPayResponse(),
- );
-
- logger.trace("got success from pay URL", merchantResp);
-
- const now = getTimestampNow();
-
- const merchantPub = purchase.contractData.merchantPub;
- const valid: boolean = await ws.cryptoApi.isValidPaymentSignature(
- merchantResp.sig,
- purchase.contractData.contractTermsHash,
- merchantPub,
- );
- if (!valid) {
- console.error("merchant payment signature invalid");
- // FIXME: properly display error
- throw Error("merchant payment signature invalid");
- }
- const isFirst = purchase.timestampFirstSuccessfulPay === undefined;
- purchase.timestampFirstSuccessfulPay = now;
- purchase.paymentSubmitPending = false;
- purchase.lastPayError = undefined;
- purchase.payRetryInfo = initRetryInfo(false);
- if (isFirst) {
- const ar = purchase.contractData.autoRefund;
- if (ar) {
- console.log("auto_refund present");
- purchase.refundStatusRequested = true;
- purchase.refundStatusRetryInfo = initRetryInfo();
- purchase.lastRefundStatusError = undefined;
- purchase.autoRefundDeadline = timestampAddDuration(now, ar);
- }
- }
-
- await ws.db.runWithWriteTransaction(
- [Stores.purchases, Stores.payEvents],
- async (tx) => {
- await tx.put(Stores.purchases, purchase);
- const payEvent: PayEventRecord = {
- proposalId,
- sessionId,
- timestamp: now,
- isReplay: !isFirst,
- };
- await tx.put(Stores.payEvents, payEvent);
- },
- );
-
- const nextUrl = getNextUrl(purchase.contractData);
- ws.cachedNextUrl[purchase.contractData.fulfillmentUrl] = {
- nextUrl,
- lastSessionId: sessionId,
- };
-
- return { nextUrl };
-}
-
-/**
- * Check if a payment for the given taler://pay/ URI is possible.
- *
- * If the payment is possible, the signature are already generated but not
- * yet send to the merchant.
- */
-export async function preparePayForUri(
- ws: InternalWalletState,
- talerPayUri: string,
-): Promise<PreparePayResult> {
- const uriResult = parsePayUri(talerPayUri);
-
- if (!uriResult) {
- throw OperationFailedError.fromCode(
- TalerErrorCode.WALLET_INVALID_TALER_PAY_URI,
- `invalid taler://pay URI (${talerPayUri})`,
- {
- talerPayUri,
- },
- );
- }
-
- let proposalId = await startDownloadProposal(
- ws,
- uriResult.merchantBaseUrl,
- uriResult.orderId,
- uriResult.sessionId,
- uriResult.claimToken,
- );
-
- let proposal = await ws.db.get(Stores.proposals, proposalId);
- if (!proposal) {
- throw Error(`could not get proposal ${proposalId}`);
- }
- if (proposal.proposalStatus === ProposalStatus.REPURCHASE) {
- const existingProposalId = proposal.repurchaseProposalId;
- if (!existingProposalId) {
- throw Error("invalid proposal state");
- }
- console.log("using existing purchase for same product");
- proposal = await ws.db.get(Stores.proposals, existingProposalId);
- if (!proposal) {
- throw Error("existing proposal is in wrong state");
- }
- }
- const d = proposal.download;
- if (!d) {
- console.error("bad proposal", proposal);
- throw Error("proposal is in invalid state");
- }
- const contractData = d.contractData;
- const merchantSig = d.contractData.merchantSig;
- if (!merchantSig) {
- throw Error("BUG: proposal is in invalid state");
- }
-
- proposalId = proposal.proposalId;
-
- // First check if we already payed for it.
- const purchase = await ws.db.get(Stores.purchases, proposalId);
-
- if (!purchase) {
- // If not already paid, check if we could pay for it.
- const res = await getCoinsForPayment(ws, contractData);
-
- if (!res) {
- logger.info("not confirming payment, insufficient coins");
- return {
- status: PreparePayResultType.InsufficientBalance,
- contractTerms: JSON.parse(d.contractTermsRaw),
- proposalId: proposal.proposalId,
- };
- }
-
- const costInfo = await getTotalPaymentCost(ws, res);
- logger.trace("costInfo", costInfo);
- logger.trace("coinsForPayment", res);
-
- return {
- status: PreparePayResultType.PaymentPossible,
- contractTerms: JSON.parse(d.contractTermsRaw),
- proposalId: proposal.proposalId,
- amountEffective: Amounts.stringify(costInfo.totalCost),
- amountRaw: Amounts.stringify(res.paymentAmount),
- };
- }
-
- if (purchase.lastSessionId !== uriResult.sessionId) {
- logger.trace(
- "automatically re-submitting payment with different session ID",
- );
- await ws.db.runWithWriteTransaction([Stores.purchases], async (tx) => {
- const p = await tx.get(Stores.purchases, proposalId);
- if (!p) {
- return;
- }
- p.lastSessionId = uriResult.sessionId;
- await tx.put(Stores.purchases, p);
- });
- const r = await submitPay(ws, proposalId);
- return {
- status: PreparePayResultType.AlreadyConfirmed,
- contractTerms: JSON.parse(purchase.contractTermsRaw),
- paid: true,
- nextUrl: r.nextUrl,
- };
- } else if (!purchase.timestampFirstSuccessfulPay) {
- return {
- status: PreparePayResultType.AlreadyConfirmed,
- contractTerms: JSON.parse(purchase.contractTermsRaw),
- paid: false,
- };
- } else if (purchase.paymentSubmitPending) {
- return {
- status: PreparePayResultType.AlreadyConfirmed,
- contractTerms: JSON.parse(purchase.contractTermsRaw),
- paid: false,
- };
- }
- // FIXME: we don't handle aborted payments correctly here.
- throw Error("BUG: invariant violation (purchase status)");
-}
-
-/**
- * Add a contract to the wallet and sign coins, and send them.
- */
-export async function confirmPay(
- ws: InternalWalletState,
- proposalId: string,
- sessionIdOverride: string | undefined,
-): Promise<ConfirmPayResult> {
- logger.trace(
- `executing confirmPay with proposalId ${proposalId} and sessionIdOverride ${sessionIdOverride}`,
- );
- const proposal = await ws.db.get(Stores.proposals, proposalId);
-
- if (!proposal) {
- throw Error(`proposal with id ${proposalId} not found`);
- }
-
- const d = proposal.download;
- if (!d) {
- throw Error("proposal is in invalid state");
- }
-
- let purchase = await ws.db.get(
- Stores.purchases,
- d.contractData.contractTermsHash,
- );
-
- if (purchase) {
- if (
- sessionIdOverride !== undefined &&
- sessionIdOverride != purchase.lastSessionId
- ) {
- logger.trace(`changing session ID to ${sessionIdOverride}`);
- await ws.db.mutate(Stores.purchases, purchase.proposalId, (x) => {
- x.lastSessionId = sessionIdOverride;
- x.paymentSubmitPending = true;
- return x;
- });
- }
- logger.trace("confirmPay: submitting payment for existing purchase");
- return submitPay(ws, proposalId);
- }
-
- logger.trace("confirmPay: purchase record does not exist yet");
-
- const res = await getCoinsForPayment(ws, d.contractData);
-
- logger.trace("coin selection result", res);
-
- if (!res) {
- // Should not happen, since checkPay should be called first
- logger.warn("not confirming payment, insufficient coins");
- throw Error("insufficient balance");
- }
-
- const depositPermissions: CoinDepositPermission[] = [];
- for (let i = 0; i < res.coinPubs.length; i++) {
- const coin = await ws.db.get(Stores.coins, res.coinPubs[i]);
- if (!coin) {
- throw Error("can't pay, allocated coin not found anymore");
- }
- const denom = await ws.db.get(Stores.denominations, [
- coin.exchangeBaseUrl,
- coin.denomPub,
- ]);
- if (!denom) {
- throw Error(
- "can't pay, denomination of allocated coin not found anymore",
- );
- }
- const dp = await ws.cryptoApi.signDepositPermission({
- coinPriv: coin.coinPriv,
- coinPub: coin.coinPub,
- contractTermsHash: d.contractData.contractTermsHash,
- denomPubHash: coin.denomPubHash,
- denomSig: coin.denomSig,
- exchangeBaseUrl: coin.exchangeBaseUrl,
- feeDeposit: denom.feeDeposit,
- merchantPub: d.contractData.merchantPub,
- refundDeadline: d.contractData.refundDeadline,
- spendAmount: res.coinContributions[i],
- timestamp: d.contractData.timestamp,
- wireInfoHash: d.contractData.wireInfoHash,
- });
- depositPermissions.push(dp);
- }
- purchase = await recordConfirmPay(
- ws,
- proposal,
- res,
- depositPermissions,
- sessionIdOverride,
- );
-
- return submitPay(ws, proposalId);
-}
-
-export async function processPurchasePay(
- ws: InternalWalletState,
- proposalId: string,
- forceNow = false,
-): Promise<void> {
- const onOpErr = (e: OperationErrorDetails): Promise<void> =>
- incrementPurchasePayRetry(ws, proposalId, e);
- await guardOperationException(
- () => processPurchasePayImpl(ws, proposalId, forceNow),
- onOpErr,
- );
-}
-
-async function resetPurchasePayRetry(
- ws: InternalWalletState,
- proposalId: string,
-): Promise<void> {
- await ws.db.mutate(Stores.purchases, proposalId, (x) => {
- if (x.payRetryInfo.active) {
- x.payRetryInfo = initRetryInfo();
- }
- return x;
- });
-}
-
-async function processPurchasePayImpl(
- ws: InternalWalletState,
- proposalId: string,
- forceNow: boolean,
-): Promise<void> {
- if (forceNow) {
- await resetPurchasePayRetry(ws, proposalId);
- }
- const purchase = await ws.db.get(Stores.purchases, proposalId);
- if (!purchase) {
- return;
- }
- if (!purchase.paymentSubmitPending) {
- return;
- }
- logger.trace(`processing purchase pay ${proposalId}`);
- await submitPay(ws, proposalId);
-}
-
-export async function refuseProposal(
- ws: InternalWalletState,
- proposalId: string,
-): Promise<void> {
- const success = await ws.db.runWithWriteTransaction(
- [Stores.proposals],
- async (tx) => {
- const proposal = await tx.get(Stores.proposals, proposalId);
- if (!proposal) {
- logger.trace(`proposal ${proposalId} not found, won't refuse proposal`);
- return false;
- }
- if (proposal.proposalStatus !== ProposalStatus.PROPOSED) {
- return false;
- }
- proposal.proposalStatus = ProposalStatus.REFUSED;
- await tx.put(Stores.proposals, proposal);
- return true;
- },
- );
- if (success) {
- ws.notify({
- type: NotificationType.ProposalRefused,
- });
- }
-}
diff --git a/src/operations/pending.ts b/src/operations/pending.ts
deleted file mode 100644
index acad5e634..000000000
--- a/src/operations/pending.ts
+++ /dev/null
@@ -1,458 +0,0 @@
-/*
- This file is part of GNU Taler
- (C) 2019 GNUnet e.V.
-
- 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/>
- */
-
-/**
- * Imports.
- */
-import {
- ExchangeUpdateStatus,
- ProposalStatus,
- ReserveRecordStatus,
- Stores,
-} from "../types/dbTypes";
-import {
- PendingOperationsResponse,
- PendingOperationType,
- ExchangeUpdateOperationStage,
- ReserveType,
-} from "../types/pending";
-import {
- Duration,
- getTimestampNow,
- Timestamp,
- getDurationRemaining,
- durationMin,
-} from "../util/time";
-import { TransactionHandle } from "../util/query";
-import { InternalWalletState } from "./state";
-import { getBalancesInsideTransaction } from "./balance";
-
-function updateRetryDelay(
- oldDelay: Duration,
- now: Timestamp,
- retryTimestamp: Timestamp,
-): Duration {
- const remaining = getDurationRemaining(retryTimestamp, now);
- const nextDelay = durationMin(oldDelay, remaining);
- return nextDelay;
-}
-
-async function gatherExchangePending(
- tx: TransactionHandle,
- now: Timestamp,
- resp: PendingOperationsResponse,
- onlyDue = false,
-): Promise<void> {
- if (onlyDue) {
- // FIXME: exchanges should also be updated regularly
- return;
- }
- await tx.iter(Stores.exchanges).forEach((e) => {
- switch (e.updateStatus) {
- case ExchangeUpdateStatus.Finished:
- if (e.lastError) {
- resp.pendingOperations.push({
- type: PendingOperationType.Bug,
- givesLifeness: false,
- message:
- "Exchange record is in FINISHED state but has lastError set",
- details: {
- exchangeBaseUrl: e.baseUrl,
- },
- });
- }
- if (!e.details) {
- resp.pendingOperations.push({
- type: PendingOperationType.Bug,
- givesLifeness: false,
- message:
- "Exchange record does not have details, but no update in progress.",
- details: {
- exchangeBaseUrl: e.baseUrl,
- },
- });
- }
- if (!e.wireInfo) {
- resp.pendingOperations.push({
- type: PendingOperationType.Bug,
- givesLifeness: false,
- message:
- "Exchange record does not have wire info, but no update in progress.",
- details: {
- exchangeBaseUrl: e.baseUrl,
- },
- });
- }
- break;
- case ExchangeUpdateStatus.FetchKeys:
- resp.pendingOperations.push({
- type: PendingOperationType.ExchangeUpdate,
- givesLifeness: false,
- stage: ExchangeUpdateOperationStage.FetchKeys,
- exchangeBaseUrl: e.baseUrl,
- lastError: e.lastError,
- reason: e.updateReason || "unknown",
- });
- break;
- case ExchangeUpdateStatus.FetchWire:
- resp.pendingOperations.push({
- type: PendingOperationType.ExchangeUpdate,
- givesLifeness: false,
- stage: ExchangeUpdateOperationStage.FetchWire,
- exchangeBaseUrl: e.baseUrl,
- lastError: e.lastError,
- reason: e.updateReason || "unknown",
- });
- break;
- case ExchangeUpdateStatus.FinalizeUpdate:
- resp.pendingOperations.push({
- type: PendingOperationType.ExchangeUpdate,
- givesLifeness: false,
- stage: ExchangeUpdateOperationStage.FinalizeUpdate,
- exchangeBaseUrl: e.baseUrl,
- lastError: e.lastError,
- reason: e.updateReason || "unknown",
- });
- break;
- default:
- resp.pendingOperations.push({
- type: PendingOperationType.Bug,
- givesLifeness: false,
- message: "Unknown exchangeUpdateStatus",
- details: {
- exchangeBaseUrl: e.baseUrl,
- exchangeUpdateStatus: e.updateStatus,
- },
- });
- break;
- }
- });
-}
-
-async function gatherReservePending(
- tx: TransactionHandle,
- now: Timestamp,
- resp: PendingOperationsResponse,
- onlyDue = false,
-): Promise<void> {
- // FIXME: this should be optimized by using an index for "onlyDue==true".
- await tx.iter(Stores.reserves).forEach((reserve) => {
- const reserveType = reserve.bankInfo
- ? ReserveType.TalerBankWithdraw
- : ReserveType.Manual;
- if (!reserve.retryInfo.active) {
- return;
- }
- switch (reserve.reserveStatus) {
- case ReserveRecordStatus.DORMANT:
- // nothing to report as pending
- break;
- case ReserveRecordStatus.WAIT_CONFIRM_BANK:
- case ReserveRecordStatus.WITHDRAWING:
- case ReserveRecordStatus.QUERYING_STATUS:
- case ReserveRecordStatus.REGISTERING_BANK:
- resp.nextRetryDelay = updateRetryDelay(
- resp.nextRetryDelay,
- now,
- reserve.retryInfo.nextRetry,
- );
- if (onlyDue && reserve.retryInfo.nextRetry.t_ms > now.t_ms) {
- return;
- }
- resp.pendingOperations.push({
- type: PendingOperationType.Reserve,
- givesLifeness: true,
- stage: reserve.reserveStatus,
- timestampCreated: reserve.timestampCreated,
- reserveType,
- reservePub: reserve.reservePub,
- retryInfo: reserve.retryInfo,
- });
- break;
- default:
- resp.pendingOperations.push({
- type: PendingOperationType.Bug,
- givesLifeness: false,
- message: "Unknown reserve record status",
- details: {
- reservePub: reserve.reservePub,
- reserveStatus: reserve.reserveStatus,
- },
- });
- break;
- }
- });
-}
-
-async function gatherRefreshPending(
- tx: TransactionHandle,
- now: Timestamp,
- resp: PendingOperationsResponse,
- onlyDue = false,
-): Promise<void> {
- await tx.iter(Stores.refreshGroups).forEach((r) => {
- if (r.timestampFinished) {
- return;
- }
- resp.nextRetryDelay = updateRetryDelay(
- resp.nextRetryDelay,
- now,
- r.retryInfo.nextRetry,
- );
- if (onlyDue && r.retryInfo.nextRetry.t_ms > now.t_ms) {
- return;
- }
-
- resp.pendingOperations.push({
- type: PendingOperationType.Refresh,
- givesLifeness: true,
- refreshGroupId: r.refreshGroupId,
- finishedPerCoin: r.finishedPerCoin,
- retryInfo: r.retryInfo,
- });
- });
-}
-
-async function gatherWithdrawalPending(
- tx: TransactionHandle,
- now: Timestamp,
- resp: PendingOperationsResponse,
- onlyDue = false,
-): Promise<void> {
- await tx.iter(Stores.withdrawalGroups).forEachAsync(async (wsr) => {
- if (wsr.timestampFinish) {
- return;
- }
- resp.nextRetryDelay = updateRetryDelay(
- resp.nextRetryDelay,
- now,
- wsr.retryInfo.nextRetry,
- );
- if (onlyDue && wsr.retryInfo.nextRetry.t_ms > now.t_ms) {
- return;
- }
- let numCoinsWithdrawn = 0;
- let numCoinsTotal = 0;
- await tx
- .iterIndexed(Stores.planchets.byGroup, wsr.withdrawalGroupId)
- .forEach((x) => {
- numCoinsTotal++;
- if (x.withdrawalDone) {
- numCoinsWithdrawn++;
- }
- });
- resp.pendingOperations.push({
- type: PendingOperationType.Withdraw,
- givesLifeness: true,
- numCoinsTotal,
- numCoinsWithdrawn,
- source: wsr.source,
- withdrawalGroupId: wsr.withdrawalGroupId,
- lastError: wsr.lastError,
- });
- });
-}
-
-async function gatherProposalPending(
- tx: TransactionHandle,
- now: Timestamp,
- resp: PendingOperationsResponse,
- onlyDue = false,
-): Promise<void> {
- await tx.iter(Stores.proposals).forEach((proposal) => {
- if (proposal.proposalStatus == ProposalStatus.PROPOSED) {
- if (onlyDue) {
- return;
- }
- const dl = proposal.download;
- if (!dl) {
- resp.pendingOperations.push({
- type: PendingOperationType.Bug,
- message: "proposal is in invalid state",
- details: {},
- givesLifeness: false,
- });
- } else {
- resp.pendingOperations.push({
- type: PendingOperationType.ProposalChoice,
- givesLifeness: false,
- merchantBaseUrl: dl.contractData.merchantBaseUrl,
- proposalId: proposal.proposalId,
- proposalTimestamp: proposal.timestamp,
- });
- }
- } else if (proposal.proposalStatus == ProposalStatus.DOWNLOADING) {
- resp.nextRetryDelay = updateRetryDelay(
- resp.nextRetryDelay,
- now,
- proposal.retryInfo.nextRetry,
- );
- if (onlyDue && proposal.retryInfo.nextRetry.t_ms > now.t_ms) {
- return;
- }
- resp.pendingOperations.push({
- type: PendingOperationType.ProposalDownload,
- givesLifeness: true,
- merchantBaseUrl: proposal.merchantBaseUrl,
- orderId: proposal.orderId,
- proposalId: proposal.proposalId,
- proposalTimestamp: proposal.timestamp,
- lastError: proposal.lastError,
- retryInfo: proposal.retryInfo,
- });
- }
- });
-}
-
-async function gatherTipPending(
- tx: TransactionHandle,
- now: Timestamp,
- resp: PendingOperationsResponse,
- onlyDue = false,
-): Promise<void> {
- await tx.iter(Stores.tips).forEach((tip) => {
- if (tip.pickedUp) {
- return;
- }
- resp.nextRetryDelay = updateRetryDelay(
- resp.nextRetryDelay,
- now,
- tip.retryInfo.nextRetry,
- );
- if (onlyDue && tip.retryInfo.nextRetry.t_ms > now.t_ms) {
- return;
- }
- if (tip.acceptedTimestamp) {
- resp.pendingOperations.push({
- type: PendingOperationType.TipPickup,
- givesLifeness: true,
- merchantBaseUrl: tip.merchantBaseUrl,
- tipId: tip.tipId,
- merchantTipId: tip.merchantTipId,
- });
- }
- });
-}
-
-async function gatherPurchasePending(
- tx: TransactionHandle,
- now: Timestamp,
- resp: PendingOperationsResponse,
- onlyDue = false,
-): Promise<void> {
- await tx.iter(Stores.purchases).forEach((pr) => {
- if (pr.paymentSubmitPending) {
- resp.nextRetryDelay = updateRetryDelay(
- resp.nextRetryDelay,
- now,
- pr.payRetryInfo.nextRetry,
- );
- if (!onlyDue || pr.payRetryInfo.nextRetry.t_ms <= now.t_ms) {
- resp.pendingOperations.push({
- type: PendingOperationType.Pay,
- givesLifeness: true,
- isReplay: false,
- proposalId: pr.proposalId,
- retryInfo: pr.payRetryInfo,
- lastError: pr.lastPayError,
- });
- }
- }
- if (pr.refundStatusRequested) {
- resp.nextRetryDelay = updateRetryDelay(
- resp.nextRetryDelay,
- now,
- pr.refundStatusRetryInfo.nextRetry,
- );
- if (!onlyDue || pr.refundStatusRetryInfo.nextRetry.t_ms <= now.t_ms) {
- resp.pendingOperations.push({
- type: PendingOperationType.RefundQuery,
- givesLifeness: true,
- proposalId: pr.proposalId,
- retryInfo: pr.refundStatusRetryInfo,
- lastError: pr.lastRefundStatusError,
- });
- }
- }
- });
-}
-
-async function gatherRecoupPending(
- tx: TransactionHandle,
- now: Timestamp,
- resp: PendingOperationsResponse,
- onlyDue = false,
-): Promise<void> {
- await tx.iter(Stores.recoupGroups).forEach((rg) => {
- if (rg.timestampFinished) {
- return;
- }
- resp.nextRetryDelay = updateRetryDelay(
- resp.nextRetryDelay,
- now,
- rg.retryInfo.nextRetry,
- );
- if (onlyDue && rg.retryInfo.nextRetry.t_ms > now.t_ms) {
- return;
- }
- resp.pendingOperations.push({
- type: PendingOperationType.Recoup,
- givesLifeness: true,
- recoupGroupId: rg.recoupGroupId,
- retryInfo: rg.retryInfo,
- lastError: rg.lastError,
- });
- });
-}
-
-export async function getPendingOperations(
- ws: InternalWalletState,
- { onlyDue = false } = {},
-): Promise<PendingOperationsResponse> {
- const now = getTimestampNow();
- return await ws.db.runWithReadTransaction(
- [
- Stores.exchanges,
- Stores.reserves,
- Stores.refreshGroups,
- Stores.coins,
- Stores.withdrawalGroups,
- Stores.proposals,
- Stores.tips,
- Stores.purchases,
- Stores.recoupGroups,
- Stores.planchets,
- ],
- async (tx) => {
- const walletBalance = await getBalancesInsideTransaction(ws, tx);
- const resp: PendingOperationsResponse = {
- nextRetryDelay: { d_ms: Number.MAX_SAFE_INTEGER },
- onlyDue: onlyDue,
- walletBalance,
- pendingOperations: [],
- };
- await gatherExchangePending(tx, now, resp, onlyDue);
- await gatherReservePending(tx, now, resp, onlyDue);
- await gatherRefreshPending(tx, now, resp, onlyDue);
- await gatherWithdrawalPending(tx, now, resp, onlyDue);
- await gatherProposalPending(tx, now, resp, onlyDue);
- await gatherTipPending(tx, now, resp, onlyDue);
- await gatherPurchasePending(tx, now, resp, onlyDue);
- await gatherRecoupPending(tx, now, resp, onlyDue);
- return resp;
- },
- );
-}
diff --git a/src/operations/recoup.ts b/src/operations/recoup.ts
deleted file mode 100644
index e5f14c6ee..000000000
--- a/src/operations/recoup.ts
+++ /dev/null
@@ -1,411 +0,0 @@
-/*
- 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 { InternalWalletState } from "./state";
-import {
- Stores,
- CoinStatus,
- CoinSourceType,
- CoinRecord,
- WithdrawCoinSource,
- RefreshCoinSource,
- ReserveRecordStatus,
- RecoupGroupRecord,
- initRetryInfo,
- updateRetryInfoTimeout,
-} from "../types/dbTypes";
-
-import { codecForRecoupConfirmation } from "../types/talerTypes";
-import { NotificationType } from "../types/notifications";
-import { forceQueryReserve } from "./reserves";
-
-import { Amounts } from "../util/amounts";
-import { createRefreshGroup, processRefreshGroup } from "./refresh";
-import { RefreshReason, OperationErrorDetails } from "../types/walletTypes";
-import { TransactionHandle } from "../util/query";
-import { encodeCrock, getRandomBytes } from "../crypto/talerCrypto";
-import { getTimestampNow } from "../util/time";
-import { guardOperationException } from "./errors";
-import { readSuccessResponseJsonOrThrow } from "../util/http";
-
-async function incrementRecoupRetry(
- ws: InternalWalletState,
- recoupGroupId: string,
- err: OperationErrorDetails | undefined,
-): Promise<void> {
- await ws.db.runWithWriteTransaction([Stores.recoupGroups], async (tx) => {
- const r = await tx.get(Stores.recoupGroups, recoupGroupId);
- if (!r) {
- return;
- }
- if (!r.retryInfo) {
- return;
- }
- r.retryInfo.retryCounter++;
- updateRetryInfoTimeout(r.retryInfo);
- r.lastError = err;
- await tx.put(Stores.recoupGroups, r);
- });
- if (err) {
- ws.notify({ type: NotificationType.RecoupOperationError, error: err });
- }
-}
-
-async function putGroupAsFinished(
- ws: InternalWalletState,
- tx: TransactionHandle,
- recoupGroup: RecoupGroupRecord,
- coinIdx: number,
-): Promise<void> {
- if (recoupGroup.timestampFinished) {
- return;
- }
- recoupGroup.recoupFinishedPerCoin[coinIdx] = true;
- let allFinished = true;
- for (const b of recoupGroup.recoupFinishedPerCoin) {
- if (!b) {
- allFinished = false;
- }
- }
- if (allFinished) {
- recoupGroup.timestampFinished = getTimestampNow();
- recoupGroup.retryInfo = initRetryInfo(false);
- recoupGroup.lastError = undefined;
- if (recoupGroup.scheduleRefreshCoins.length > 0) {
- const refreshGroupId = await createRefreshGroup(
- ws,
- tx,
- recoupGroup.scheduleRefreshCoins.map((x) => ({ coinPub: x })),
- RefreshReason.Recoup,
- );
- processRefreshGroup(ws, refreshGroupId.refreshGroupId).then((e) => {
- console.error("error while refreshing after recoup", e);
- });
- }
- }
- await tx.put(Stores.recoupGroups, 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
- await ws.db.runWithWriteTransaction([Stores.recoupGroups], async (tx) => {
- const recoupGroup = await tx.get(Stores.recoupGroups, 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 reserve = await ws.db.get(Stores.reserves, reservePub);
- if (!reserve) {
- // FIXME: We should at least emit some pending operation / warning for this?
- return;
- }
-
- ws.notify({
- type: NotificationType.RecoupStarted,
- });
-
- const recoupRequest = await ws.cryptoApi.createRecoupRequest(coin);
- const reqUrl = new URL(`/coins/${coin.coinPub}/recoup`, coin.exchangeBaseUrl);
- const resp = await ws.http.postJson(reqUrl.href, recoupRequest);
- const recoupConfirmation = await readSuccessResponseJsonOrThrow(
- resp,
- codecForRecoupConfirmation(),
- );
-
- if (recoupConfirmation.reserve_pub !== reservePub) {
- throw Error(`Coin's reserve doesn't match reserve on recoup`);
- }
-
- const exchange = await ws.db.get(Stores.exchanges, coin.exchangeBaseUrl);
- if (!exchange) {
- // FIXME: report inconsistency?
- return;
- }
- const exchangeDetails = exchange.details;
- if (!exchangeDetails) {
- // FIXME: report inconsistency?
- return;
- }
-
- // FIXME: verify that our expectations about the amount match
-
- await ws.db.runWithWriteTransaction(
- [Stores.coins, Stores.reserves, Stores.recoupGroups],
- async (tx) => {
- const recoupGroup = await tx.get(Stores.recoupGroups, recoupGroupId);
- if (!recoupGroup) {
- return;
- }
- if (recoupGroup.recoupFinishedPerCoin[coinIdx]) {
- return;
- }
- const updatedCoin = await tx.get(Stores.coins, coin.coinPub);
- if (!updatedCoin) {
- return;
- }
- const updatedReserve = await tx.get(Stores.reserves, reserve.reservePub);
- if (!updatedReserve) {
- return;
- }
- updatedCoin.status = CoinStatus.Dormant;
- const currency = updatedCoin.currentAmount.currency;
- updatedCoin.currentAmount = Amounts.getZero(currency);
- updatedReserve.reserveStatus = ReserveRecordStatus.QUERYING_STATUS;
- await tx.put(Stores.coins, updatedCoin);
- await tx.put(Stores.reserves, updatedReserve);
- await putGroupAsFinished(ws, tx, recoupGroup, coinIdx);
- },
- );
-
- ws.notify({
- type: NotificationType.RecoupFinished,
- });
-
- forceQueryReserve(ws, reserve.reservePub).catch((e) => {
- console.log("re-querying reserve after recoup failed:", e);
- });
-}
-
-async function recoupRefreshCoin(
- ws: InternalWalletState,
- recoupGroupId: string,
- coinIdx: number,
- coin: CoinRecord,
- cs: RefreshCoinSource,
-): Promise<void> {
- ws.notify({
- type: NotificationType.RecoupStarted,
- });
-
- const recoupRequest = await ws.cryptoApi.createRecoupRequest(coin);
- const reqUrl = new URL(`/coins/${coin.coinPub}/recoup`, coin.exchangeBaseUrl);
- console.log("making recoup request");
-
- 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`);
- }
-
- const exchange = await ws.db.get(Stores.exchanges, coin.exchangeBaseUrl);
- if (!exchange) {
- // FIXME: report inconsistency?
- return;
- }
- const exchangeDetails = exchange.details;
- if (!exchangeDetails) {
- // FIXME: report inconsistency?
- return;
- }
-
- await ws.db.runWithWriteTransaction(
- [Stores.coins, Stores.reserves, Stores.recoupGroups, Stores.refreshGroups],
- async (tx) => {
- const recoupGroup = await tx.get(Stores.recoupGroups, recoupGroupId);
- if (!recoupGroup) {
- return;
- }
- if (recoupGroup.recoupFinishedPerCoin[coinIdx]) {
- return;
- }
- const oldCoin = await tx.get(Stores.coins, cs.oldCoinPub);
- const revokedCoin = await tx.get(Stores.coins, coin.coinPub);
- if (!revokedCoin) {
- return;
- }
- if (!oldCoin) {
- return;
- }
- revokedCoin.status = CoinStatus.Dormant;
- oldCoin.currentAmount = Amounts.add(
- oldCoin.currentAmount,
- recoupGroup.oldAmountPerCoin[coinIdx],
- ).amount;
- console.log(
- "recoup: setting old coin amount to",
- Amounts.stringify(oldCoin.currentAmount),
- );
- recoupGroup.scheduleRefreshCoins.push(oldCoin.coinPub);
- await tx.put(Stores.coins, revokedCoin);
- await tx.put(Stores.coins, oldCoin);
- await putGroupAsFinished(ws, tx, recoupGroup, coinIdx);
- },
- );
-}
-
-async function resetRecoupGroupRetry(
- ws: InternalWalletState,
- recoupGroupId: string,
-): Promise<void> {
- await ws.db.mutate(Stores.recoupGroups, recoupGroupId, (x) => {
- if (x.retryInfo.active) {
- x.retryInfo = initRetryInfo();
- }
- return x;
- });
-}
-
-export async function processRecoupGroup(
- ws: InternalWalletState,
- recoupGroupId: string,
- forceNow = false,
-): Promise<void> {
- await ws.memoProcessRecoup.memo(recoupGroupId, async () => {
- const onOpErr = (e: OperationErrorDetails): Promise<void> =>
- incrementRecoupRetry(ws, recoupGroupId, e);
- return await guardOperationException(
- async () => await processRecoupGroupImpl(ws, recoupGroupId, forceNow),
- onOpErr,
- );
- });
-}
-
-async function processRecoupGroupImpl(
- ws: InternalWalletState,
- recoupGroupId: string,
- forceNow = false,
-): Promise<void> {
- if (forceNow) {
- await resetRecoupGroupRetry(ws, recoupGroupId);
- }
- console.log("in processRecoupGroupImpl");
- const recoupGroup = await ws.db.get(Stores.recoupGroups, recoupGroupId);
- if (!recoupGroup) {
- return;
- }
- console.log(recoupGroup);
- if (recoupGroup.timestampFinished) {
- console.log("recoup group finished");
- return;
- }
- const ps = recoupGroup.coinPubs.map((x, i) =>
- processRecoup(ws, recoupGroupId, i),
- );
- await Promise.all(ps);
-}
-
-export async function createRecoupGroup(
- ws: InternalWalletState,
- tx: TransactionHandle,
- coinPubs: string[],
-): Promise<string> {
- const recoupGroupId = encodeCrock(getRandomBytes(32));
-
- const recoupGroup: RecoupGroupRecord = {
- recoupGroupId,
- coinPubs: coinPubs,
- lastError: undefined,
- timestampFinished: undefined,
- timestampStarted: getTimestampNow(),
- retryInfo: initRetryInfo(),
- recoupFinishedPerCoin: coinPubs.map(() => false),
- // Will be populated later
- oldAmountPerCoin: [],
- scheduleRefreshCoins: [],
- };
-
- for (let coinIdx = 0; coinIdx < coinPubs.length; coinIdx++) {
- const coinPub = coinPubs[coinIdx];
- const coin = await tx.get(Stores.coins, coinPub);
- if (!coin) {
- await putGroupAsFinished(ws, tx, recoupGroup, coinIdx);
- continue;
- }
- if (Amounts.isZero(coin.currentAmount)) {
- await putGroupAsFinished(ws, tx, recoupGroup, coinIdx);
- continue;
- }
- recoupGroup.oldAmountPerCoin[coinIdx] = coin.currentAmount;
- coin.currentAmount = Amounts.getZero(coin.currentAmount.currency);
- await tx.put(Stores.coins, coin);
- }
-
- await tx.put(Stores.recoupGroups, recoupGroup);
-
- return recoupGroupId;
-}
-
-async function processRecoup(
- ws: InternalWalletState,
- recoupGroupId: string,
- coinIdx: number,
-): Promise<void> {
- const recoupGroup = await ws.db.get(Stores.recoupGroups, recoupGroupId);
- if (!recoupGroup) {
- return;
- }
- if (recoupGroup.timestampFinished) {
- return;
- }
- if (recoupGroup.recoupFinishedPerCoin[coinIdx]) {
- return;
- }
-
- const coinPub = recoupGroup.coinPubs[coinIdx];
-
- const coin = await ws.db.get(Stores.coins, coinPub);
- if (!coin) {
- throw Error(`Coin ${coinPub} not found, can't request payback`);
- }
-
- 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");
- }
-}
diff --git a/src/operations/refresh.ts b/src/operations/refresh.ts
deleted file mode 100644
index 74b032b91..000000000
--- a/src/operations/refresh.ts
+++ /dev/null
@@ -1,572 +0,0 @@
-/*
- This file is part of GNU Taler
- (C) 2019 GNUnet e.V.
-
- 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/>
- */
-
-import { Amounts, AmountJson } from "../util/amounts";
-import {
- DenominationRecord,
- Stores,
- CoinStatus,
- RefreshPlanchetRecord,
- CoinRecord,
- RefreshSessionRecord,
- initRetryInfo,
- updateRetryInfoTimeout,
- RefreshGroupRecord,
- CoinSourceType,
-} from "../types/dbTypes";
-import { amountToPretty } from "../util/helpers";
-import { TransactionHandle } from "../util/query";
-import { InternalWalletState } from "./state";
-import { Logger } from "../util/logging";
-import { getWithdrawDenomList } from "./withdraw";
-import { updateExchangeFromUrl } from "./exchanges";
-import {
- OperationErrorDetails,
- CoinPublicKey,
- RefreshReason,
- RefreshGroupId,
-} from "../types/walletTypes";
-import { guardOperationException } from "./errors";
-import { NotificationType } from "../types/notifications";
-import { getRandomBytes, encodeCrock } from "../crypto/talerCrypto";
-import { getTimestampNow } from "../util/time";
-import { readSuccessResponseJsonOrThrow } from "../util/http";
-import {
- codecForExchangeMeltResponse,
- codecForExchangeRevealResponse,
-} from "../types/talerTypes";
-
-const logger = new Logger("refresh.ts");
-
-/**
- * Get the amount that we lose when refreshing a coin of the given denomination
- * with a certain amount left.
- *
- * If the amount left is zero, then the refresh cost
- * is also considered to be zero. If a refresh isn't possible (e.g. due to lack of
- * the right denominations), then the cost is the full amount left.
- *
- * Considers refresh fees, withdrawal fees after refresh and amounts too small
- * to refresh.
- */
-export function getTotalRefreshCost(
- denoms: DenominationRecord[],
- refreshedDenom: DenominationRecord,
- amountLeft: AmountJson,
-): AmountJson {
- const withdrawAmount = Amounts.sub(amountLeft, refreshedDenom.feeRefresh)
- .amount;
- const withdrawDenoms = getWithdrawDenomList(withdrawAmount, denoms);
- const resultingAmount = Amounts.add(
- Amounts.getZero(withdrawAmount.currency),
- ...withdrawDenoms.selectedDenoms.map(
- (d) => Amounts.mult(d.denom.value, d.count).amount,
- ),
- ).amount;
- const totalCost = Amounts.sub(amountLeft, resultingAmount).amount;
- logger.trace(
- `total refresh cost for ${amountToPretty(amountLeft)} is ${amountToPretty(
- totalCost,
- )}`,
- );
- return totalCost;
-}
-
-/**
- * Create a refresh session inside a refresh group.
- */
-async function refreshCreateSession(
- ws: InternalWalletState,
- refreshGroupId: string,
- coinIndex: number,
-): Promise<void> {
- logger.trace(
- `creating refresh session for coin ${coinIndex} in refresh group ${refreshGroupId}`,
- );
- const refreshGroup = await ws.db.get(Stores.refreshGroups, refreshGroupId);
- if (!refreshGroup) {
- return;
- }
- if (refreshGroup.finishedPerCoin[coinIndex]) {
- return;
- }
- const existingRefreshSession = refreshGroup.refreshSessionPerCoin[coinIndex];
- if (existingRefreshSession) {
- return;
- }
- const oldCoinPub = refreshGroup.oldCoinPubs[coinIndex];
- const coin = await ws.db.get(Stores.coins, oldCoinPub);
- if (!coin) {
- throw Error("Can't refresh, coin not found");
- }
-
- const exchange = await updateExchangeFromUrl(ws, coin.exchangeBaseUrl);
- if (!exchange) {
- throw Error("db inconsistent: exchange of coin not found");
- }
-
- const oldDenom = await ws.db.get(Stores.denominations, [
- exchange.baseUrl,
- coin.denomPub,
- ]);
-
- if (!oldDenom) {
- throw Error("db inconsistent: denomination for coin not found");
- }
-
- const availableDenoms: DenominationRecord[] = await ws.db
- .iterIndex(Stores.denominations.exchangeBaseUrlIndex, exchange.baseUrl)
- .toArray();
-
- const availableAmount = Amounts.sub(coin.currentAmount, oldDenom.feeRefresh)
- .amount;
-
- const newCoinDenoms = getWithdrawDenomList(availableAmount, availableDenoms);
-
- if (newCoinDenoms.selectedDenoms.length === 0) {
- logger.trace(
- `not refreshing, available amount ${amountToPretty(
- availableAmount,
- )} too small`,
- );
- await ws.db.runWithWriteTransaction(
- [Stores.coins, Stores.refreshGroups],
- async (tx) => {
- const rg = await tx.get(Stores.refreshGroups, refreshGroupId);
- if (!rg) {
- return;
- }
- rg.finishedPerCoin[coinIndex] = true;
- let allDone = true;
- for (const f of rg.finishedPerCoin) {
- if (!f) {
- allDone = false;
- break;
- }
- }
- if (allDone) {
- rg.timestampFinished = getTimestampNow();
- rg.retryInfo = initRetryInfo(false);
- }
- await tx.put(Stores.refreshGroups, rg);
- },
- );
- ws.notify({ type: NotificationType.RefreshUnwarranted });
- return;
- }
-
- const refreshSession: RefreshSessionRecord = await ws.cryptoApi.createRefreshSession(
- exchange.baseUrl,
- 3,
- coin,
- newCoinDenoms,
- oldDenom.feeRefresh,
- );
-
- // Store refresh session and subtract refreshed amount from
- // coin in the same transaction.
- await ws.db.runWithWriteTransaction(
- [Stores.refreshGroups, Stores.coins],
- async (tx) => {
- const c = await tx.get(Stores.coins, coin.coinPub);
- if (!c) {
- throw Error("coin not found, but marked for refresh");
- }
- const r = Amounts.sub(c.currentAmount, refreshSession.amountRefreshInput);
- if (r.saturated) {
- console.log("can't refresh coin, no amount left");
- return;
- }
- c.currentAmount = r.amount;
- c.status = CoinStatus.Dormant;
- const rg = await tx.get(Stores.refreshGroups, refreshGroupId);
- if (!rg) {
- return;
- }
- if (rg.refreshSessionPerCoin[coinIndex]) {
- return;
- }
- rg.refreshSessionPerCoin[coinIndex] = refreshSession;
- await tx.put(Stores.refreshGroups, rg);
- await tx.put(Stores.coins, c);
- },
- );
- logger.info(
- `created refresh session for coin #${coinIndex} in ${refreshGroupId}`,
- );
- ws.notify({ type: NotificationType.RefreshStarted });
-}
-
-async function refreshMelt(
- ws: InternalWalletState,
- refreshGroupId: string,
- coinIndex: number,
-): Promise<void> {
- const refreshGroup = await ws.db.get(Stores.refreshGroups, refreshGroupId);
- if (!refreshGroup) {
- return;
- }
- const refreshSession = refreshGroup.refreshSessionPerCoin[coinIndex];
- if (!refreshSession) {
- return;
- }
- if (refreshSession.norevealIndex !== undefined) {
- return;
- }
-
- const coin = await ws.db.get(Stores.coins, refreshSession.meltCoinPub);
-
- if (!coin) {
- console.error("can't melt coin, it does not exist");
- return;
- }
-
- const reqUrl = new URL(
- `coins/${coin.coinPub}/melt`,
- refreshSession.exchangeBaseUrl,
- );
- const meltReq = {
- coin_pub: coin.coinPub,
- confirm_sig: refreshSession.confirmSig,
- denom_pub_hash: coin.denomPubHash,
- denom_sig: coin.denomSig,
- rc: refreshSession.hash,
- value_with_fee: Amounts.stringify(refreshSession.amountRefreshInput),
- };
- logger.trace(`melt request for coin:`, meltReq);
- const resp = await ws.http.postJson(reqUrl.href, meltReq);
- const meltResponse = await readSuccessResponseJsonOrThrow(
- resp,
- codecForExchangeMeltResponse(),
- );
-
- const norevealIndex = meltResponse.noreveal_index;
-
- refreshSession.norevealIndex = norevealIndex;
-
- await ws.db.mutate(Stores.refreshGroups, refreshGroupId, (rg) => {
- const rs = rg.refreshSessionPerCoin[coinIndex];
- if (!rs) {
- return;
- }
- if (rs.norevealIndex !== undefined) {
- return;
- }
- if (rs.finishedTimestamp) {
- return;
- }
- rs.norevealIndex = norevealIndex;
- return rg;
- });
-
- ws.notify({
- type: NotificationType.RefreshMelted,
- });
-}
-
-async function refreshReveal(
- ws: InternalWalletState,
- refreshGroupId: string,
- coinIndex: number,
-): Promise<void> {
- const refreshGroup = await ws.db.get(Stores.refreshGroups, refreshGroupId);
- if (!refreshGroup) {
- return;
- }
- const refreshSession = refreshGroup.refreshSessionPerCoin[coinIndex];
- if (!refreshSession) {
- return;
- }
- const norevealIndex = refreshSession.norevealIndex;
- if (norevealIndex === undefined) {
- throw Error("can't reveal without melting first");
- }
- const privs = Array.from(refreshSession.transferPrivs);
- privs.splice(norevealIndex, 1);
-
- const planchets = refreshSession.planchetsForGammas[norevealIndex];
- if (!planchets) {
- throw Error("refresh index error");
- }
-
- const meltCoinRecord = await ws.db.get(
- Stores.coins,
- refreshSession.meltCoinPub,
- );
- if (!meltCoinRecord) {
- throw Error("inconsistent database");
- }
-
- const evs = planchets.map((x: RefreshPlanchetRecord) => x.coinEv);
-
- const linkSigs: string[] = [];
- for (let i = 0; i < refreshSession.newDenoms.length; i++) {
- const linkSig = await ws.cryptoApi.signCoinLink(
- meltCoinRecord.coinPriv,
- refreshSession.newDenomHashes[i],
- refreshSession.meltCoinPub,
- refreshSession.transferPubs[norevealIndex],
- planchets[i].coinEv,
- );
- linkSigs.push(linkSig);
- }
-
- const req = {
- coin_evs: evs,
- new_denoms_h: refreshSession.newDenomHashes,
- rc: refreshSession.hash,
- transfer_privs: privs,
- transfer_pub: refreshSession.transferPubs[norevealIndex],
- link_sigs: linkSigs,
- };
-
- const reqUrl = new URL(
- `refreshes/${refreshSession.hash}/reveal`,
- refreshSession.exchangeBaseUrl,
- );
-
- const resp = await ws.http.postJson(reqUrl.href, req);
- const reveal = await readSuccessResponseJsonOrThrow(
- resp,
- codecForExchangeRevealResponse(),
- );
-
- const coins: CoinRecord[] = [];
-
- for (let i = 0; i < reveal.ev_sigs.length; i++) {
- const denom = await ws.db.get(Stores.denominations, [
- refreshSession.exchangeBaseUrl,
- refreshSession.newDenoms[i],
- ]);
- if (!denom) {
- console.error("denom not found");
- continue;
- }
- const pc = refreshSession.planchetsForGammas[norevealIndex][i];
- const denomSig = await ws.cryptoApi.rsaUnblind(
- reveal.ev_sigs[i].ev_sig,
- pc.blindingKey,
- denom.denomPub,
- );
- const coin: CoinRecord = {
- blindingKey: pc.blindingKey,
- coinPriv: pc.privateKey,
- coinPub: pc.publicKey,
- currentAmount: denom.value,
- denomPub: denom.denomPub,
- denomPubHash: denom.denomPubHash,
- denomSig,
- exchangeBaseUrl: refreshSession.exchangeBaseUrl,
- status: CoinStatus.Fresh,
- coinSource: {
- type: CoinSourceType.Refresh,
- oldCoinPub: refreshSession.meltCoinPub,
- },
- suspended: false,
- };
-
- coins.push(coin);
- }
-
- await ws.db.runWithWriteTransaction(
- [Stores.coins, Stores.refreshGroups],
- async (tx) => {
- const rg = await tx.get(Stores.refreshGroups, refreshGroupId);
- if (!rg) {
- console.log("no refresh session found");
- return;
- }
- const rs = rg.refreshSessionPerCoin[coinIndex];
- if (!rs) {
- return;
- }
- if (rs.finishedTimestamp) {
- console.log("refresh session already finished");
- return;
- }
- rs.finishedTimestamp = getTimestampNow();
- rg.finishedPerCoin[coinIndex] = true;
- let allDone = true;
- for (const f of rg.finishedPerCoin) {
- if (!f) {
- allDone = false;
- break;
- }
- }
- if (allDone) {
- rg.timestampFinished = getTimestampNow();
- rg.retryInfo = initRetryInfo(false);
- }
- for (const coin of coins) {
- await tx.put(Stores.coins, coin);
- }
- await tx.put(Stores.refreshGroups, rg);
- },
- );
- console.log("refresh finished (end of reveal)");
- ws.notify({
- type: NotificationType.RefreshRevealed,
- });
-}
-
-async function incrementRefreshRetry(
- ws: InternalWalletState,
- refreshGroupId: string,
- err: OperationErrorDetails | undefined,
-): Promise<void> {
- await ws.db.runWithWriteTransaction([Stores.refreshGroups], async (tx) => {
- const r = await tx.get(Stores.refreshGroups, refreshGroupId);
- if (!r) {
- return;
- }
- if (!r.retryInfo) {
- return;
- }
- r.retryInfo.retryCounter++;
- updateRetryInfoTimeout(r.retryInfo);
- r.lastError = err;
- await tx.put(Stores.refreshGroups, r);
- });
- if (err) {
- ws.notify({ type: NotificationType.RefreshOperationError, error: err });
- }
-}
-
-export async function processRefreshGroup(
- ws: InternalWalletState,
- refreshGroupId: string,
- forceNow = false,
-): Promise<void> {
- await ws.memoProcessRefresh.memo(refreshGroupId, async () => {
- const onOpErr = (e: OperationErrorDetails): Promise<void> =>
- incrementRefreshRetry(ws, refreshGroupId, e);
- return await guardOperationException(
- async () => await processRefreshGroupImpl(ws, refreshGroupId, forceNow),
- onOpErr,
- );
- });
-}
-
-async function resetRefreshGroupRetry(
- ws: InternalWalletState,
- refreshSessionId: string,
-): Promise<void> {
- await ws.db.mutate(Stores.refreshGroups, refreshSessionId, (x) => {
- if (x.retryInfo.active) {
- x.retryInfo = initRetryInfo();
- }
- return x;
- });
-}
-
-async function processRefreshGroupImpl(
- ws: InternalWalletState,
- refreshGroupId: string,
- forceNow: boolean,
-): Promise<void> {
- if (forceNow) {
- await resetRefreshGroupRetry(ws, refreshGroupId);
- }
- const refreshGroup = await ws.db.get(Stores.refreshGroups, refreshGroupId);
- if (!refreshGroup) {
- return;
- }
- if (refreshGroup.timestampFinished) {
- return;
- }
- const ps = refreshGroup.oldCoinPubs.map((x, i) =>
- processRefreshSession(ws, refreshGroupId, i),
- );
- await Promise.all(ps);
- logger.trace("refresh finished");
-}
-
-async function processRefreshSession(
- ws: InternalWalletState,
- refreshGroupId: string,
- coinIndex: number,
-): Promise<void> {
- logger.trace(
- `processing refresh session for coin ${coinIndex} of group ${refreshGroupId}`,
- );
- let refreshGroup = await ws.db.get(Stores.refreshGroups, refreshGroupId);
- if (!refreshGroup) {
- return;
- }
- if (refreshGroup.finishedPerCoin[coinIndex]) {
- return;
- }
- if (!refreshGroup.refreshSessionPerCoin[coinIndex]) {
- await refreshCreateSession(ws, refreshGroupId, coinIndex);
- refreshGroup = await ws.db.get(Stores.refreshGroups, refreshGroupId);
- if (!refreshGroup) {
- return;
- }
- }
- const refreshSession = refreshGroup.refreshSessionPerCoin[coinIndex];
- if (!refreshSession) {
- if (!refreshGroup.finishedPerCoin[coinIndex]) {
- throw Error(
- "BUG: refresh session was not created and coin not marked as finished",
- );
- }
- return;
- }
- if (refreshSession.norevealIndex === undefined) {
- await refreshMelt(ws, refreshGroupId, coinIndex);
- }
- await refreshReveal(ws, refreshGroupId, coinIndex);
-}
-
-/**
- * Create a refresh group for a list of coins.
- */
-export async function createRefreshGroup(
- ws: InternalWalletState,
- tx: TransactionHandle,
- oldCoinPubs: CoinPublicKey[],
- reason: RefreshReason,
-): Promise<RefreshGroupId> {
- const refreshGroupId = encodeCrock(getRandomBytes(32));
-
- const refreshGroup: RefreshGroupRecord = {
- timestampFinished: undefined,
- finishedPerCoin: oldCoinPubs.map((x) => false),
- lastError: undefined,
- lastErrorPerCoin: {},
- oldCoinPubs: oldCoinPubs.map((x) => x.coinPub),
- reason,
- refreshGroupId,
- refreshSessionPerCoin: oldCoinPubs.map((x) => undefined),
- retryInfo: initRetryInfo(),
- };
-
- await tx.put(Stores.refreshGroups, refreshGroup);
-
- const processAsync = async (): Promise<void> => {
- try {
- await processRefreshGroup(ws, refreshGroupId);
- } catch (e) {
- logger.trace(`Error during refresh: ${e}`)
- }
- };
-
- processAsync();
-
- return {
- refreshGroupId,
- };
-}
diff --git a/src/operations/refund.ts b/src/operations/refund.ts
deleted file mode 100644
index 35384c087..000000000
--- a/src/operations/refund.ts
+++ /dev/null
@@ -1,425 +0,0 @@
-/*
- This file is part of GNU Taler
- (C) 2019-2019 Taler Systems S.A.
-
- 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 refund operation.
- *
- * @author Florian Dold
- */
-
-/**
- * Imports.
- */
-import { InternalWalletState } from "./state";
-import {
- OperationErrorDetails,
- RefreshReason,
- CoinPublicKey,
-} from "../types/walletTypes";
-import {
- Stores,
- updateRetryInfoTimeout,
- initRetryInfo,
- CoinStatus,
- RefundReason,
- RefundState,
- PurchaseRecord,
-} from "../types/dbTypes";
-import { NotificationType } from "../types/notifications";
-import { parseRefundUri } from "../util/taleruri";
-import { createRefreshGroup, getTotalRefreshCost } from "./refresh";
-import { Amounts } from "../util/amounts";
-import {
- MerchantCoinRefundStatus,
- MerchantCoinRefundSuccessStatus,
- MerchantCoinRefundFailureStatus,
- codecForMerchantOrderStatusPaid,
-} from "../types/talerTypes";
-import { guardOperationException } from "./errors";
-import { getTimestampNow } from "../util/time";
-import { Logger } from "../util/logging";
-import { readSuccessResponseJsonOrThrow } from "../util/http";
-import { TransactionHandle } from "../util/query";
-
-const logger = new Logger("refund.ts");
-
-/**
- * Retry querying and applying refunds for an order later.
- */
-async function incrementPurchaseQueryRefundRetry(
- ws: InternalWalletState,
- proposalId: string,
- err: OperationErrorDetails | undefined,
-): Promise<void> {
- await ws.db.runWithWriteTransaction([Stores.purchases], async (tx) => {
- const pr = await tx.get(Stores.purchases, proposalId);
- if (!pr) {
- return;
- }
- if (!pr.refundStatusRetryInfo) {
- return;
- }
- pr.refundStatusRetryInfo.retryCounter++;
- updateRetryInfoTimeout(pr.refundStatusRetryInfo);
- pr.lastRefundStatusError = err;
- await tx.put(Stores.purchases, pr);
- });
- if (err) {
- ws.notify({
- type: NotificationType.RefundStatusOperationError,
- error: err,
- });
- }
-}
-
-function getRefundKey(d: MerchantCoinRefundStatus): string {
- return `${d.coin_pub}-${d.rtransaction_id}`;
-}
-
-async function applySuccessfulRefund(
- tx: TransactionHandle,
- p: PurchaseRecord,
- refreshCoinsMap: Record<string, { coinPub: string }>,
- r: MerchantCoinRefundSuccessStatus,
-): Promise<void> {
- // FIXME: check signature before storing it as valid!
-
- const refundKey = getRefundKey(r);
- const coin = await tx.get(Stores.coins, r.coin_pub);
- if (!coin) {
- console.warn("coin not found, can't apply refund");
- return;
- }
- const denom = await tx.getIndexed(
- Stores.denominations.denomPubHashIndex,
- coin.denomPubHash,
- );
- if (!denom) {
- throw Error("inconsistent database");
- }
- refreshCoinsMap[coin.coinPub] = { coinPub: coin.coinPub };
- const refundAmount = Amounts.parseOrThrow(r.refund_amount);
- const refundFee = denom.feeRefund;
- coin.status = CoinStatus.Dormant;
- coin.currentAmount = Amounts.add(coin.currentAmount, refundAmount).amount;
- coin.currentAmount = Amounts.sub(coin.currentAmount, refundFee).amount;
- logger.trace(`coin amount after is ${Amounts.stringify(coin.currentAmount)}`);
- await tx.put(Stores.coins, coin);
-
- const allDenoms = await tx
- .iterIndexed(Stores.denominations.exchangeBaseUrlIndex, coin.exchangeBaseUrl)
- .toArray();
-
- const amountLeft = Amounts.sub(
- Amounts.add(coin.currentAmount, Amounts.parseOrThrow(r.refund_amount))
- .amount,
- denom.feeRefund,
- ).amount;
-
- const totalRefreshCostBound = getTotalRefreshCost(
- allDenoms,
- denom,
- amountLeft,
- );
-
- p.refunds[refundKey] = {
- type: RefundState.Applied,
- executionTime: r.execution_time,
- refundAmount: Amounts.parseOrThrow(r.refund_amount),
- refundFee: denom.feeRefund,
- totalRefreshCostBound,
- };
-}
-
-async function storePendingRefund(
- tx: TransactionHandle,
- p: PurchaseRecord,
- r: MerchantCoinRefundFailureStatus,
-): Promise<void> {
- const refundKey = getRefundKey(r);
-
- const coin = await tx.get(Stores.coins, r.coin_pub);
- if (!coin) {
- console.warn("coin not found, can't apply refund");
- return;
- }
- const denom = await tx.getIndexed(
- Stores.denominations.denomPubHashIndex,
- coin.denomPubHash,
- );
-
- if (!denom) {
- throw Error("inconsistent database");
- }
-
- const allDenoms = await tx
- .iterIndexed(Stores.denominations.exchangeBaseUrlIndex, coin.exchangeBaseUrl)
- .toArray();
-
- const amountLeft = Amounts.sub(
- Amounts.add(coin.currentAmount, Amounts.parseOrThrow(r.refund_amount))
- .amount,
- denom.feeRefund,
- ).amount;
-
- const totalRefreshCostBound = getTotalRefreshCost(
- allDenoms,
- denom,
- amountLeft,
- );
-
- p.refunds[refundKey] = {
- type: RefundState.Pending,
- executionTime: r.execution_time,
- refundAmount: Amounts.parseOrThrow(r.refund_amount),
- refundFee: denom.feeRefund,
- totalRefreshCostBound,
- };
-}
-
-async function acceptRefunds(
- ws: InternalWalletState,
- proposalId: string,
- refunds: MerchantCoinRefundStatus[],
- reason: RefundReason,
-): Promise<void> {
- console.log("handling refunds", refunds);
- const now = getTimestampNow();
-
- await ws.db.runWithWriteTransaction(
- [Stores.purchases, Stores.coins, Stores.denominations, Stores.refreshGroups, Stores.refundEvents],
- async (tx) => {
- const p = await tx.get(Stores.purchases, proposalId);
- if (!p) {
- console.error("purchase not found, not adding refunds");
- return;
- }
-
- const refreshCoinsMap: Record<string, CoinPublicKey> = {};
-
- for (const refundStatus of refunds) {
- const refundKey = getRefundKey(refundStatus);
- const existingRefundInfo = p.refunds[refundKey];
-
- // Already failed.
- if (existingRefundInfo?.type === RefundState.Failed) {
- continue;
- }
-
- // Already applied.
- if (existingRefundInfo?.type === RefundState.Applied) {
- continue;
- }
-
- // Still pending.
- if (
- refundStatus.type === "failure" &&
- existingRefundInfo?.type === RefundState.Pending
- ) {
- continue;
- }
-
- // Invariant: (!existingRefundInfo) || (existingRefundInfo === Pending)
-
- if (refundStatus.type === "success") {
- await applySuccessfulRefund(tx, p, refreshCoinsMap, refundStatus);
- } else {
- await storePendingRefund(tx, p, refundStatus);
- }
- }
-
- const refreshCoinsPubs = Object.values(refreshCoinsMap);
- await createRefreshGroup(ws, tx, refreshCoinsPubs, RefreshReason.Refund);
-
- // Are we done with querying yet, or do we need to do another round
- // after a retry delay?
- let queryDone = true;
-
- if (p.autoRefundDeadline && p.autoRefundDeadline.t_ms > now.t_ms) {
- queryDone = false;
- }
-
- let numPendingRefunds = 0;
- for (const ri of Object.values(p.refunds)) {
- switch (ri.type) {
- case RefundState.Pending:
- numPendingRefunds++;
- break;
- }
- }
-
- if (numPendingRefunds > 0) {
- queryDone = false;
- }
-
- if (queryDone) {
- p.timestampLastRefundStatus = now;
- p.lastRefundStatusError = undefined;
- p.refundStatusRetryInfo = initRetryInfo(false);
- p.refundStatusRequested = false;
- logger.trace("refund query done");
- } else {
- // No error, but we need to try again!
- p.timestampLastRefundStatus = now;
- p.refundStatusRetryInfo.retryCounter++;
- updateRetryInfoTimeout(p.refundStatusRetryInfo);
- p.lastRefundStatusError = undefined;
- logger.trace("refund query not done");
- }
-
- await tx.put(Stores.purchases, p);
- },
- );
-
- ws.notify({
- type: NotificationType.RefundQueried,
- });
-}
-
-async function startRefundQuery(
- ws: InternalWalletState,
- proposalId: string,
-): Promise<void> {
- const success = await ws.db.runWithWriteTransaction(
- [Stores.purchases],
- async (tx) => {
- const p = await tx.get(Stores.purchases, proposalId);
- if (!p) {
- logger.error("no purchase found for refund URL");
- return false;
- }
- p.refundStatusRequested = true;
- p.lastRefundStatusError = undefined;
- p.refundStatusRetryInfo = initRetryInfo();
- await tx.put(Stores.purchases, p);
- return true;
- },
- );
-
- if (!success) {
- return;
- }
-
- ws.notify({
- type: NotificationType.RefundStarted,
- });
-
- await processPurchaseQueryRefund(ws, proposalId);
-}
-
-/**
- * Accept a refund, return the contract hash for the contract
- * that was involved in the refund.
- */
-export async function applyRefund(
- ws: InternalWalletState,
- talerRefundUri: string,
-): Promise<{ contractTermsHash: string; proposalId: string }> {
- const parseResult = parseRefundUri(talerRefundUri);
-
- logger.trace("applying refund", parseResult);
-
- if (!parseResult) {
- throw Error("invalid refund URI");
- }
-
- const purchase = await ws.db.getIndexed(Stores.purchases.orderIdIndex, [
- parseResult.merchantBaseUrl,
- parseResult.orderId,
- ]);
-
- if (!purchase) {
- throw Error(
- `no purchase for the taler://refund/ URI (${talerRefundUri}) was found`,
- );
- }
-
- logger.info("processing purchase for refund");
- await startRefundQuery(ws, purchase.proposalId);
-
- return {
- contractTermsHash: purchase.contractData.contractTermsHash,
- proposalId: purchase.proposalId,
- };
-}
-
-export async function processPurchaseQueryRefund(
- ws: InternalWalletState,
- proposalId: string,
- forceNow = false,
-): Promise<void> {
- const onOpErr = (e: OperationErrorDetails): Promise<void> =>
- incrementPurchaseQueryRefundRetry(ws, proposalId, e);
- await guardOperationException(
- () => processPurchaseQueryRefundImpl(ws, proposalId, forceNow),
- onOpErr,
- );
-}
-
-async function resetPurchaseQueryRefundRetry(
- ws: InternalWalletState,
- proposalId: string,
-): Promise<void> {
- await ws.db.mutate(Stores.purchases, proposalId, (x) => {
- if (x.refundStatusRetryInfo.active) {
- x.refundStatusRetryInfo = initRetryInfo();
- }
- return x;
- });
-}
-
-async function processPurchaseQueryRefundImpl(
- ws: InternalWalletState,
- proposalId: string,
- forceNow: boolean,
-): Promise<void> {
- if (forceNow) {
- await resetPurchaseQueryRefundRetry(ws, proposalId);
- }
- const purchase = await ws.db.get(Stores.purchases, proposalId);
- if (!purchase) {
- return;
- }
-
- if (!purchase.refundStatusRequested) {
- return;
- }
-
- const requestUrl = new URL(
- `orders/${purchase.contractData.orderId}`,
- purchase.contractData.merchantBaseUrl,
- );
- requestUrl.searchParams.set(
- "h_contract",
- purchase.contractData.contractTermsHash,
- );
-
- const request = await ws.http.get(requestUrl.href);
-
- console.log("got json", JSON.stringify(await request.json(), undefined, 2));
-
- const refundResponse = await readSuccessResponseJsonOrThrow(
- request,
- codecForMerchantOrderStatusPaid(),
- );
-
- await acceptRefunds(
- ws,
- proposalId,
- refundResponse.refunds,
- RefundReason.NormalRefund,
- );
-}
diff --git a/src/operations/reserves.ts b/src/operations/reserves.ts
deleted file mode 100644
index 405a02f9e..000000000
--- a/src/operations/reserves.ts
+++ /dev/null
@@ -1,840 +0,0 @@
-/*
- This file is part of GNU Taler
- (C) 2019 GNUnet e.V.
-
- 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/>
- */
-
-import {
- CreateReserveRequest,
- CreateReserveResponse,
- OperationErrorDetails,
- AcceptWithdrawalResponse,
-} from "../types/walletTypes";
-import { canonicalizeBaseUrl } from "../util/helpers";
-import { InternalWalletState } from "./state";
-import {
- ReserveRecordStatus,
- ReserveRecord,
- CurrencyRecord,
- Stores,
- WithdrawalGroupRecord,
- initRetryInfo,
- updateRetryInfoTimeout,
- ReserveUpdatedEventRecord,
- WalletReserveHistoryItemType,
- WithdrawalSourceType,
- ReserveHistoryRecord,
- ReserveBankInfo,
-} from "../types/dbTypes";
-import { Logger } from "../util/logging";
-import { Amounts } from "../util/amounts";
-import {
- updateExchangeFromUrl,
- getExchangeTrust,
- getExchangePaytoUri,
-} from "./exchanges";
-import {
- codecForWithdrawOperationStatusResponse,
- codecForBankWithdrawalOperationPostResponse,
-} from "../types/talerTypes";
-import { assertUnreachable } from "../util/assertUnreachable";
-import { encodeCrock, getRandomBytes } from "../crypto/talerCrypto";
-import { randomBytes } from "../crypto/primitives/nacl-fast";
-import {
- selectWithdrawalDenoms,
- processWithdrawGroup,
- getBankWithdrawalInfo,
- denomSelectionInfoToState,
-} from "./withdraw";
-import {
- guardOperationException,
- OperationFailedAndReportedError,
- makeErrorDetails,
-} from "./errors";
-import { NotificationType } from "../types/notifications";
-import { codecForReserveStatus } from "../types/ReserveStatus";
-import { getTimestampNow } from "../util/time";
-import {
- reconcileReserveHistory,
- summarizeReserveHistory,
-} from "../util/reserveHistoryUtil";
-import { TransactionHandle } from "../util/query";
-import { addPaytoQueryParams } from "../util/payto";
-import { TalerErrorCode } from "../TalerErrorCode";
-import {
- readSuccessResponseJsonOrErrorCode,
- throwUnexpectedRequestError,
- readSuccessResponseJsonOrThrow,
-} from "../util/http";
-import { codecForAny } from "../util/codec";
-
-const logger = new Logger("reserves.ts");
-
-async function resetReserveRetry(
- ws: InternalWalletState,
- reservePub: string,
-): Promise<void> {
- await ws.db.mutate(Stores.reserves, reservePub, (x) => {
- if (x.retryInfo.active) {
- x.retryInfo = initRetryInfo();
- }
- return x;
- });
-}
-
-/**
- * Create a reserve, but do not flag it as confirmed yet.
- *
- * Adds the corresponding exchange as a trusted exchange if it is neither
- * audited nor trusted already.
- */
-export async function createReserve(
- ws: InternalWalletState,
- req: CreateReserveRequest,
-): Promise<CreateReserveResponse> {
- const keypair = await ws.cryptoApi.createEddsaKeypair();
- const now = getTimestampNow();
- const canonExchange = canonicalizeBaseUrl(req.exchange);
-
- let reserveStatus;
- if (req.bankWithdrawStatusUrl) {
- reserveStatus = ReserveRecordStatus.REGISTERING_BANK;
- } else {
- reserveStatus = ReserveRecordStatus.QUERYING_STATUS;
- }
-
- let bankInfo: ReserveBankInfo | undefined;
-
- if (req.bankWithdrawStatusUrl) {
- if (!req.exchangePaytoUri) {
- throw Error(
- "Exchange payto URI must be specified for a bank-integrated withdrawal",
- );
- }
- bankInfo = {
- statusUrl: req.bankWithdrawStatusUrl,
- exchangePaytoUri: req.exchangePaytoUri,
- };
- }
-
- const initialWithdrawalGroupId = encodeCrock(getRandomBytes(32));
-
- const denomSelInfo = await selectWithdrawalDenoms(
- ws,
- canonExchange,
- req.amount,
- );
- const initialDenomSel = denomSelectionInfoToState(denomSelInfo);
-
- const reserveRecord: ReserveRecord = {
- instructedAmount: req.amount,
- initialWithdrawalGroupId,
- initialDenomSel,
- initialWithdrawalStarted: false,
- timestampCreated: now,
- exchangeBaseUrl: canonExchange,
- reservePriv: keypair.priv,
- reservePub: keypair.pub,
- senderWire: req.senderWire,
- timestampBankConfirmed: undefined,
- timestampReserveInfoPosted: undefined,
- bankInfo,
- reserveStatus,
- lastSuccessfulStatusQuery: undefined,
- retryInfo: initRetryInfo(),
- lastError: undefined,
- currency: req.amount.currency,
- };
-
- const reserveHistoryRecord: ReserveHistoryRecord = {
- reservePub: keypair.pub,
- reserveTransactions: [],
- };
-
- reserveHistoryRecord.reserveTransactions.push({
- type: WalletReserveHistoryItemType.Credit,
- expectedAmount: req.amount,
- });
-
- const senderWire = req.senderWire;
- if (senderWire) {
- const rec = {
- paytoUri: senderWire,
- };
- await ws.db.put(Stores.senderWires, rec);
- }
-
- const exchangeInfo = await updateExchangeFromUrl(ws, req.exchange);
- const exchangeDetails = exchangeInfo.details;
- if (!exchangeDetails) {
- console.log(exchangeDetails);
- throw Error("exchange not updated");
- }
- const { isAudited, isTrusted } = await getExchangeTrust(ws, exchangeInfo);
- let currencyRecord = await ws.db.get(
- Stores.currencies,
- exchangeDetails.currency,
- );
- if (!currencyRecord) {
- currencyRecord = {
- auditors: [],
- exchanges: [],
- fractionalDigits: 2,
- name: exchangeDetails.currency,
- };
- }
-
- if (!isAudited && !isTrusted) {
- currencyRecord.exchanges.push({
- baseUrl: req.exchange,
- exchangePub: exchangeDetails.masterPublicKey,
- });
- }
-
- const cr: CurrencyRecord = currencyRecord;
-
- const resp = await ws.db.runWithWriteTransaction(
- [
- Stores.currencies,
- Stores.reserves,
- Stores.reserveHistory,
- Stores.bankWithdrawUris,
- ],
- async (tx) => {
- // Check if we have already created a reserve for that bankWithdrawStatusUrl
- if (reserveRecord.bankInfo?.statusUrl) {
- const bwi = await tx.get(
- Stores.bankWithdrawUris,
- reserveRecord.bankInfo.statusUrl,
- );
- if (bwi) {
- const otherReserve = await tx.get(Stores.reserves, bwi.reservePub);
- if (otherReserve) {
- logger.trace(
- "returning existing reserve for bankWithdrawStatusUri",
- );
- return {
- exchange: otherReserve.exchangeBaseUrl,
- reservePub: otherReserve.reservePub,
- };
- }
- }
- await tx.put(Stores.bankWithdrawUris, {
- reservePub: reserveRecord.reservePub,
- talerWithdrawUri: reserveRecord.bankInfo.statusUrl,
- });
- }
- await tx.put(Stores.currencies, cr);
- await tx.put(Stores.reserves, reserveRecord);
- await tx.put(Stores.reserveHistory, reserveHistoryRecord);
- const r: CreateReserveResponse = {
- exchange: canonExchange,
- reservePub: keypair.pub,
- };
- return r;
- },
- );
-
- if (reserveRecord.reservePub === resp.reservePub) {
- // Only emit notification when a new reserve was created.
- ws.notify({
- type: NotificationType.ReserveCreated,
- reservePub: reserveRecord.reservePub,
- });
- }
-
- // Asynchronously process the reserve, but return
- // to the caller already.
- processReserve(ws, resp.reservePub, true).catch((e) => {
- logger.error("Processing reserve (after createReserve) failed:", e);
- });
-
- return resp;
-}
-
-/**
- * Re-query the status of a reserve.
- */
-export async function forceQueryReserve(
- ws: InternalWalletState,
- reservePub: string,
-): Promise<void> {
- await ws.db.runWithWriteTransaction([Stores.reserves], async (tx) => {
- const reserve = await tx.get(Stores.reserves, reservePub);
- if (!reserve) {
- return;
- }
- // Only force status query where it makes sense
- switch (reserve.reserveStatus) {
- case ReserveRecordStatus.DORMANT:
- case ReserveRecordStatus.WITHDRAWING:
- case ReserveRecordStatus.QUERYING_STATUS:
- break;
- default:
- return;
- }
- reserve.reserveStatus = ReserveRecordStatus.QUERYING_STATUS;
- reserve.retryInfo = initRetryInfo();
- await tx.put(Stores.reserves, reserve);
- });
- await processReserve(ws, reservePub, true);
-}
-
-/**
- * First fetch information requred to withdraw from the reserve,
- * then deplete the reserve, withdrawing coins until it is empty.
- *
- * The returned promise resolves once the reserve is set to the
- * state DORMANT.
- */
-export async function processReserve(
- ws: InternalWalletState,
- reservePub: string,
- forceNow = false,
-): Promise<void> {
- return ws.memoProcessReserve.memo(reservePub, async () => {
- const onOpError = (err: OperationErrorDetails): Promise<void> =>
- incrementReserveRetry(ws, reservePub, err);
- await guardOperationException(
- () => processReserveImpl(ws, reservePub, forceNow),
- onOpError,
- );
- });
-}
-
-async function registerReserveWithBank(
- ws: InternalWalletState,
- reservePub: string,
-): Promise<void> {
- const reserve = await ws.db.get(Stores.reserves, reservePub);
- switch (reserve?.reserveStatus) {
- case ReserveRecordStatus.WAIT_CONFIRM_BANK:
- case ReserveRecordStatus.REGISTERING_BANK:
- break;
- default:
- return;
- }
- const bankInfo = reserve.bankInfo;
- if (!bankInfo) {
- return;
- }
- const bankStatusUrl = bankInfo.statusUrl;
- const httpResp = await ws.http.postJson(bankStatusUrl, {
- reserve_pub: reservePub,
- selected_exchange: bankInfo.exchangePaytoUri,
- });
- await readSuccessResponseJsonOrThrow(
- httpResp,
- codecForBankWithdrawalOperationPostResponse(),
- );
- await ws.db.mutate(Stores.reserves, reservePub, (r) => {
- switch (r.reserveStatus) {
- case ReserveRecordStatus.REGISTERING_BANK:
- case ReserveRecordStatus.WAIT_CONFIRM_BANK:
- break;
- default:
- return;
- }
- r.timestampReserveInfoPosted = getTimestampNow();
- r.reserveStatus = ReserveRecordStatus.WAIT_CONFIRM_BANK;
- if (!r.bankInfo) {
- throw Error("invariant failed");
- }
- r.retryInfo = initRetryInfo();
- return r;
- });
- ws.notify({ type: NotificationType.ReserveRegisteredWithBank });
- return processReserveBankStatus(ws, reservePub);
-}
-
-export async function processReserveBankStatus(
- ws: InternalWalletState,
- reservePub: string,
-): Promise<void> {
- const onOpError = (err: OperationErrorDetails): Promise<void> =>
- incrementReserveRetry(ws, reservePub, err);
- await guardOperationException(
- () => processReserveBankStatusImpl(ws, reservePub),
- onOpError,
- );
-}
-
-async function processReserveBankStatusImpl(
- ws: InternalWalletState,
- reservePub: string,
-): Promise<void> {
- const reserve = await ws.db.get(Stores.reserves, reservePub);
- switch (reserve?.reserveStatus) {
- case ReserveRecordStatus.WAIT_CONFIRM_BANK:
- case ReserveRecordStatus.REGISTERING_BANK:
- break;
- default:
- return;
- }
- const bankStatusUrl = reserve.bankInfo?.statusUrl;
- if (!bankStatusUrl) {
- return;
- }
-
- const statusResp = await ws.http.get(bankStatusUrl);
- const status = await readSuccessResponseJsonOrThrow(
- statusResp,
- codecForWithdrawOperationStatusResponse(),
- );
-
- if (status.selection_done) {
- if (reserve.reserveStatus === ReserveRecordStatus.REGISTERING_BANK) {
- await registerReserveWithBank(ws, reservePub);
- return await processReserveBankStatus(ws, reservePub);
- }
- } else {
- await registerReserveWithBank(ws, reservePub);
- return await processReserveBankStatus(ws, reservePub);
- }
-
- if (status.transfer_done) {
- await ws.db.mutate(Stores.reserves, reservePub, (r) => {
- switch (r.reserveStatus) {
- case ReserveRecordStatus.REGISTERING_BANK:
- case ReserveRecordStatus.WAIT_CONFIRM_BANK:
- break;
- default:
- return;
- }
- const now = getTimestampNow();
- r.timestampBankConfirmed = now;
- r.reserveStatus = ReserveRecordStatus.QUERYING_STATUS;
- r.retryInfo = initRetryInfo();
- return r;
- });
- await processReserveImpl(ws, reservePub, true);
- } else {
- await ws.db.mutate(Stores.reserves, reservePub, (r) => {
- switch (r.reserveStatus) {
- case ReserveRecordStatus.WAIT_CONFIRM_BANK:
- break;
- default:
- return;
- }
- if (r.bankInfo) {
- r.bankInfo.confirmUrl = status.confirm_transfer_url;
- }
- return r;
- });
- await incrementReserveRetry(ws, reservePub, undefined);
- }
-}
-
-async function incrementReserveRetry(
- ws: InternalWalletState,
- reservePub: string,
- err: OperationErrorDetails | undefined,
-): Promise<void> {
- await ws.db.runWithWriteTransaction([Stores.reserves], async (tx) => {
- const r = await tx.get(Stores.reserves, reservePub);
- if (!r) {
- return;
- }
- if (!r.retryInfo) {
- return;
- }
- r.retryInfo.retryCounter++;
- updateRetryInfoTimeout(r.retryInfo);
- r.lastError = err;
- await tx.put(Stores.reserves, r);
- });
- if (err) {
- ws.notify({
- type: NotificationType.ReserveOperationError,
- error: err,
- });
- }
-}
-
-/**
- * Update the information about a reserve that is stored in the wallet
- * by quering the reserve's exchange.
- */
-async function updateReserve(
- ws: InternalWalletState,
- reservePub: string,
-): Promise<{ ready: boolean }> {
- const reserve = await ws.db.get(Stores.reserves, reservePub);
- if (!reserve) {
- throw Error("reserve not in db");
- }
-
- if (reserve.reserveStatus !== ReserveRecordStatus.QUERYING_STATUS) {
- return { ready: true };
- }
-
- const resp = await ws.http.get(
- new URL(`reserves/${reservePub}`, reserve.exchangeBaseUrl).href,
- );
-
- const result = await readSuccessResponseJsonOrErrorCode(
- resp,
- codecForReserveStatus(),
- );
- if (result.isError) {
- if (
- resp.status === 404 &&
- result.talerErrorResponse.code === TalerErrorCode.RESERVE_STATUS_UNKNOWN
- ) {
- ws.notify({
- type: NotificationType.ReserveNotYetFound,
- reservePub,
- });
- await incrementReserveRetry(ws, reservePub, undefined);
- return { ready: false };
- } else {
- throwUnexpectedRequestError(resp, result.talerErrorResponse);
- }
- }
-
- const reserveInfo = result.response;
-
- const balance = Amounts.parseOrThrow(reserveInfo.balance);
- const currency = balance.currency;
- await ws.db.runWithWriteTransaction(
- [Stores.reserves, Stores.reserveUpdatedEvents, Stores.reserveHistory],
- async (tx) => {
- const r = await tx.get(Stores.reserves, reservePub);
- if (!r) {
- return;
- }
- if (r.reserveStatus !== ReserveRecordStatus.QUERYING_STATUS) {
- return;
- }
-
- const hist = await tx.get(Stores.reserveHistory, reservePub);
- if (!hist) {
- throw Error("inconsistent database");
- }
-
- const newHistoryTransactions = reserveInfo.history.slice(
- hist.reserveTransactions.length,
- );
-
- const reserveUpdateId = encodeCrock(getRandomBytes(32));
-
- const reconciled = reconcileReserveHistory(
- hist.reserveTransactions,
- reserveInfo.history,
- );
-
- const summary = summarizeReserveHistory(
- reconciled.updatedLocalHistory,
- currency,
- );
-
- if (
- reconciled.newAddedItems.length + reconciled.newMatchedItems.length !=
- 0
- ) {
- const reserveUpdate: ReserveUpdatedEventRecord = {
- reservePub: r.reservePub,
- timestamp: getTimestampNow(),
- amountReserveBalance: Amounts.stringify(balance),
- amountExpected: Amounts.stringify(summary.awaitedReserveAmount),
- newHistoryTransactions,
- reserveUpdateId,
- };
- await tx.put(Stores.reserveUpdatedEvents, reserveUpdate);
- r.reserveStatus = ReserveRecordStatus.WITHDRAWING;
- r.retryInfo = initRetryInfo();
- } else {
- r.reserveStatus = ReserveRecordStatus.DORMANT;
- r.retryInfo = initRetryInfo(false);
- }
- r.lastSuccessfulStatusQuery = getTimestampNow();
- hist.reserveTransactions = reconciled.updatedLocalHistory;
- r.lastError = undefined;
- await tx.put(Stores.reserves, r);
- await tx.put(Stores.reserveHistory, hist);
- },
- );
- ws.notify({ type: NotificationType.ReserveUpdated });
- return { ready: true };
-}
-
-async function processReserveImpl(
- ws: InternalWalletState,
- reservePub: string,
- forceNow = false,
-): Promise<void> {
- const reserve = await ws.db.get(Stores.reserves, reservePub);
- if (!reserve) {
- console.log("not processing reserve: reserve does not exist");
- return;
- }
- if (!forceNow) {
- const now = getTimestampNow();
- if (reserve.retryInfo.nextRetry.t_ms > now.t_ms) {
- logger.trace("processReserve retry not due yet");
- return;
- }
- } else {
- await resetReserveRetry(ws, reservePub);
- }
- logger.trace(
- `Processing reserve ${reservePub} with status ${reserve.reserveStatus}`,
- );
- switch (reserve.reserveStatus) {
- case ReserveRecordStatus.REGISTERING_BANK:
- await processReserveBankStatus(ws, reservePub);
- return await processReserveImpl(ws, reservePub, true);
- case ReserveRecordStatus.QUERYING_STATUS: {
- const res = await updateReserve(ws, reservePub);
- if (res.ready) {
- return await processReserveImpl(ws, reservePub, true);
- } else {
- break;
- }
- }
- case ReserveRecordStatus.WITHDRAWING:
- await depleteReserve(ws, reservePub);
- break;
- case ReserveRecordStatus.DORMANT:
- // nothing to do
- break;
- case ReserveRecordStatus.WAIT_CONFIRM_BANK:
- await processReserveBankStatus(ws, reservePub);
- break;
- default:
- console.warn("unknown reserve record status:", reserve.reserveStatus);
- assertUnreachable(reserve.reserveStatus);
- break;
- }
-}
-
-/**
- * Withdraw coins from a reserve until it is empty.
- *
- * When finished, marks the reserve as depleted by setting
- * the depleted timestamp.
- */
-async function depleteReserve(
- ws: InternalWalletState,
- reservePub: string,
-): Promise<void> {
- let reserve: ReserveRecord | undefined;
- let hist: ReserveHistoryRecord | undefined;
- await ws.db.runWithReadTransaction(
- [Stores.reserves, Stores.reserveHistory],
- async (tx) => {
- reserve = await tx.get(Stores.reserves, reservePub);
- hist = await tx.get(Stores.reserveHistory, reservePub);
- },
- );
-
- if (!reserve) {
- return;
- }
- if (!hist) {
- throw Error("inconsistent database");
- }
- if (reserve.reserveStatus !== ReserveRecordStatus.WITHDRAWING) {
- return;
- }
- logger.trace(`depleting reserve ${reservePub}`);
-
- const summary = summarizeReserveHistory(
- hist.reserveTransactions,
- reserve.currency,
- );
-
- const withdrawAmount = summary.unclaimedReserveAmount;
-
- const denomsForWithdraw = await selectWithdrawalDenoms(
- ws,
- reserve.exchangeBaseUrl,
- withdrawAmount,
- );
- if (!denomsForWithdraw) {
- // Only complain about inability to withdraw if we
- // didn't withdraw before.
- if (Amounts.isZero(summary.withdrawnAmount)) {
- const opErr = makeErrorDetails(
- TalerErrorCode.WALLET_EXCHANGE_DENOMINATIONS_INSUFFICIENT,
- `Unable to withdraw from reserve, no denominations are available to withdraw.`,
- {},
- );
- await incrementReserveRetry(ws, reserve.reservePub, opErr);
- throw new OperationFailedAndReportedError(opErr);
- }
- return;
- }
-
- logger.trace(
- `Selected coins total cost ${Amounts.stringify(
- denomsForWithdraw.totalWithdrawCost,
- )} for withdrawal of ${Amounts.stringify(withdrawAmount)}`,
- );
-
- logger.trace("selected denominations");
-
- const newWithdrawalGroup = await ws.db.runWithWriteTransaction(
- [
- Stores.withdrawalGroups,
- Stores.reserves,
- Stores.reserveHistory,
- Stores.planchets,
- ],
- async (tx) => {
- const newReserve = await tx.get(Stores.reserves, reservePub);
- if (!newReserve) {
- return false;
- }
- if (newReserve.reserveStatus !== ReserveRecordStatus.WITHDRAWING) {
- return false;
- }
- const newHist = await tx.get(Stores.reserveHistory, reservePub);
- if (!newHist) {
- throw Error("inconsistent database");
- }
- const newSummary = summarizeReserveHistory(
- newHist.reserveTransactions,
- newReserve.currency,
- );
- if (
- Amounts.cmp(
- newSummary.unclaimedReserveAmount,
- denomsForWithdraw.totalWithdrawCost,
- ) < 0
- ) {
- // Something must have happened concurrently!
- logger.error(
- "aborting withdrawal session, likely concurrent withdrawal happened",
- );
- logger.error(
- `unclaimed reserve amount is ${newSummary.unclaimedReserveAmount}`,
- );
- logger.error(
- `withdrawal cost is ${denomsForWithdraw.totalWithdrawCost}`,
- );
- return false;
- }
- for (let i = 0; i < denomsForWithdraw.selectedDenoms.length; i++) {
- const sd = denomsForWithdraw.selectedDenoms[i];
- for (let j = 0; j < sd.count; j++) {
- const amt = Amounts.add(sd.denom.value, sd.denom.feeWithdraw).amount;
- newHist.reserveTransactions.push({
- type: WalletReserveHistoryItemType.Withdraw,
- expectedAmount: amt,
- });
- }
- }
- newReserve.reserveStatus = ReserveRecordStatus.DORMANT;
- newReserve.retryInfo = initRetryInfo(false);
-
- let withdrawalGroupId: string;
-
- if (!newReserve.initialWithdrawalStarted) {
- withdrawalGroupId = newReserve.initialWithdrawalGroupId;
- newReserve.initialWithdrawalStarted = true;
- } else {
- withdrawalGroupId = encodeCrock(randomBytes(32));
- }
-
- const withdrawalRecord: WithdrawalGroupRecord = {
- withdrawalGroupId: withdrawalGroupId,
- exchangeBaseUrl: newReserve.exchangeBaseUrl,
- source: {
- type: WithdrawalSourceType.Reserve,
- reservePub: newReserve.reservePub,
- },
- rawWithdrawalAmount: withdrawAmount,
- timestampStart: getTimestampNow(),
- retryInfo: initRetryInfo(),
- lastErrorPerCoin: {},
- lastError: undefined,
- denomsSel: denomSelectionInfoToState(denomsForWithdraw),
- };
-
- await tx.put(Stores.reserves, newReserve);
- await tx.put(Stores.reserveHistory, newHist);
- await tx.put(Stores.withdrawalGroups, withdrawalRecord);
- return withdrawalRecord;
- },
- );
-
- if (newWithdrawalGroup) {
- logger.trace("processing new withdraw group");
- ws.notify({
- type: NotificationType.WithdrawGroupCreated,
- withdrawalGroupId: newWithdrawalGroup.withdrawalGroupId,
- });
- await processWithdrawGroup(ws, newWithdrawalGroup.withdrawalGroupId);
- } else {
- console.trace("withdraw session already existed");
- }
-}
-
-export async function createTalerWithdrawReserve(
- ws: InternalWalletState,
- talerWithdrawUri: string,
- selectedExchange: string,
-): Promise<AcceptWithdrawalResponse> {
- const withdrawInfo = await getBankWithdrawalInfo(ws, talerWithdrawUri);
- const exchangeWire = await getExchangePaytoUri(
- ws,
- selectedExchange,
- withdrawInfo.wireTypes,
- );
- const reserve = await createReserve(ws, {
- amount: withdrawInfo.amount,
- bankWithdrawStatusUrl: withdrawInfo.extractedStatusUrl,
- exchange: selectedExchange,
- senderWire: withdrawInfo.senderWire,
- exchangePaytoUri: exchangeWire,
- });
- // We do this here, as the reserve should be registered before we return,
- // so that we can redirect the user to the bank's status page.
- await processReserveBankStatus(ws, reserve.reservePub);
- return {
- reservePub: reserve.reservePub,
- confirmTransferUrl: withdrawInfo.confirmTransferUrl,
- };
-}
-
-/**
- * Get payto URIs needed to fund a reserve.
- */
-export async function getFundingPaytoUris(
- tx: TransactionHandle,
- reservePub: string,
-): Promise<string[]> {
- const r = await tx.get(Stores.reserves, reservePub);
- if (!r) {
- logger.error(`reserve ${reservePub} not found (DB corrupted?)`);
- return [];
- }
- const exchange = await tx.get(Stores.exchanges, r.exchangeBaseUrl);
- if (!exchange) {
- logger.error(`exchange ${r.exchangeBaseUrl} not found (DB corrupted?)`);
- return [];
- }
- const plainPaytoUris =
- exchange.wireInfo?.accounts.map((x) => x.payto_uri) ?? [];
- if (!plainPaytoUris) {
- logger.error(`exchange ${r.exchangeBaseUrl} has no wire info`);
- return [];
- }
- return plainPaytoUris.map((x) =>
- addPaytoQueryParams(x, {
- amount: Amounts.stringify(r.instructedAmount),
- message: `Taler Withdrawal ${r.reservePub}`,
- }),
- );
-}
diff --git a/src/operations/state.ts b/src/operations/state.ts
deleted file mode 100644
index cfec85d0f..000000000
--- a/src/operations/state.ts
+++ /dev/null
@@ -1,65 +0,0 @@
-/*
- This file is part of GNU Taler
- (C) 2019 GNUnet e.V.
-
- 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/>
- */
-
-import { HttpRequestLibrary } from "../util/http";
-import { NextUrlResult, BalancesResponse } from "../types/walletTypes";
-import { CryptoApi, CryptoWorkerFactory } from "../crypto/workers/cryptoApi";
-import { AsyncOpMemoMap, AsyncOpMemoSingle } from "../util/asyncMemo";
-import { Logger } from "../util/logging";
-import { PendingOperationsResponse } from "../types/pending";
-import { WalletNotification } from "../types/notifications";
-import { Database } from "../util/query";
-
-type NotificationListener = (n: WalletNotification) => void;
-
-const logger = new Logger("state.ts");
-
-export class InternalWalletState {
- cachedNextUrl: { [fulfillmentUrl: string]: NextUrlResult } = {};
- memoProcessReserve: AsyncOpMemoMap<void> = new AsyncOpMemoMap();
- memoMakePlanchet: AsyncOpMemoMap<void> = new AsyncOpMemoMap();
- memoGetPending: AsyncOpMemoSingle<
- PendingOperationsResponse
- > = new AsyncOpMemoSingle();
- memoGetBalance: AsyncOpMemoSingle<BalancesResponse> = new AsyncOpMemoSingle();
- memoProcessRefresh: AsyncOpMemoMap<void> = new AsyncOpMemoMap();
- memoProcessRecoup: AsyncOpMemoMap<void> = new AsyncOpMemoMap();
- cryptoApi: CryptoApi;
-
- listeners: NotificationListener[] = [];
-
- constructor(
- public db: Database,
- public http: HttpRequestLibrary,
- cryptoWorkerFactory: CryptoWorkerFactory,
- ) {
- this.cryptoApi = new CryptoApi(cryptoWorkerFactory);
- }
-
- public notify(n: WalletNotification): void {
- logger.trace("Notification", n);
- for (const l of this.listeners) {
- const nc = JSON.parse(JSON.stringify(n));
- setTimeout(() => {
- l(nc);
- }, 0);
- }
- }
-
- addNotificationListener(f: (n: WalletNotification) => void): void {
- this.listeners.push(f);
- }
-}
diff --git a/src/operations/tip.ts b/src/operations/tip.ts
deleted file mode 100644
index 17f7ee90d..000000000
--- a/src/operations/tip.ts
+++ /dev/null
@@ -1,342 +0,0 @@
-/*
- This file is part of GNU Taler
- (C) 2019 Taler Systems S.A.
-
- 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/>
- */
-
-import { InternalWalletState } from "./state";
-import { parseTipUri } from "../util/taleruri";
-import { TipStatus, OperationErrorDetails } from "../types/walletTypes";
-import {
- TipPlanchetDetail,
- codecForTipPickupGetResponse,
- codecForTipResponse,
-} from "../types/talerTypes";
-import * as Amounts from "../util/amounts";
-import {
- Stores,
- PlanchetRecord,
- WithdrawalGroupRecord,
- initRetryInfo,
- updateRetryInfoTimeout,
- WithdrawalSourceType,
- TipPlanchet,
-} from "../types/dbTypes";
-import {
- getExchangeWithdrawalInfo,
- selectWithdrawalDenoms,
- processWithdrawGroup,
- denomSelectionInfoToState,
-} from "./withdraw";
-import { updateExchangeFromUrl } from "./exchanges";
-import { getRandomBytes, encodeCrock } from "../crypto/talerCrypto";
-import { guardOperationException } from "./errors";
-import { NotificationType } from "../types/notifications";
-import { getTimestampNow } from "../util/time";
-import { readSuccessResponseJsonOrThrow } from "../util/http";
-
-export async function getTipStatus(
- ws: InternalWalletState,
- talerTipUri: string,
-): Promise<TipStatus> {
- const res = parseTipUri(talerTipUri);
- if (!res) {
- throw Error("invalid taler://tip URI");
- }
-
- const tipStatusUrl = new URL("tip-pickup", res.merchantBaseUrl);
- tipStatusUrl.searchParams.set("tip_id", res.merchantTipId);
- console.log("checking tip status from", tipStatusUrl.href);
- const merchantResp = await ws.http.get(tipStatusUrl.href);
- const tipPickupStatus = await readSuccessResponseJsonOrThrow(
- merchantResp,
- codecForTipPickupGetResponse(),
- );
- console.log("status", tipPickupStatus);
-
- const amount = Amounts.parseOrThrow(tipPickupStatus.amount);
-
- const merchantOrigin = new URL(res.merchantBaseUrl).origin;
-
- let tipRecord = await ws.db.get(Stores.tips, [
- res.merchantTipId,
- merchantOrigin,
- ]);
-
- if (!tipRecord) {
- await updateExchangeFromUrl(ws, tipPickupStatus.exchange_url);
- const withdrawDetails = await getExchangeWithdrawalInfo(
- ws,
- tipPickupStatus.exchange_url,
- amount,
- );
-
- const tipId = encodeCrock(getRandomBytes(32));
- const selectedDenoms = await selectWithdrawalDenoms(
- ws,
- tipPickupStatus.exchange_url,
- amount,
- );
-
- tipRecord = {
- tipId,
- acceptedTimestamp: undefined,
- rejectedTimestamp: undefined,
- amount,
- deadline: tipPickupStatus.stamp_expire,
- exchangeUrl: tipPickupStatus.exchange_url,
- merchantBaseUrl: res.merchantBaseUrl,
- nextUrl: undefined,
- pickedUp: false,
- planchets: undefined,
- response: undefined,
- createdTimestamp: getTimestampNow(),
- merchantTipId: res.merchantTipId,
- totalFees: Amounts.add(
- withdrawDetails.overhead,
- withdrawDetails.withdrawFee,
- ).amount,
- retryInfo: initRetryInfo(),
- lastError: undefined,
- denomsSel: denomSelectionInfoToState(selectedDenoms),
- };
- await ws.db.put(Stores.tips, tipRecord);
- }
-
- const tipStatus: TipStatus = {
- accepted: !!tipRecord && !!tipRecord.acceptedTimestamp,
- amount: Amounts.parseOrThrow(tipPickupStatus.amount),
- amountLeft: Amounts.parseOrThrow(tipPickupStatus.amount_left),
- exchangeUrl: tipPickupStatus.exchange_url,
- nextUrl: tipPickupStatus.extra.next_url,
- merchantOrigin: merchantOrigin,
- merchantTipId: res.merchantTipId,
- expirationTimestamp: tipPickupStatus.stamp_expire,
- timestamp: tipPickupStatus.stamp_created,
- totalFees: tipRecord.totalFees,
- tipId: tipRecord.tipId,
- };
-
- return tipStatus;
-}
-
-async function incrementTipRetry(
- ws: InternalWalletState,
- refreshSessionId: string,
- err: OperationErrorDetails | undefined,
-): Promise<void> {
- await ws.db.runWithWriteTransaction([Stores.tips], async (tx) => {
- const t = await tx.get(Stores.tips, refreshSessionId);
- if (!t) {
- return;
- }
- if (!t.retryInfo) {
- return;
- }
- t.retryInfo.retryCounter++;
- updateRetryInfoTimeout(t.retryInfo);
- t.lastError = err;
- await tx.put(Stores.tips, t);
- });
- ws.notify({ type: NotificationType.TipOperationError });
-}
-
-export async function processTip(
- ws: InternalWalletState,
- tipId: string,
- forceNow = false,
-): Promise<void> {
- const onOpErr = (e: OperationErrorDetails): Promise<void> =>
- incrementTipRetry(ws, tipId, e);
- await guardOperationException(
- () => processTipImpl(ws, tipId, forceNow),
- onOpErr,
- );
-}
-
-async function resetTipRetry(
- ws: InternalWalletState,
- tipId: string,
-): Promise<void> {
- await ws.db.mutate(Stores.tips, tipId, (x) => {
- if (x.retryInfo.active) {
- x.retryInfo = initRetryInfo();
- }
- return x;
- });
-}
-
-async function processTipImpl(
- ws: InternalWalletState,
- tipId: string,
- forceNow: boolean,
-): Promise<void> {
- if (forceNow) {
- await resetTipRetry(ws, tipId);
- }
- let tipRecord = await ws.db.get(Stores.tips, tipId);
- if (!tipRecord) {
- return;
- }
-
- if (tipRecord.pickedUp) {
- console.log("tip already picked up");
- return;
- }
-
- const denomsForWithdraw = tipRecord.denomsSel;
-
- if (!tipRecord.planchets) {
- const planchets: TipPlanchet[] = [];
-
- for (const sd of denomsForWithdraw.selectedDenoms) {
- const denom = await ws.db.getIndexed(
- Stores.denominations.denomPubHashIndex,
- sd.denomPubHash,
- );
- if (!denom) {
- throw Error("denom does not exist anymore");
- }
- for (let i = 0; i < sd.count; i++) {
- const r = await ws.cryptoApi.createTipPlanchet(denom);
- planchets.push(r);
- }
- }
- await ws.db.mutate(Stores.tips, tipId, (r) => {
- if (!r.planchets) {
- r.planchets = planchets;
- }
- return r;
- });
- }
-
- tipRecord = await ws.db.get(Stores.tips, tipId);
- if (!tipRecord) {
- throw Error("tip not in database");
- }
-
- if (!tipRecord.planchets) {
- throw Error("invariant violated");
- }
-
- console.log("got planchets for tip!");
-
- // Planchets in the form that the merchant expects
- const planchetsDetail: TipPlanchetDetail[] = tipRecord.planchets.map((p) => ({
- coin_ev: p.coinEv,
- denom_pub_hash: p.denomPubHash,
- }));
-
- let merchantResp;
-
- const tipStatusUrl = new URL("tip-pickup", tipRecord.merchantBaseUrl);
-
- try {
- const req = { planchets: planchetsDetail, tip_id: tipRecord.merchantTipId };
- merchantResp = await ws.http.postJson(tipStatusUrl.href, req);
- if (merchantResp.status !== 200) {
- throw Error(`unexpected status ${merchantResp.status} for tip-pickup`);
- }
- console.log("got merchant resp:", merchantResp);
- } catch (e) {
- console.log("tipping failed", e);
- throw e;
- }
-
- const response = codecForTipResponse().decode(await merchantResp.json());
-
- if (response.reserve_sigs.length !== tipRecord.planchets.length) {
- throw Error("number of tip responses does not match requested planchets");
- }
-
- const withdrawalGroupId = encodeCrock(getRandomBytes(32));
- const planchets: PlanchetRecord[] = [];
-
- for (let i = 0; i < tipRecord.planchets.length; i++) {
- const tipPlanchet = tipRecord.planchets[i];
- const coinEvHash = await ws.cryptoApi.hashEncoded(tipPlanchet.coinEv);
- const planchet: PlanchetRecord = {
- blindingKey: tipPlanchet.blindingKey,
- coinEv: tipPlanchet.coinEv,
- coinPriv: tipPlanchet.coinPriv,
- coinPub: tipPlanchet.coinPub,
- coinValue: tipPlanchet.coinValue,
- denomPub: tipPlanchet.denomPub,
- denomPubHash: tipPlanchet.denomPubHash,
- reservePub: response.reserve_pub,
- withdrawSig: response.reserve_sigs[i].reserve_sig,
- isFromTip: true,
- coinEvHash,
- coinIdx: i,
- withdrawalDone: false,
- withdrawalGroupId: withdrawalGroupId,
- };
- planchets.push(planchet);
- }
-
- const withdrawalGroup: WithdrawalGroupRecord = {
- exchangeBaseUrl: tipRecord.exchangeUrl,
- source: {
- type: WithdrawalSourceType.Tip,
- tipId: tipRecord.tipId,
- },
- timestampStart: getTimestampNow(),
- withdrawalGroupId: withdrawalGroupId,
- rawWithdrawalAmount: tipRecord.amount,
- lastErrorPerCoin: {},
- retryInfo: initRetryInfo(),
- timestampFinish: undefined,
- lastError: undefined,
- denomsSel: tipRecord.denomsSel,
- };
-
- await ws.db.runWithWriteTransaction(
- [Stores.tips, Stores.withdrawalGroups],
- async (tx) => {
- const tr = await tx.get(Stores.tips, tipId);
- if (!tr) {
- return;
- }
- if (tr.pickedUp) {
- return;
- }
- tr.pickedUp = true;
- tr.retryInfo = initRetryInfo(false);
-
- await tx.put(Stores.tips, tr);
- await tx.put(Stores.withdrawalGroups, withdrawalGroup);
- for (const p of planchets) {
- await tx.put(Stores.planchets, p);
- }
- },
- );
-
- await processWithdrawGroup(ws, withdrawalGroupId);
-}
-
-export async function acceptTip(
- ws: InternalWalletState,
- tipId: string,
-): Promise<void> {
- const tipRecord = await ws.db.get(Stores.tips, tipId);
- if (!tipRecord) {
- console.log("tip not found");
- return;
- }
-
- tipRecord.acceptedTimestamp = getTimestampNow();
- await ws.db.put(Stores.tips, tipRecord);
-
- await processTip(ws, tipId);
- return;
-}
diff --git a/src/operations/transactions.ts b/src/operations/transactions.ts
deleted file mode 100644
index 647949d22..000000000
--- a/src/operations/transactions.ts
+++ /dev/null
@@ -1,292 +0,0 @@
-/*
- This file is part of GNU Taler
- (C) 2019 Taler Systems S.A.
-
- 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/>
- */
-
-/**
- * Imports.
- */
-import { InternalWalletState } from "./state";
-import {
- Stores,
- WithdrawalSourceType,
-} from "../types/dbTypes";
-import { Amounts, AmountJson } from "../util/amounts";
-import { timestampCmp } from "../util/time";
-import {
- TransactionsRequest,
- TransactionsResponse,
- Transaction,
- TransactionType,
- PaymentStatus,
- WithdrawalType,
- WithdrawalDetails,
-} from "../types/transactions";
-import { getFundingPaytoUris } from "./reserves";
-
-/**
- * Create an event ID from the type and the primary key for the event.
- */
-function makeEventId(type: TransactionType, ...args: string[]): string {
- return type + ";" + args.map((x) => encodeURIComponent(x)).join(";");
-}
-
-function shouldSkipCurrency(
- transactionsRequest: TransactionsRequest | undefined,
- currency: string,
-): boolean {
- if (!transactionsRequest?.currency) {
- return false;
- }
- return transactionsRequest.currency.toLowerCase() !== currency.toLowerCase();
-}
-
-function shouldSkipSearch(
- transactionsRequest: TransactionsRequest | undefined,
- fields: string[],
-): boolean {
- if (!transactionsRequest?.search) {
- return false;
- }
- const needle = transactionsRequest.search.trim();
- for (const f of fields) {
- if (f.indexOf(needle) >= 0) {
- return false;
- }
- }
- return true;
-}
-
-/**
- * Retrive the full event history for this wallet.
- */
-export async function getTransactions(
- ws: InternalWalletState,
- transactionsRequest?: TransactionsRequest,
-): Promise<TransactionsResponse> {
- const transactions: Transaction[] = [];
-
- await ws.db.runWithReadTransaction(
- [
- Stores.currencies,
- Stores.coins,
- Stores.denominations,
- Stores.exchanges,
- Stores.proposals,
- Stores.purchases,
- Stores.refreshGroups,
- Stores.reserves,
- Stores.reserveHistory,
- Stores.tips,
- Stores.withdrawalGroups,
- Stores.payEvents,
- Stores.planchets,
- Stores.refundEvents,
- Stores.reserveUpdatedEvents,
- Stores.recoupGroups,
- ],
- // Report withdrawals that are currently in progress.
- async (tx) => {
- tx.iter(Stores.withdrawalGroups).forEachAsync(async (wsr) => {
- if (
- shouldSkipCurrency(
- transactionsRequest,
- wsr.rawWithdrawalAmount.currency,
- )
- ) {
- return;
- }
-
- if (shouldSkipSearch(transactionsRequest, [])) {
- return;
- }
-
- switch (wsr.source.type) {
- case WithdrawalSourceType.Reserve:
- {
- const r = await tx.get(Stores.reserves, wsr.source.reservePub);
- if (!r) {
- break;
- }
- let amountRaw: AmountJson | undefined = undefined;
- if (wsr.withdrawalGroupId === r.initialWithdrawalGroupId) {
- amountRaw = r.instructedAmount;
- } else {
- amountRaw = wsr.denomsSel.totalWithdrawCost;
- }
- let withdrawalDetails: WithdrawalDetails;
- if (r.bankInfo) {
- withdrawalDetails = {
- type: WithdrawalType.TalerBankIntegrationApi,
- confirmed: true,
- bankConfirmationUrl: r.bankInfo.confirmUrl,
- };
- } else {
- const exchange = await tx.get(
- Stores.exchanges,
- r.exchangeBaseUrl,
- );
- if (!exchange) {
- // FIXME: report somehow
- break;
- }
- withdrawalDetails = {
- type: WithdrawalType.ManualTransfer,
- exchangePaytoUris:
- exchange.wireInfo?.accounts.map((x) => x.payto_uri) ?? [],
- };
- }
- transactions.push({
- type: TransactionType.Withdrawal,
- amountEffective: Amounts.stringify(
- wsr.denomsSel.totalCoinValue,
- ),
- amountRaw: Amounts.stringify(amountRaw),
- withdrawalDetails,
- exchangeBaseUrl: wsr.exchangeBaseUrl,
- pending: !wsr.timestampFinish,
- timestamp: wsr.timestampStart,
- transactionId: makeEventId(
- TransactionType.Withdrawal,
- wsr.withdrawalGroupId,
- ),
- });
- }
- break;
- default:
- // Tips are reported via their own event
- break;
- }
- });
-
- // Report pending withdrawals based on reserves that
- // were created, but where the actual withdrawal group has
- // not started yet.
- tx.iter(Stores.reserves).forEachAsync(async (r) => {
- if (shouldSkipCurrency(transactionsRequest, r.currency)) {
- return;
- }
- if (shouldSkipSearch(transactionsRequest, [])) {
- return;
- }
- if (r.initialWithdrawalStarted) {
- return;
- }
- let withdrawalDetails: WithdrawalDetails;
- if (r.bankInfo) {
- withdrawalDetails = {
- type: WithdrawalType.TalerBankIntegrationApi,
- confirmed: false,
- bankConfirmationUrl: r.bankInfo.confirmUrl,
- };
- } else {
- withdrawalDetails = {
- type: WithdrawalType.ManualTransfer,
- exchangePaytoUris: await getFundingPaytoUris(tx, r.reservePub),
- };
- }
- transactions.push({
- type: TransactionType.Withdrawal,
- amountRaw: Amounts.stringify(r.instructedAmount),
- amountEffective: Amounts.stringify(r.initialDenomSel.totalCoinValue),
- exchangeBaseUrl: r.exchangeBaseUrl,
- pending: true,
- timestamp: r.timestampCreated,
- withdrawalDetails: withdrawalDetails,
- transactionId: makeEventId(
- TransactionType.Withdrawal,
- r.initialWithdrawalGroupId,
- ),
- });
- });
-
- tx.iter(Stores.purchases).forEachAsync(async (pr) => {
- if (
- shouldSkipCurrency(
- transactionsRequest,
- pr.contractData.amount.currency,
- )
- ) {
- return;
- }
- if (shouldSkipSearch(transactionsRequest, [pr.contractData.summary])) {
- return;
- }
- const proposal = await tx.get(Stores.proposals, pr.proposalId);
- if (!proposal) {
- return;
- }
- transactions.push({
- type: TransactionType.Payment,
- amountRaw: Amounts.stringify(pr.contractData.amount),
- amountEffective: Amounts.stringify(pr.payCostInfo.totalCost),
- status: pr.timestampFirstSuccessfulPay
- ? PaymentStatus.Paid
- : PaymentStatus.Accepted,
- pending: !pr.timestampFirstSuccessfulPay,
- timestamp: pr.timestampAccept,
- transactionId: makeEventId(TransactionType.Payment, pr.proposalId),
- info: {
- fulfillmentUrl: pr.contractData.fulfillmentUrl,
- merchant: pr.contractData.merchant,
- orderId: pr.contractData.orderId,
- products: pr.contractData.products,
- summary: pr.contractData.summary,
- summary_i18n: pr.contractData.summaryI18n,
- },
- });
-
- // for (const rg of pr.refundGroups) {
- // const pending = Object.keys(pr.refundsPending).length > 0;
- // const stats = getRefundStats(pr, rg.refundGroupId);
-
- // transactions.push({
- // type: TransactionType.Refund,
- // pending,
- // info: {
- // fulfillmentUrl: pr.contractData.fulfillmentUrl,
- // merchant: pr.contractData.merchant,
- // orderId: pr.contractData.orderId,
- // products: pr.contractData.products,
- // summary: pr.contractData.summary,
- // summary_i18n: pr.contractData.summaryI18n,
- // },
- // timestamp: rg.timestampQueried,
- // transactionId: makeEventId(
- // TransactionType.Refund,
- // pr.proposalId,
- // `${rg.timestampQueried.t_ms}`,
- // ),
- // refundedTransactionId: makeEventId(
- // TransactionType.Payment,
- // pr.proposalId,
- // ),
- // amountEffective: Amounts.stringify(stats.amountEffective),
- // amountInvalid: Amounts.stringify(stats.amountInvalid),
- // amountRaw: Amounts.stringify(stats.amountRaw),
- // });
- // }
-
- });
- },
- );
-
- const txPending = transactions.filter((x) => x.pending);
- const txNotPending = transactions.filter((x) => !x.pending);
-
- txPending.sort((h1, h2) => timestampCmp(h1.timestamp, h2.timestamp));
- txNotPending.sort((h1, h2) => timestampCmp(h1.timestamp, h2.timestamp));
-
- return { transactions: [...txPending, ...txNotPending] };
-}
diff --git a/src/operations/versions.ts b/src/operations/versions.ts
deleted file mode 100644
index 31c4921c6..000000000
--- a/src/operations/versions.ts
+++ /dev/null
@@ -1,38 +0,0 @@
-/*
- This file is part of GNU Taler
- (C) 2019 Taler Systems S.A.
-
- 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/>
- */
-
-/**
- * Protocol version spoken with the exchange.
- *
- * Uses libtool's current:revision:age versioning.
- */
-export const WALLET_EXCHANGE_PROTOCOL_VERSION = "8:0:0";
-
-/**
- * Protocol version spoken with the merchant.
- *
- * Uses libtool's current:revision:age versioning.
- */
-export const WALLET_MERCHANT_PROTOCOL_VERSION = "1:0:0";
-
-/**
- * Cache breaker that is appended to queries such as /keys and /wire
- * to break through caching, if it has been accidentally/badly configured
- * by the exchange.
- *
- * This is only a temporary measure.
- */
-export const WALLET_CACHE_BREAKER_CLIENT_VERSION = "3";
diff --git a/src/operations/withdraw-test.ts b/src/operations/withdraw-test.ts
deleted file mode 100644
index 24cb6f4b1..000000000
--- a/src/operations/withdraw-test.ts
+++ /dev/null
@@ -1,332 +0,0 @@
-/*
- This file is part of GNU Taler
- (C) 2020 Taler Systems S.A.
-
- 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/>
- */
-
-import test from "ava";
-import { getWithdrawDenomList } from "./withdraw";
-import { Amounts } from "../util/amounts";
-
-test("withdrawal selection bug repro", (t) => {
- const amount = {
- currency: "KUDOS",
- fraction: 43000000,
- value: 23,
- };
-
- const denoms = [
- {
- denomPub:
- "040000XT67C8KBD6B75TTQ3SK8FWXMNQW4372T3BDDGPAMB9RFCA03638W8T3F71WFEFK9NP32VKYVNFXPYRWQ1N1HDKV5J0DFEKHBPJCYSWCBJDRNWD7G8BN8PT97FA9AMV75MYEK4X54D1HGJ207JSVJBGFCATSPNTEYNHEQF1F220W00TBZR1HNPDQFD56FG0DJQ9KGHM8EC33H6AY9YN9CNX5R3Z4TZ4Q23W47SBHB13H6W74FQJG1F50X38VRSC4SR8RWBAFB7S4K8D2H4NMRFSQT892A3T0BTBW7HM5C0H2CK6FRKG31F7W9WP1S29013K5CXYE55CT8TH6N8J9B780R42Y5S3ZB6J6E9H76XBPSGH4TGYSR2VZRB98J417KCQMZKX1BB67E7W5KVE37TC9SJ904002",
- denomPubHash:
- "Q21FQSSG4FXNT96Z14CHXM8N1RZAG9GPHAV8PRWS0PZAAVWH7PBW6R97M2CH19KKP65NNSWXY7B6S53PT3CBM342E357ZXDDJ8RDVW8",
- exchangeBaseUrl: "https://exchange.demo.taler.net/",
- feeDeposit: {
- currency: "KUDOS",
- fraction: 1000000,
- value: 0,
- },
- feeRefresh: {
- currency: "KUDOS",
- fraction: 1000000,
- value: 0,
- },
- feeRefund: {
- currency: "KUDOS",
- fraction: 1000000,
- value: 0,
- },
- feeWithdraw: {
- currency: "KUDOS",
- fraction: 1000000,
- value: 0,
- },
- isOffered: true,
- isRevoked: false,
- masterSig:
- "4F0P456CNNTTWK8BFJHGM3JTD6FVVNZY8EP077GYAHDJ5Y81S5RQ3SMS925NXMDVG9A88JAAP0E2GDZBC21PP5NHFFVWHAW3AVT8J3R",
- stampExpireDeposit: {
- t_ms: 1742909388000,
- },
- stampExpireLegal: {
- t_ms: 1900589388000,
- },
- stampExpireWithdraw: {
- t_ms: 1679837388000,
- },
- stampStart: {
- t_ms: 1585229388000,
- },
- status: 0,
- value: {
- currency: "KUDOS",
- fraction: 0,
- value: 1000,
- },
- },
- {
- denomPub:
- "040000Y63CF78QFPKRY77BRK9P557Q1GQWX3NCZ3HSYSK0Z7TT0KGRA7N4SKBKEHSTVHX1Z9DNXMJR4EXSY1TXCKV0GJ3T3YYC6Z0JNMJFVYQAV4FX5J90NZH1N33MZTV8HS9SMNAA9S6K73G4P99GYBB01B0P6M1KXZ5JRDR7VWBR3MEJHHGJ6QBMCJR3NWJRE3WJW9PRY8QPQ2S7KFWTWRESH2DBXCXWBD2SRN6P9YX8GRAEMFEGXC9V5GVJTEMH6ZDGNXFPWZE3JVJ2Q4N9GDYKBCHZCJ7M7M2RJ9ZV4Y64NAN9BT6XDC68215GKKRHTW1BBF1MYY6AR3JCTT9HYAM923RMVQR3TAEB7SDX8J76XRZWYH3AGJCZAQGMN5C8SSH9AHQ9RNQJQ15CN45R37X4YNFJV904002",
- denomPubHash:
- "447WA23SCBATMABHA0793F92MYTBYVPYMMQHCPKMKVY5P7RZRFMQ6VRW0Y8HRA7177GTBT0TBT08R21DZD129AJ995H9G09XBFE55G8",
- exchangeBaseUrl: "https://exchange.demo.taler.net/",
- feeDeposit: {
- currency: "KUDOS",
- fraction: 1000000,
- value: 0,
- },
- feeRefresh: {
- currency: "KUDOS",
- fraction: 1000000,
- value: 0,
- },
- feeRefund: {
- currency: "KUDOS",
- fraction: 1000000,
- value: 0,
- },
- feeWithdraw: {
- currency: "KUDOS",
- fraction: 1000000,
- value: 0,
- },
- isOffered: true,
- isRevoked: false,
- masterSig:
- "P99AW82W46MZ0AKW7Z58VQPXFNTJQM9DVTYPBDF6KVYF38PPVDAZTV7JQ8TY7HGEC7JJJAY4E7AY7J3W1WV10DAZZQHHKTAVTSRAC20",
- stampExpireDeposit: {
- t_ms: 1742909388000,
- },
- stampExpireLegal: {
- t_ms: 1900589388000,
- },
- stampExpireWithdraw: {
- t_ms: 1679837388000,
- },
- stampStart: {
- t_ms: 1585229388000,
- },
- status: 0,
- value: {
- currency: "KUDOS",
- fraction: 0,
- value: 10,
- },
- },
- {
- denomPub:
- "040000YDESWC2B962DA4WK356SC50MA3N9KV0ZSGY3RC48JCTY258W909C7EEMT5BTC5KZ5T4CERCZ141P9QF87EK2BD1XEEM5GB07MB3H19WE4CQGAS8X84JBWN83PQGQXVMWE5HFA992KMGHC566GT9ZS2QPHZB6X89C4A80Z663PYAAPXP728VHAKATGNNBQ01ZZ2XD1CH9Y38YZBSPJ4K7GB2J76GBCYAVD9ENHDVWXJAXYRPBX4KSS5TXRR3K5NEN9ZV3AJD2V65K7ABRZDF5D5V1FJZZMNJ5XZ4FEREEKEBV9TDFPGJTKDEHEC60K3DN24DAATRESDJ1ZYYSYSRCAT4BT2B62ARGVMJTT5N2R126DRW9TGRWCW0ZAF2N2WET1H4NJEW77X0QT46Z5R3MZ0XPHD04002",
- denomPubHash:
- "JS61DTKAFM0BX8Q4XV3ZSKB921SM8QK745Z2AFXTKFMBHHFNBD8TQ5ETJHFNDGBGX22FFN2A2ERNYG1SGSDQWNQHQQ2B14DBVJYJG8R",
- exchangeBaseUrl: "https://exchange.demo.taler.net/",
- feeDeposit: {
- currency: "KUDOS",
- fraction: 1000000,
- value: 0,
- },
- feeRefresh: {
- currency: "KUDOS",
- fraction: 1000000,
- value: 0,
- },
- feeRefund: {
- currency: "KUDOS",
- fraction: 1000000,
- value: 0,
- },
- feeWithdraw: {
- currency: "KUDOS",
- fraction: 1000000,
- value: 0,
- },
- isOffered: true,
- isRevoked: false,
- masterSig:
- "8S4VZGHE5WE0N5ZVCHYW9KZZR4YAKK15S46MV1HR1QB9AAMH3NWPW4DCR4NYGJK33Q8YNFY80SWNS6XKAP5DEVK933TM894FJ2VGE3G",
- stampExpireDeposit: {
- t_ms: 1742909388000,
- },
- stampExpireLegal: {
- t_ms: 1900589388000,
- },
- stampExpireWithdraw: {
- t_ms: 1679837388000,
- },
- stampStart: {
- t_ms: 1585229388000,
- },
- status: 0,
- value: {
- currency: "KUDOS",
- fraction: 0,
- value: 5,
- },
- },
- {
- denomPub:
- "040000YG3T1ADB8DVA6BD3EPV6ZHSHTDW35DEN4VH1AE6CSB7P1PSDTNTJG866PHF6QB1CCWYCVRGA0FVBJ9Q0G7KV7AD9010GDYBQH0NNPHW744MTNXVXWBGGGRGQGYK4DTYN1DSWQ1FZNDSZZPB5BEKG2PDJ93NX2JTN06Y8QMS2G734Z9XHC10EENBG2KVB7EJ3CM8PV1T32RC7AY62F3496E8D8KRHJQQTT67DSGMNKK86QXVDTYW677FG27DP20E8XY3M6FQD53NDJ1WWES91401MV1A3VXVPGC76GZVDD62W3WTJ1YMKHTTA3MRXX3VEAAH3XTKDN1ER7X6CZPMYTF8VK735VP2B2TZGTF28TTW4FZS32SBS64APCDF6SZQ427N5538TJC7SRE71YSP5ET8GS904002",
- denomPubHash:
- "8T51NEY81VMPQ180EQ5WR0YH7GMNNT90W55Q0514KZM18AZT71FHJGJHQXGK0WTA7ACN1X2SD0S53XPBQ1A9KH960R48VCVVM6E3TH8",
- exchangeBaseUrl: "https://exchange.demo.taler.net/",
- feeDeposit: {
- currency: "KUDOS",
- fraction: 1000000,
- value: 0,
- },
- feeRefresh: {
- currency: "KUDOS",
- fraction: 1000000,
- value: 0,
- },
- feeRefund: {
- currency: "KUDOS",
- fraction: 1000000,
- value: 0,
- },
- feeWithdraw: {
- currency: "KUDOS",
- fraction: 1000000,
- value: 0,
- },
- isOffered: true,
- isRevoked: false,
- masterSig:
- "E3AWGAG8VB42P3KXM8B04Z6M483SX59R3Y4T53C3NXCA2NPB6C7HVCMVX05DC6S58E9X40NGEBQNYXKYMYCF3ASY2C4WP1WCZ4ME610",
- stampExpireDeposit: {
- t_ms: 1742909388000,
- },
- stampExpireLegal: {
- t_ms: 1900589388000,
- },
- stampExpireWithdraw: {
- t_ms: 1679837388000,
- },
- stampStart: {
- t_ms: 1585229388000,
- },
- status: 0,
- value: {
- currency: "KUDOS",
- fraction: 0,
- value: 1,
- },
- },
- {
- denomPub:
- "040000ZC0G60E9QQ5PD81TSDWD9GV5Y6P8Z05NSPA696DP07NGQQVSRQXBA76Q6PRB0YFX295RG4MTQJXAZZ860ET307HSC2X37XAVGQXRVB8Q4F1V7NP5ZEVKTX75DZK1QRAVHEZGQYKSSH6DBCJNQF6V9WNQF3GEYVA4KCBHA7JF772KHXM9642C28Z0AS4XXXV2PABAN5C8CHYD5H7JDFNK3920W5Q69X0BS84XZ4RE2PW6HM1WZ6KGZ3MKWWWCPKQ1FSFABRBWKAB09PF563BEBXKY6M38QETPH5EDWGANHD0SC3QV0WXYVB7BNHNNQ0J5BNV56K563SYHM4E5ND260YRJSYA1GN5YSW2B1J5T1A1EBNYF2DN6JNJKWXWEQ42G5YS17ZSZ5EWDRA9QKV8EGTCNAD04002",
- denomPubHash:
- "A41HW0Q2H9PCNMEWW0C0N45QAYVXZ8SBVRRAHE4W6X24SV1TH38ANTWDT80JXEBW9Z8PVPGT9GFV2EYZWJ5JW5W1N34NFNKHQSZ1PFR",
- exchangeBaseUrl: "https://exchange.demo.taler.net/",
- feeDeposit: {
- currency: "KUDOS",
- fraction: 1000000,
- value: 0,
- },
- feeRefresh: {
- currency: "KUDOS",
- fraction: 1000000,
- value: 0,
- },
- feeRefund: {
- currency: "KUDOS",
- fraction: 1000000,
- value: 0,
- },
- feeWithdraw: {
- currency: "KUDOS",
- fraction: 1000000,
- value: 0,
- },
- isOffered: true,
- isRevoked: false,
- masterSig:
- "0ES1RKV002XB4YP21SN0QB7RSDHGYT0XAE65JYN8AVJAA6H7JZFN7JADXT521DJS89XMGPZGR8GCXF1516Y0Q9QDV00E6NMFA6CF838",
- stampExpireDeposit: {
- t_ms: 1742909388000,
- },
- stampExpireLegal: {
- t_ms: 1900589388000,
- },
- stampExpireWithdraw: {
- t_ms: 1679837388000,
- },
- stampStart: {
- t_ms: 1585229388000,
- },
- status: 0,
- value: {
- currency: "KUDOS",
- fraction: 10000000,
- value: 0,
- },
- },
- {
- denomPub:
- "040000ZSK2PMVY6E3NBQ52KXMW029M60F4BWYTDS0FZSD0PE53CNZ9H6TM3GQK1WRTEKQ5GRWJ1J9DY6Y42SP47QVT1XD1G0W05SQ5F3F7P5KSWR0FJBJ9NZBXQEVN8Q4JRC94X3JJ3XV3KBYTZ2HTDFV28C3H2SRR0XGNZB4FY85NDZF1G4AEYJJ9QB3C0V8H70YB8RV3FKTNH7XS4K4HFNZHJ5H9VMX5SM9Z2DX37HA5WFH0E2MJBVVF2BWWA5M0HPPSB365RAE2AMD42Q65A96WD80X27SB2ZNQZ8WX0K13FWF85GZ6YNYAJGE1KGN06JDEKE9QD68Z651D7XE8V6664TVVC8M68S7WD0DSXMJQKQ0BNJXNDE29Q7MRX6DA3RW0PZ44B3TKRK0294FPVZTNSTA6XF04002",
- denomPubHash:
- "F5NGBX33DTV4595XZZVK0S2MA1VMXFEJQERE5EBP5DS4QQ9EFRANN7YHWC1TKSHT2K6CQWDBRES8D3DWR0KZF5RET40B4AZXZ0RW1ZG",
- exchangeBaseUrl: "https://exchange.demo.taler.net/",
- feeDeposit: {
- currency: "KUDOS",
- fraction: 1000000,
- value: 0,
- },
- feeRefresh: {
- currency: "KUDOS",
- fraction: 1000000,
- value: 0,
- },
- feeRefund: {
- currency: "KUDOS",
- fraction: 1000000,
- value: 0,
- },
- feeWithdraw: {
- currency: "KUDOS",
- fraction: 1000000,
- value: 0,
- },
- isOffered: true,
- isRevoked: false,
- masterSig:
- "58QEB6C6N7602E572E3JYANVVJ9BRW0V9E2ZFDW940N47YVQDK9SAFPWBN5YGT3G1742AFKQ0CYR4DM2VWV0Z0T1XMEKWN6X2EZ9M0R",
- stampExpireDeposit: {
- t_ms: 1742909388000,
- },
- stampExpireLegal: {
- t_ms: 1900589388000,
- },
- stampExpireWithdraw: {
- t_ms: 1679837388000,
- },
- stampStart: {
- t_ms: 1585229388000,
- },
- status: 0,
- value: {
- currency: "KUDOS",
- fraction: 0,
- value: 2,
- },
- },
- ];
-
- const res = getWithdrawDenomList(amount, denoms);
-
- console.error("cost", Amounts.stringify(res.totalWithdrawCost));
- console.error("withdraw amount", Amounts.stringify(amount));
-
- t.assert(Amounts.cmp(res.totalWithdrawCost, amount) <= 0);
- t.pass();
-});
diff --git a/src/operations/withdraw.ts b/src/operations/withdraw.ts
deleted file mode 100644
index 486375300..000000000
--- a/src/operations/withdraw.ts
+++ /dev/null
@@ -1,756 +0,0 @@
-/*
- 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/>
- */
-
-import { AmountJson, Amounts } from "../util/amounts";
-import {
- DenominationRecord,
- Stores,
- DenominationStatus,
- CoinStatus,
- CoinRecord,
- initRetryInfo,
- updateRetryInfoTimeout,
- CoinSourceType,
- DenominationSelectionInfo,
- PlanchetRecord,
- WithdrawalSourceType,
- DenomSelectionState,
-} from "../types/dbTypes";
-import {
- BankWithdrawDetails,
- ExchangeWithdrawDetails,
- OperationErrorDetails,
- ExchangeListItem,
-} from "../types/walletTypes";
-import {
- codecForWithdrawOperationStatusResponse,
- codecForWithdrawResponse,
- WithdrawUriInfoResponse,
-} from "../types/talerTypes";
-import { InternalWalletState } from "./state";
-import { parseWithdrawUri } from "../util/taleruri";
-import { Logger } from "../util/logging";
-import { updateExchangeFromUrl, getExchangeTrust } from "./exchanges";
-import { WALLET_EXCHANGE_PROTOCOL_VERSION } from "./versions";
-
-import * as LibtoolVersion from "../util/libtoolVersion";
-import { guardOperationException } from "./errors";
-import { NotificationType } from "../types/notifications";
-import {
- getTimestampNow,
- getDurationRemaining,
- timestampCmp,
- timestampSubtractDuraction,
-} from "../util/time";
-import { readSuccessResponseJsonOrThrow } from "../util/http";
-
-const logger = new Logger("withdraw.ts");
-
-function isWithdrawableDenom(d: DenominationRecord): boolean {
- const now = getTimestampNow();
- const started = timestampCmp(now, d.stampStart) >= 0;
- const lastPossibleWithdraw = timestampSubtractDuraction(
- d.stampExpireWithdraw,
- { d_ms: 50 * 1000 },
- );
- const remaining = getDurationRemaining(lastPossibleWithdraw, now);
- const stillOkay = remaining.d_ms !== 0;
- return started && stillOkay && !d.isRevoked;
-}
-
-/**
- * Get a list of denominations (with repetitions possible)
- * whose total value is as close as possible to the available
- * amount, but never larger.
- */
-export function getWithdrawDenomList(
- amountAvailable: AmountJson,
- denoms: DenominationRecord[],
-): DenominationSelectionInfo {
- let remaining = Amounts.copy(amountAvailable);
-
- const selectedDenoms: {
- count: number;
- denom: DenominationRecord;
- }[] = [];
-
- let totalCoinValue = Amounts.getZero(amountAvailable.currency);
- let totalWithdrawCost = Amounts.getZero(amountAvailable.currency);
-
- denoms = denoms.filter(isWithdrawableDenom);
- denoms.sort((d1, d2) => Amounts.cmp(d2.value, d1.value));
-
- for (const d of denoms) {
- let count = 0;
- const cost = Amounts.add(d.value, d.feeWithdraw).amount;
- for (;;) {
- if (Amounts.cmp(remaining, cost) < 0) {
- break;
- }
- remaining = Amounts.sub(remaining, cost).amount;
- count++;
- }
- if (count > 0) {
- totalCoinValue = Amounts.add(
- totalCoinValue,
- Amounts.mult(d.value, count).amount,
- ).amount;
- totalWithdrawCost = Amounts.add(
- totalWithdrawCost,
- Amounts.mult(cost, count).amount,
- ).amount;
- selectedDenoms.push({
- count,
- denom: d,
- });
- }
-
- if (Amounts.isZero(remaining)) {
- break;
- }
- }
-
- return {
- selectedDenoms,
- totalCoinValue,
- totalWithdrawCost,
- };
-}
-
-/**
- * Get information about a withdrawal from
- * a taler://withdraw URI by asking the bank.
- */
-export async function getBankWithdrawalInfo(
- ws: InternalWalletState,
- talerWithdrawUri: string,
-): Promise<BankWithdrawDetails> {
- const uriResult = parseWithdrawUri(talerWithdrawUri);
- if (!uriResult) {
- throw Error(`can't parse URL ${talerWithdrawUri}`);
- }
- const reqUrl = new URL(
- `api/withdraw-operation/${uriResult.withdrawalOperationId}`,
- uriResult.bankIntegrationApiBaseUrl,
- );
- const resp = await ws.http.get(reqUrl.href);
- const status = await readSuccessResponseJsonOrThrow(
- resp,
- codecForWithdrawOperationStatusResponse(),
- );
-
- return {
- amount: Amounts.parseOrThrow(status.amount),
- confirmTransferUrl: status.confirm_transfer_url,
- extractedStatusUrl: reqUrl.href,
- selectionDone: status.selection_done,
- senderWire: status.sender_wire,
- suggestedExchange: status.suggested_exchange,
- transferDone: status.transfer_done,
- wireTypes: status.wire_types,
- };
-}
-
-/**
- * Return denominations that can potentially used for a withdrawal.
- */
-async function getPossibleDenoms(
- ws: InternalWalletState,
- exchangeBaseUrl: string,
-): Promise<DenominationRecord[]> {
- return await ws.db
- .iterIndex(Stores.denominations.exchangeBaseUrlIndex, exchangeBaseUrl)
- .filter((d) => {
- return (
- (d.status === DenominationStatus.Unverified ||
- d.status === DenominationStatus.VerifiedGood) &&
- !d.isRevoked
- );
- });
-}
-
-/**
- * Given a planchet, withdraw a coin from the exchange.
- */
-async function processPlanchet(
- ws: InternalWalletState,
- withdrawalGroupId: string,
- coinIdx: number,
-): Promise<void> {
- const withdrawalGroup = await ws.db.get(
- Stores.withdrawalGroups,
- withdrawalGroupId,
- );
- if (!withdrawalGroup) {
- return;
- }
- let planchet = await ws.db.getIndexed(Stores.planchets.byGroupAndIndex, [
- withdrawalGroupId,
- coinIdx,
- ]);
- if (!planchet) {
- let ci = 0;
- let denomPubHash: string | undefined;
- for (
- let di = 0;
- di < withdrawalGroup.denomsSel.selectedDenoms.length;
- di++
- ) {
- const d = withdrawalGroup.denomsSel.selectedDenoms[di];
- if (coinIdx >= ci && coinIdx < ci + d.count) {
- denomPubHash = d.denomPubHash;
- break;
- }
- ci += d.count;
- }
- if (!denomPubHash) {
- throw Error("invariant violated");
- }
- const denom = await ws.db.getIndexed(
- Stores.denominations.denomPubHashIndex,
- denomPubHash,
- );
- if (!denom) {
- throw Error("invariant violated");
- }
- if (withdrawalGroup.source.type != WithdrawalSourceType.Reserve) {
- throw Error("invariant violated");
- }
- const reserve = await ws.db.get(
- Stores.reserves,
- withdrawalGroup.source.reservePub,
- );
- if (!reserve) {
- throw Error("invariant violated");
- }
- const r = await ws.cryptoApi.createPlanchet({
- denomPub: denom.denomPub,
- feeWithdraw: denom.feeWithdraw,
- reservePriv: reserve.reservePriv,
- reservePub: reserve.reservePub,
- value: denom.value,
- });
- const newPlanchet: PlanchetRecord = {
- blindingKey: r.blindingKey,
- coinEv: r.coinEv,
- coinEvHash: r.coinEvHash,
- coinIdx,
- coinPriv: r.coinPriv,
- coinPub: r.coinPub,
- coinValue: r.coinValue,
- denomPub: r.denomPub,
- denomPubHash: r.denomPubHash,
- isFromTip: false,
- reservePub: r.reservePub,
- withdrawalDone: false,
- withdrawSig: r.withdrawSig,
- withdrawalGroupId: withdrawalGroupId,
- };
- await ws.db.runWithWriteTransaction([Stores.planchets], async (tx) => {
- const p = await tx.getIndexed(Stores.planchets.byGroupAndIndex, [
- withdrawalGroupId,
- coinIdx,
- ]);
- if (p) {
- planchet = p;
- return;
- }
- await tx.put(Stores.planchets, newPlanchet);
- planchet = newPlanchet;
- });
- }
- if (!planchet) {
- throw Error("invariant violated");
- }
- if (planchet.withdrawalDone) {
- logger.warn("processPlanchet: planchet already withdrawn");
- return;
- }
- const exchange = await ws.db.get(
- Stores.exchanges,
- withdrawalGroup.exchangeBaseUrl,
- );
- if (!exchange) {
- logger.error("db inconsistent: exchange for planchet not found");
- return;
- }
-
- const denom = await ws.db.get(Stores.denominations, [
- withdrawalGroup.exchangeBaseUrl,
- planchet.denomPub,
- ]);
-
- if (!denom) {
- console.error("db inconsistent: denom for planchet not found");
- return;
- }
-
- logger.trace(
- `processing planchet #${coinIdx} in withdrawal ${withdrawalGroupId}`,
- );
-
- const wd: any = {};
- wd.denom_pub_hash = planchet.denomPubHash;
- wd.reserve_pub = planchet.reservePub;
- wd.reserve_sig = planchet.withdrawSig;
- wd.coin_ev = planchet.coinEv;
- const reqUrl = new URL(
- `reserves/${planchet.reservePub}/withdraw`,
- exchange.baseUrl,
- ).href;
-
- const resp = await ws.http.postJson(reqUrl, wd);
- const r = await readSuccessResponseJsonOrThrow(
- resp,
- codecForWithdrawResponse(),
- );
-
- logger.trace(`got response for /withdraw`);
-
- const denomSig = await ws.cryptoApi.rsaUnblind(
- r.ev_sig,
- planchet.blindingKey,
- planchet.denomPub,
- );
-
- const isValid = await ws.cryptoApi.rsaVerify(
- planchet.coinPub,
- denomSig,
- planchet.denomPub,
- );
-
- if (!isValid) {
- throw Error("invalid RSA signature by the exchange");
- }
-
- logger.trace(`unblinded and verified`);
-
- const coin: CoinRecord = {
- blindingKey: planchet.blindingKey,
- coinPriv: planchet.coinPriv,
- coinPub: planchet.coinPub,
- currentAmount: planchet.coinValue,
- denomPub: planchet.denomPub,
- denomPubHash: planchet.denomPubHash,
- denomSig,
- exchangeBaseUrl: withdrawalGroup.exchangeBaseUrl,
- status: CoinStatus.Fresh,
- coinSource: {
- type: CoinSourceType.Withdraw,
- coinIndex: coinIdx,
- reservePub: planchet.reservePub,
- withdrawalGroupId: withdrawalGroupId,
- },
- suspended: false,
- };
-
- let withdrawalGroupFinished = false;
-
- const planchetCoinPub = planchet.coinPub;
-
- const success = await ws.db.runWithWriteTransaction(
- [Stores.coins, Stores.withdrawalGroups, Stores.reserves, Stores.planchets],
- async (tx) => {
- const ws = await tx.get(Stores.withdrawalGroups, withdrawalGroupId);
- if (!ws) {
- return false;
- }
- const p = await tx.get(Stores.planchets, planchetCoinPub);
- if (!p) {
- return false;
- }
- if (p.withdrawalDone) {
- // Already withdrawn
- return false;
- }
- p.withdrawalDone = true;
- await tx.put(Stores.planchets, p);
-
- let numTotal = 0;
-
- for (const ds of ws.denomsSel.selectedDenoms) {
- numTotal += ds.count;
- }
-
- let numDone = 0;
-
- await tx
- .iterIndexed(Stores.planchets.byGroup, withdrawalGroupId)
- .forEach((x) => {
- if (x.withdrawalDone) {
- numDone++;
- }
- });
-
- if (numDone > numTotal) {
- throw Error(
- "invariant violated (created more planchets than expected)",
- );
- }
-
- if (numDone == numTotal) {
- ws.timestampFinish = getTimestampNow();
- ws.lastError = undefined;
- ws.retryInfo = initRetryInfo(false);
- withdrawalGroupFinished = true;
- }
- await tx.put(Stores.withdrawalGroups, ws);
- await tx.add(Stores.coins, coin);
- return true;
- },
- );
-
- logger.trace(`withdrawal result stored in DB`);
-
- if (success) {
- ws.notify({
- type: NotificationType.CoinWithdrawn,
- });
- }
-
- if (withdrawalGroupFinished) {
- ws.notify({
- type: NotificationType.WithdrawGroupFinished,
- withdrawalSource: withdrawalGroup.source,
- });
- }
-}
-
-export function denomSelectionInfoToState(
- dsi: DenominationSelectionInfo,
-): DenomSelectionState {
- return {
- selectedDenoms: dsi.selectedDenoms.map((x) => {
- return {
- count: x.count,
- denomPubHash: x.denom.denomPubHash,
- };
- }),
- totalCoinValue: dsi.totalCoinValue,
- totalWithdrawCost: dsi.totalWithdrawCost,
- };
-}
-
-/**
- * Get a list of denominations to withdraw from the given exchange for the
- * given amount, making sure that all denominations' signatures are verified.
- *
- * Writes to the DB in order to record the result from verifying
- * denominations.
- */
-export async function selectWithdrawalDenoms(
- ws: InternalWalletState,
- exchangeBaseUrl: string,
- amount: AmountJson,
-): Promise<DenominationSelectionInfo> {
- const exchange = await ws.db.get(Stores.exchanges, exchangeBaseUrl);
- if (!exchange) {
- logger.error("exchange not found");
- throw Error(`exchange ${exchangeBaseUrl} not found`);
- }
- const exchangeDetails = exchange.details;
- if (!exchangeDetails) {
- logger.error("exchange details not available");
- throw Error(`exchange ${exchangeBaseUrl} details not available`);
- }
-
- let allValid = false;
- let selectedDenoms: DenominationSelectionInfo;
-
- // Find a denomination selection for the requested amount.
- // If a selected denomination has not been validated yet
- // and turns our to be invalid, we try again with the
- // reduced set of denominations.
- do {
- allValid = true;
- const nextPossibleDenoms = await getPossibleDenoms(ws, exchange.baseUrl);
- selectedDenoms = getWithdrawDenomList(amount, nextPossibleDenoms);
- for (const denomSel of selectedDenoms.selectedDenoms) {
- const denom = denomSel.denom;
- if (denom.status === DenominationStatus.Unverified) {
- const valid = await ws.cryptoApi.isValidDenom(
- denom,
- exchangeDetails.masterPublicKey,
- );
- if (!valid) {
- denom.status = DenominationStatus.VerifiedBad;
- allValid = false;
- } else {
- denom.status = DenominationStatus.VerifiedGood;
- }
- await ws.db.put(Stores.denominations, denom);
- }
- }
- } while (selectedDenoms.selectedDenoms.length > 0 && !allValid);
-
- if (Amounts.cmp(selectedDenoms.totalWithdrawCost, amount) > 0) {
- throw Error("Bug: withdrawal coin selection is wrong");
- }
-
- return selectedDenoms;
-}
-
-async function incrementWithdrawalRetry(
- ws: InternalWalletState,
- withdrawalGroupId: string,
- err: OperationErrorDetails | undefined,
-): Promise<void> {
- await ws.db.runWithWriteTransaction([Stores.withdrawalGroups], async (tx) => {
- const wsr = await tx.get(Stores.withdrawalGroups, withdrawalGroupId);
- if (!wsr) {
- return;
- }
- if (!wsr.retryInfo) {
- return;
- }
- wsr.retryInfo.retryCounter++;
- updateRetryInfoTimeout(wsr.retryInfo);
- wsr.lastError = err;
- await tx.put(Stores.withdrawalGroups, wsr);
- });
- if (err) {
- ws.notify({ type: NotificationType.WithdrawOperationError, error: err });
- }
-}
-
-export async function processWithdrawGroup(
- ws: InternalWalletState,
- withdrawalGroupId: string,
- forceNow = false,
-): Promise<void> {
- const onOpErr = (e: OperationErrorDetails): Promise<void> =>
- incrementWithdrawalRetry(ws, withdrawalGroupId, e);
- await guardOperationException(
- () => processWithdrawGroupImpl(ws, withdrawalGroupId, forceNow),
- onOpErr,
- );
-}
-
-async function resetWithdrawalGroupRetry(
- ws: InternalWalletState,
- withdrawalGroupId: string,
-): Promise<void> {
- await ws.db.mutate(Stores.withdrawalGroups, withdrawalGroupId, (x) => {
- if (x.retryInfo.active) {
- x.retryInfo = initRetryInfo();
- }
- return x;
- });
-}
-
-async function processInBatches(
- workGen: Iterator<Promise<void>>,
- batchSize: number,
-): Promise<void> {
- for (;;) {
- const batch: Promise<void>[] = [];
- for (let i = 0; i < batchSize; i++) {
- const wn = workGen.next();
- if (wn.done) {
- break;
- }
- batch.push(wn.value);
- }
- if (batch.length == 0) {
- break;
- }
- logger.trace(`processing withdrawal batch of ${batch.length} elements`);
- await Promise.all(batch);
- }
-}
-
-async function processWithdrawGroupImpl(
- ws: InternalWalletState,
- withdrawalGroupId: string,
- forceNow: boolean,
-): Promise<void> {
- logger.trace("processing withdraw group", withdrawalGroupId);
- if (forceNow) {
- await resetWithdrawalGroupRetry(ws, withdrawalGroupId);
- }
- const withdrawalGroup = await ws.db.get(
- Stores.withdrawalGroups,
- withdrawalGroupId,
- );
- if (!withdrawalGroup) {
- logger.trace("withdraw session doesn't exist");
- return;
- }
-
- const numDenoms = withdrawalGroup.denomsSel.selectedDenoms.length;
- const genWork = function* (): Iterator<Promise<void>> {
- let coinIdx = 0;
- for (let i = 0; i < numDenoms; i++) {
- const count = withdrawalGroup.denomsSel.selectedDenoms[i].count;
- for (let j = 0; j < count; j++) {
- yield processPlanchet(ws, withdrawalGroupId, coinIdx);
- coinIdx++;
- }
- }
- };
-
- // Withdraw coins in batches.
- // The batch size is relatively large
- await processInBatches(genWork(), 10);
-}
-
-export async function getExchangeWithdrawalInfo(
- ws: InternalWalletState,
- baseUrl: string,
- amount: AmountJson,
-): Promise<ExchangeWithdrawDetails> {
- const exchangeInfo = await updateExchangeFromUrl(ws, baseUrl);
- const exchangeDetails = exchangeInfo.details;
- if (!exchangeDetails) {
- throw Error(`exchange ${exchangeInfo.baseUrl} details not available`);
- }
- const exchangeWireInfo = exchangeInfo.wireInfo;
- if (!exchangeWireInfo) {
- throw Error(`exchange ${exchangeInfo.baseUrl} wire details not available`);
- }
-
- const selectedDenoms = await selectWithdrawalDenoms(ws, baseUrl, amount);
- const exchangeWireAccounts: string[] = [];
- for (const account of exchangeWireInfo.accounts) {
- exchangeWireAccounts.push(account.payto_uri);
- }
-
- const { isTrusted, isAudited } = await getExchangeTrust(ws, exchangeInfo);
-
- let earliestDepositExpiration =
- selectedDenoms.selectedDenoms[0].denom.stampExpireDeposit;
- for (let i = 1; i < selectedDenoms.selectedDenoms.length; i++) {
- const expireDeposit =
- selectedDenoms.selectedDenoms[i].denom.stampExpireDeposit;
- if (expireDeposit.t_ms < earliestDepositExpiration.t_ms) {
- earliestDepositExpiration = expireDeposit;
- }
- }
-
- const possibleDenoms = await ws.db
- .iterIndex(Stores.denominations.exchangeBaseUrlIndex, baseUrl)
- .filter((d) => d.isOffered);
-
- const trustedAuditorPubs = [];
- const currencyRecord = await ws.db.get(Stores.currencies, amount.currency);
- if (currencyRecord) {
- trustedAuditorPubs.push(
- ...currencyRecord.auditors.map((a) => a.auditorPub),
- );
- }
-
- let versionMatch;
- if (exchangeDetails.protocolVersion) {
- versionMatch = LibtoolVersion.compare(
- WALLET_EXCHANGE_PROTOCOL_VERSION,
- exchangeDetails.protocolVersion,
- );
-
- if (
- versionMatch &&
- !versionMatch.compatible &&
- versionMatch.currentCmp === -1
- ) {
- console.warn(
- `wallet's support for exchange protocol version ${WALLET_EXCHANGE_PROTOCOL_VERSION} might be outdated ` +
- `(exchange has ${exchangeDetails.protocolVersion}), checking for updates`,
- );
- }
- }
-
- let tosAccepted = false;
-
- if (exchangeInfo.termsOfServiceAcceptedTimestamp) {
- if (
- exchangeInfo.termsOfServiceAcceptedEtag ==
- exchangeInfo.termsOfServiceLastEtag
- ) {
- tosAccepted = true;
- }
- }
-
- const withdrawFee = Amounts.sub(
- selectedDenoms.totalWithdrawCost,
- selectedDenoms.totalCoinValue,
- ).amount;
-
- const ret: ExchangeWithdrawDetails = {
- earliestDepositExpiration,
- exchangeInfo,
- exchangeWireAccounts,
- exchangeVersion: exchangeDetails.protocolVersion || "unknown",
- isAudited,
- isTrusted,
- numOfferedDenoms: possibleDenoms.length,
- overhead: Amounts.sub(amount, selectedDenoms.totalWithdrawCost).amount,
- selectedDenoms,
- trustedAuditorPubs,
- versionMatch,
- walletVersion: WALLET_EXCHANGE_PROTOCOL_VERSION,
- wireFees: exchangeWireInfo,
- withdrawFee,
- termsOfServiceAccepted: tosAccepted,
- };
- return ret;
-}
-
-export async function getWithdrawalDetailsForUri(
- ws: InternalWalletState,
- talerWithdrawUri: string,
-): Promise<WithdrawUriInfoResponse> {
- const info = await getBankWithdrawalInfo(ws, talerWithdrawUri);
- if (info.suggestedExchange) {
- // FIXME: right now the exchange gets permanently added,
- // we might want to only temporarily add it.
- try {
- await updateExchangeFromUrl(ws, info.suggestedExchange);
- } catch (e) {
- // We still continued if it failed, as other exchanges might be available.
- // We don't want to fail if the bank-suggested exchange is broken/offline.
- logger.trace(`querying bank-suggested exchange (${info.suggestedExchange}) failed`)
- }
- }
-
- const exchangesRes: (ExchangeListItem | undefined)[] = await ws.db
- .iter(Stores.exchanges)
- .map((x) => {
- const details = x.details;
- if (!details) {
- return undefined;
- }
- if (!x.addComplete) {
- return undefined;
- }
- if (!x.wireInfo) {
- return undefined;
- }
- if (details.currency !== info.amount.currency) {
- return undefined;
- }
- return {
- exchangeBaseUrl: x.baseUrl,
- currency: details.currency,
- paytoUris: x.wireInfo.accounts.map((x) => x.payto_uri),
- };
- });
- const exchanges = exchangesRes.filter((x) => !!x) as ExchangeListItem[];
-
- return {
- amount: Amounts.stringify(info.amount),
- defaultExchangeBaseUrl: info.suggestedExchange,
- possibleExchanges: exchanges,
- }
-}