aboutsummaryrefslogtreecommitdiff
path: root/src/operations
diff options
context:
space:
mode:
Diffstat (limited to 'src/operations')
-rw-r--r--src/operations/balance.ts158
-rw-r--r--src/operations/errors.ts84
-rw-r--r--src/operations/exchanges.ts505
-rw-r--r--src/operations/history.ts221
-rw-r--r--src/operations/pay.ts1494
-rw-r--r--src/operations/payback.ts93
-rw-r--r--src/operations/pending.ts452
-rw-r--r--src/operations/refresh.ts479
-rw-r--r--src/operations/reserves.ts630
-rw-r--r--src/operations/return.ts267
-rw-r--r--src/operations/state.ts68
-rw-r--r--src/operations/tip.ts305
-rw-r--r--src/operations/withdraw.ts699
13 files changed, 5455 insertions, 0 deletions
diff --git a/src/operations/balance.ts b/src/operations/balance.ts
new file mode 100644
index 000000000..8c8a2a9cf
--- /dev/null
+++ b/src/operations/balance.ts
@@ -0,0 +1,158 @@
+/*
+ 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 { WalletBalance, WalletBalanceEntry } from "../types/walletTypes";
+import { runWithReadTransaction } from "../util/query";
+import { InternalWalletState } from "./state";
+import { Stores, TipRecord, 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");
+
+/**
+ * Get detailed balance information, sliced by exchange and by currency.
+ */
+export async function getBalances(
+ ws: InternalWalletState,
+): Promise<WalletBalance> {
+ logger.trace("starting to compute balance");
+ /**
+ * Add amount to a balance field, both for
+ * the slicing by exchange and currency.
+ */
+ function addTo(
+ balance: WalletBalance,
+ field: keyof WalletBalanceEntry,
+ amount: AmountJson,
+ exchange: string,
+ ): void {
+ const z = Amounts.getZero(amount.currency);
+ const balanceIdentity = {
+ available: z,
+ paybackAmount: z,
+ pendingIncoming: z,
+ pendingPayment: z,
+ pendingIncomingDirty: z,
+ pendingIncomingRefresh: z,
+ pendingIncomingWithdraw: z,
+ };
+ let entryCurr = balance.byCurrency[amount.currency];
+ if (!entryCurr) {
+ balance.byCurrency[amount.currency] = entryCurr = {
+ ...balanceIdentity,
+ };
+ }
+ let entryEx = balance.byExchange[exchange];
+ if (!entryEx) {
+ balance.byExchange[exchange] = entryEx = { ...balanceIdentity };
+ }
+ entryCurr[field] = Amounts.add(entryCurr[field], amount).amount;
+ entryEx[field] = Amounts.add(entryEx[field], amount).amount;
+ }
+
+ const balanceStore = {
+ byCurrency: {},
+ byExchange: {},
+ };
+
+ await runWithReadTransaction(
+ ws.db,
+ [Stores.coins, Stores.refresh, Stores.reserves, Stores.purchases, Stores.withdrawalSession],
+ async tx => {
+ await tx.iter(Stores.coins).forEach(c => {
+ if (c.suspended) {
+ return;
+ }
+ if (c.status === CoinStatus.Fresh) {
+ addTo(balanceStore, "available", c.currentAmount, c.exchangeBaseUrl);
+ }
+ if (c.status === CoinStatus.Dirty) {
+ addTo(
+ balanceStore,
+ "pendingIncoming",
+ c.currentAmount,
+ c.exchangeBaseUrl,
+ );
+ addTo(
+ balanceStore,
+ "pendingIncomingDirty",
+ c.currentAmount,
+ c.exchangeBaseUrl,
+ );
+ }
+ });
+ await tx.iter(Stores.refresh).forEach(r => {
+ // Don't count finished refreshes, since the refresh already resulted
+ // in coins being added to the wallet.
+ if (r.finishedTimestamp) {
+ return;
+ }
+ addTo(
+ balanceStore,
+ "pendingIncoming",
+ r.valueOutput,
+ r.exchangeBaseUrl,
+ );
+ addTo(
+ balanceStore,
+ "pendingIncomingRefresh",
+ r.valueOutput,
+ r.exchangeBaseUrl,
+ );
+ });
+
+ await tx.iter(Stores.withdrawalSession).forEach(wds => {
+ let w = wds.totalCoinValue;
+ for (let i = 0; i < wds.planchets.length; i++) {
+ if (wds.withdrawn[i]) {
+ const p = wds.planchets[i];
+ if (p) {
+ w = Amounts.sub(w, p.coinValue).amount;
+ }
+ }
+ }
+ addTo(
+ balanceStore,
+ "pendingIncoming",
+ w,
+ wds.exchangeBaseUrl,
+ );
+ });
+
+ await tx.iter(Stores.purchases).forEach(t => {
+ if (t.firstSuccessfulPayTimestamp) {
+ return;
+ }
+ for (const c of t.payReq.coins) {
+ addTo(
+ balanceStore,
+ "pendingPayment",
+ Amounts.parseOrThrow(c.contribution),
+ c.exchange_url,
+ );
+ }
+ });
+ },
+ );
+
+ logger.trace("computed balances:", balanceStore);
+ return balanceStore;
+}
diff --git a/src/operations/errors.ts b/src/operations/errors.ts
new file mode 100644
index 000000000..7e97fdb3c
--- /dev/null
+++ b/src/operations/errors.ts
@@ -0,0 +1,84 @@
+import { OperationError } from "../types/walletTypes";
+
+/*
+ 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/>
+ */
+
+/**
+ * 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(message: string) {
+ super(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 {
+ constructor(message: string, public err: OperationError) {
+ super(message);
+
+ // Set the prototype explicitly.
+ Object.setPrototypeOf(this, OperationFailedError.prototype);
+ }
+}
+
+/**
+ * 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: OperationError) => Promise<void>,
+): Promise<T> {
+ try {
+ return await op();
+ } catch (e) {
+ console.log("guard: caught exception");
+ if (e instanceof OperationFailedAndReportedError) {
+ throw e;
+ }
+ if (e instanceof OperationFailedError) {
+ await onOpError(e.err);
+ throw new OperationFailedAndReportedError(e.message);
+ }
+ if (e instanceof Error) {
+ console.log("guard: caught Error");
+ await onOpError({
+ type: "exception",
+ message: e.message,
+ details: {},
+ });
+ throw new OperationFailedAndReportedError(e.message);
+ }
+ console.log("guard: caught something else");
+ await onOpError({
+ type: "exception",
+ message: "non-error exception thrown",
+ details: {
+ value: e.toString(),
+ },
+ });
+ throw new OperationFailedAndReportedError(e.message);
+ }
+} \ No newline at end of file
diff --git a/src/operations/exchanges.ts b/src/operations/exchanges.ts
new file mode 100644
index 000000000..836bce6e4
--- /dev/null
+++ b/src/operations/exchanges.ts
@@ -0,0 +1,505 @@
+/*
+ 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 { WALLET_CACHE_BREAKER_CLIENT_VERSION } from "../wallet";
+import { KeysJson, Denomination, ExchangeWireJson } from "../types/talerTypes";
+import { getTimestampNow, OperationError } from "../types/walletTypes";
+import {
+ ExchangeRecord,
+ ExchangeUpdateStatus,
+ Stores,
+ DenominationRecord,
+ DenominationStatus,
+ WireFee,
+} from "../types/dbTypes";
+import {
+ canonicalizeBaseUrl,
+ extractTalerStamp,
+ extractTalerStampOrThrow,
+} from "../util/helpers";
+import {
+ oneShotGet,
+ oneShotPut,
+ runWithWriteTransaction,
+ oneShotMutate,
+} from "../util/query";
+import * as Amounts from "../util/amounts";
+import { parsePaytoUri } from "../util/payto";
+import {
+ OperationFailedAndReportedError,
+ guardOperationException,
+} from "./errors";
+
+async function denominationRecordFromKeys(
+ ws: InternalWalletState,
+ exchangeBaseUrl: string,
+ denomIn: Denomination,
+): Promise<DenominationRecord> {
+ const denomPubHash = await ws.cryptoApi.hashDenomPub(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,
+ masterSig: denomIn.master_sig,
+ stampExpireDeposit: extractTalerStampOrThrow(denomIn.stamp_expire_deposit),
+ stampExpireLegal: extractTalerStampOrThrow(denomIn.stamp_expire_legal),
+ stampExpireWithdraw: extractTalerStampOrThrow(
+ denomIn.stamp_expire_withdraw,
+ ),
+ stampStart: extractTalerStampOrThrow(denomIn.stamp_start),
+ status: DenominationStatus.Unverified,
+ value: Amounts.parseOrThrow(denomIn.value),
+ };
+ return d;
+}
+
+async function setExchangeError(
+ ws: InternalWalletState,
+ baseUrl: string,
+ err: OperationError,
+): Promise<void> {
+ const mut = (exchange: ExchangeRecord) => {
+ exchange.lastError = err;
+ return exchange;
+ };
+ await oneShotMutate(ws.db, 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 oneShotGet(
+ ws.db,
+ Stores.exchanges,
+ baseUrl,
+ );
+
+ if (existingExchangeRecord?.updateStatus != ExchangeUpdateStatus.FETCH_KEYS) {
+ return;
+ }
+ const keysUrl = new URL("keys", baseUrl);
+ keysUrl.searchParams.set("cacheBreaker", WALLET_CACHE_BREAKER_CLIENT_VERSION);
+
+ let keysResp;
+ try {
+ const r = await ws.http.get(keysUrl.href);
+ if (r.status !== 200) {
+ throw Error(`unexpected status for keys: ${r.status}`);
+ }
+ keysResp = await r.json();
+ } catch (e) {
+ const m = `Fetching keys failed: ${e.message}`;
+ await setExchangeError(ws, baseUrl, {
+ type: "network",
+ details: {
+ requestUrl: e.config?.url,
+ },
+ message: m,
+ });
+ throw new OperationFailedAndReportedError(m);
+ }
+ let exchangeKeysJson: KeysJson;
+ try {
+ exchangeKeysJson = KeysJson.checked(keysResp);
+ } catch (e) {
+ const m = `Parsing /keys response failed: ${e.message}`;
+ await setExchangeError(ws, baseUrl, {
+ type: "protocol-violation",
+ details: {},
+ message: m,
+ });
+ throw new OperationFailedAndReportedError(m);
+ }
+
+ const lastUpdateTimestamp = extractTalerStamp(
+ exchangeKeysJson.list_issue_date,
+ );
+ if (!lastUpdateTimestamp) {
+ const m = `Parsing /keys response failed: invalid list_issue_date.`;
+ await setExchangeError(ws, baseUrl, {
+ type: "protocol-violation",
+ details: {},
+ message: m,
+ });
+ throw new OperationFailedAndReportedError(m);
+ }
+
+ if (exchangeKeysJson.denoms.length === 0) {
+ const m = "exchange doesn't offer any denominations";
+ await setExchangeError(ws, baseUrl, {
+ type: "protocol-violation",
+ details: {},
+ message: m,
+ });
+ throw new OperationFailedAndReportedError(m);
+ }
+
+ const protocolVersion = exchangeKeysJson.version;
+ if (!protocolVersion) {
+ const m = "outdate exchange, no version in /keys response";
+ await setExchangeError(ws, baseUrl, {
+ type: "protocol-violation",
+ details: {},
+ message: m,
+ });
+ throw new OperationFailedAndReportedError(m);
+ }
+
+ const currency = Amounts.parseOrThrow(exchangeKeysJson.denoms[0].value)
+ .currency;
+
+ const newDenominations = await Promise.all(
+ exchangeKeysJson.denoms.map(d =>
+ denominationRecordFromKeys(ws, baseUrl, d),
+ ),
+ );
+
+ await runWithWriteTransaction(
+ ws.db,
+ [Stores.exchanges, Stores.denominations],
+ 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!
+ }
+ r.details = {
+ auditors: exchangeKeysJson.auditors,
+ currency: currency,
+ lastUpdateTime: lastUpdateTimestamp,
+ masterPublicKey: exchangeKeysJson.master_public_key,
+ protocolVersion: protocolVersion,
+ };
+ r.updateStatus = ExchangeUpdateStatus.FETCH_WIRE;
+ 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);
+ }
+ }
+ },
+ );
+}
+
+async function updateExchangeWithTermsOfService(
+ ws: InternalWalletState,
+ exchangeBaseUrl: string,
+) {
+ const exchange = await oneShotGet(ws.db, Stores.exchanges, exchangeBaseUrl);
+ if (!exchange) {
+ return;
+ }
+ if (exchange.updateStatus != ExchangeUpdateStatus.FETCH_TERMS) {
+ 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 });
+ if (resp.status !== 200) {
+ throw Error(`/terms response has unexpected status code (${resp.status})`);
+ }
+
+ const tosText = await resp.text();
+ const tosEtag = resp.headers.get("etag") || undefined;
+
+ await runWithWriteTransaction(ws.db, [Stores.exchanges], async tx => {
+ const r = await tx.get(Stores.exchanges, exchangeBaseUrl);
+ if (!r) {
+ return;
+ }
+ if (r.updateStatus != ExchangeUpdateStatus.FETCH_TERMS) {
+ return;
+ }
+ r.termsOfServiceText = tosText;
+ r.termsOfServiceLastEtag = tosEtag;
+ r.updateStatus = ExchangeUpdateStatus.FINISHED;
+ await tx.put(Stores.exchanges, r);
+ });
+}
+
+export async function acceptExchangeTermsOfService(
+ ws: InternalWalletState,
+ exchangeBaseUrl: string,
+ etag: string | undefined,
+) {
+ await runWithWriteTransaction(ws.db, [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,
+) {
+ const exchange = await oneShotGet(ws.db, Stores.exchanges, exchangeBaseUrl);
+ if (!exchange) {
+ return;
+ }
+ if (exchange.updateStatus != ExchangeUpdateStatus.FETCH_WIRE) {
+ 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);
+ if (resp.status !== 200) {
+ throw Error(`/wire response has unexpected status code (${resp.status})`);
+ }
+ const wiJson = await resp.json();
+ if (!wiJson) {
+ throw Error("/wire response malformed");
+ }
+ const wireInfo = ExchangeWireJson.checked(wiJson);
+ for (const a of wireInfo.accounts) {
+ console.log("validating exchange acct");
+ const isValid = await ws.cryptoApi.isValidWireAccount(
+ a.url,
+ 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 = extractTalerStamp(x.start_date);
+ if (!startStamp) {
+ throw Error("wrong date format");
+ }
+ const endStamp = extractTalerStamp(x.end_date);
+ if (!endStamp) {
+ throw Error("wrong date format");
+ }
+ 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 runWithWriteTransaction(ws.db, [Stores.exchanges], async tx => {
+ const r = await tx.get(Stores.exchanges, exchangeBaseUrl);
+ if (!r) {
+ return;
+ }
+ if (r.updateStatus != ExchangeUpdateStatus.FETCH_WIRE) {
+ return;
+ }
+ r.wireInfo = {
+ accounts: wireInfo.accounts,
+ feesForType: feesForType,
+ };
+ r.updateStatus = ExchangeUpdateStatus.FETCH_TERMS;
+ r.lastError = undefined;
+ await tx.put(Stores.exchanges, r);
+ });
+}
+
+export async function updateExchangeFromUrl(
+ ws: InternalWalletState,
+ baseUrl: string,
+ forceNow: boolean = false,
+): Promise<ExchangeRecord> {
+ const onOpErr = (e: OperationError) => 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: boolean = false,
+): Promise<ExchangeRecord> {
+ const now = getTimestampNow();
+ baseUrl = canonicalizeBaseUrl(baseUrl);
+
+ const r = await oneShotGet(ws.db, Stores.exchanges, baseUrl);
+ if (!r) {
+ const newExchangeRecord: ExchangeRecord = {
+ baseUrl: baseUrl,
+ details: undefined,
+ wireInfo: undefined,
+ updateStatus: ExchangeUpdateStatus.FETCH_KEYS,
+ updateStarted: now,
+ updateReason: "initial",
+ timestampAdded: getTimestampNow(),
+ termsOfServiceAcceptedEtag: undefined,
+ termsOfServiceAcceptedTimestamp: undefined,
+ termsOfServiceLastEtag: undefined,
+ termsOfServiceText: undefined,
+ };
+ await oneShotPut(ws.db, Stores.exchanges, newExchangeRecord);
+ } else {
+ await runWithWriteTransaction(ws.db, [Stores.exchanges], async t => {
+ const rec = await t.get(Stores.exchanges, baseUrl);
+ if (!rec) {
+ return;
+ }
+ if (rec.updateStatus != ExchangeUpdateStatus.FETCH_KEYS && !forceNow) {
+ return;
+ }
+ if (rec.updateStatus != ExchangeUpdateStatus.FETCH_KEYS && forceNow) {
+ rec.updateReason = "forced";
+ }
+ rec.updateStarted = now;
+ rec.updateStatus = ExchangeUpdateStatus.FETCH_KEYS;
+ rec.lastError = undefined;
+ t.put(Stores.exchanges, rec);
+ });
+ }
+
+ await updateExchangeWithKeys(ws, baseUrl);
+ await updateExchangeWithWireInfo(ws, baseUrl);
+ await updateExchangeWithTermsOfService(ws, baseUrl);
+
+ const updatedExchange = await oneShotGet(ws.db, 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 oneShotGet(
+ ws.db,
+ 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 (let account of exchangeWireInfo.accounts) {
+ const res = parsePaytoUri(account.url);
+ if (!res) {
+ continue;
+ }
+ if (supportedTargetTypes.includes(res.targetType)) {
+ return account.url;
+ }
+ }
+ throw Error("no matching exchange account found");
+}
diff --git a/src/operations/history.ts b/src/operations/history.ts
new file mode 100644
index 000000000..9c4bb6a90
--- /dev/null
+++ b/src/operations/history.ts
@@ -0,0 +1,221 @@
+/*
+ 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 { oneShotIter, runWithReadTransaction } from "../util/query";
+import { InternalWalletState } from "./state";
+import { Stores, TipRecord } from "../types/dbTypes";
+import * as Amounts from "../util/amounts";
+import { AmountJson } from "../util/amounts";
+import { HistoryQuery, HistoryEvent } from "../types/history";
+
+/**
+ * Retrive the full event history for this wallet.
+ */
+export async function getHistory(
+ ws: InternalWalletState,
+ historyQuery?: HistoryQuery,
+): Promise<{ history: HistoryEvent[] }> {
+ const history: HistoryEvent[] = [];
+
+ // FIXME: do pagination instead of generating the full history
+ // We uniquely identify history rows via their timestamp.
+ // This works as timestamps are guaranteed to be monotonically
+ // increasing even
+
+ await runWithReadTransaction(
+ ws.db,
+ [
+ Stores.currencies,
+ Stores.coins,
+ Stores.denominations,
+ Stores.exchanges,
+ Stores.proposals,
+ Stores.purchases,
+ Stores.refresh,
+ Stores.reserves,
+ Stores.tips,
+ Stores.withdrawalSession,
+ ],
+ async tx => {
+ await tx.iter(Stores.proposals).forEach(p => {
+ history.push({
+ detail: {},
+ timestamp: p.timestamp,
+ type: "claim-order",
+ explicit: false,
+ });
+ });
+
+ await tx.iter(Stores.withdrawalSession).forEach(w => {
+ history.push({
+ detail: {
+ withdrawalAmount: w.rawWithdrawalAmount,
+ },
+ timestamp: w.startTimestamp,
+ type: "withdraw-started",
+ explicit: false,
+ });
+ if (w.finishTimestamp) {
+ history.push({
+ detail: {
+ withdrawalAmount: w.rawWithdrawalAmount,
+ },
+ timestamp: w.finishTimestamp,
+ type: "withdraw-finished",
+ explicit: false,
+ });
+ }
+ });
+
+ await tx.iter(Stores.purchases).forEach(p => {
+ history.push({
+ detail: {
+ amount: p.contractTerms.amount,
+ contractTermsHash: p.contractTermsHash,
+ fulfillmentUrl: p.contractTerms.fulfillment_url,
+ merchantName: p.contractTerms.merchant.name,
+ },
+ timestamp: p.acceptTimestamp,
+ type: "pay-started",
+ explicit: false,
+ });
+ if (p.firstSuccessfulPayTimestamp) {
+ history.push({
+ detail: {
+ amount: p.contractTerms.amount,
+ contractTermsHash: p.contractTermsHash,
+ fulfillmentUrl: p.contractTerms.fulfillment_url,
+ merchantName: p.contractTerms.merchant.name,
+ },
+ timestamp: p.firstSuccessfulPayTimestamp,
+ type: "pay-finished",
+ explicit: false,
+ });
+ }
+ if (p.lastRefundStatusTimestamp) {
+ const contractAmount = Amounts.parseOrThrow(p.contractTerms.amount);
+ const amountsPending = Object.keys(p.refundsPending).map(x =>
+ Amounts.parseOrThrow(p.refundsPending[x].refund_amount),
+ );
+ const amountsDone = Object.keys(p.refundsDone).map(x =>
+ Amounts.parseOrThrow(p.refundsDone[x].refund_amount),
+ );
+ const amounts: AmountJson[] = amountsPending.concat(amountsDone);
+ const amount = Amounts.add(
+ Amounts.getZero(contractAmount.currency),
+ ...amounts,
+ ).amount;
+
+ history.push({
+ detail: {
+ contractTermsHash: p.contractTermsHash,
+ fulfillmentUrl: p.contractTerms.fulfillment_url,
+ merchantName: p.contractTerms.merchant.name,
+ refundAmount: amount,
+ },
+ timestamp: p.lastRefundStatusTimestamp,
+ type: "refund",
+ explicit: false,
+ });
+ }
+ });
+
+ await tx.iter(Stores.reserves).forEach(r => {
+ const reserveType = r.bankWithdrawStatusUrl ? "taler-bank" : "manual";
+ history.push({
+ detail: {
+ exchangeBaseUrl: r.exchangeBaseUrl,
+ requestedAmount: Amounts.toString(r.initiallyRequestedAmount),
+ reservePub: r.reservePub,
+ reserveType,
+ bankWithdrawStatusUrl: r.bankWithdrawStatusUrl,
+ },
+ timestamp: r.created,
+ type: "reserve-created",
+ explicit: false,
+ });
+ if (r.timestampConfirmed) {
+ history.push({
+ detail: {
+ exchangeBaseUrl: r.exchangeBaseUrl,
+ requestedAmount: Amounts.toString(r.initiallyRequestedAmount),
+ reservePub: r.reservePub,
+ reserveType,
+ bankWithdrawStatusUrl: r.bankWithdrawStatusUrl,
+ },
+ timestamp: r.created,
+ type: "reserve-confirmed",
+ explicit: false,
+ });
+ }
+ });
+
+ await tx.iter(Stores.tips).forEach(tip => {
+ history.push({
+ detail: {
+ accepted: tip.accepted,
+ amount: tip.amount,
+ merchantBaseUrl: tip.merchantBaseUrl,
+ tipId: tip.merchantTipId,
+ },
+ timestamp: tip.createdTimestamp,
+ explicit: false,
+ type: "tip",
+ });
+ });
+
+ await tx.iter(Stores.exchanges).forEach(exchange => {
+ history.push({
+ type: "exchange-added",
+ explicit: false,
+ timestamp: exchange.timestampAdded,
+ detail: {
+ exchangeBaseUrl: exchange.baseUrl,
+ },
+ });
+ });
+
+ await tx.iter(Stores.refresh).forEach((r) => {
+ history.push({
+ type: "refresh-started",
+ explicit: false,
+ timestamp: r.created,
+ detail: {
+ refreshSessionId: r.refreshSessionId,
+ },
+ });
+ if (r.finishedTimestamp) {
+ history.push({
+ type: "refresh-finished",
+ explicit: false,
+ timestamp: r.finishedTimestamp,
+ detail: {
+ refreshSessionId: r.refreshSessionId,
+ },
+ });
+ }
+
+ });
+ },
+ );
+
+ history.sort((h1, h2) => Math.sign(h1.timestamp.t_ms - h2.timestamp.t_ms));
+
+ return { history };
+}
diff --git a/src/operations/pay.ts b/src/operations/pay.ts
new file mode 100644
index 000000000..08d227927
--- /dev/null
+++ b/src/operations/pay.ts
@@ -0,0 +1,1494 @@
+/*
+ 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 { AmountJson } from "../util/amounts";
+import {
+ Auditor,
+ ExchangeHandle,
+ MerchantRefundResponse,
+ PayReq,
+ Proposal,
+ ContractTerms,
+ MerchantRefundPermission,
+ RefundRequest,
+} from "../types/talerTypes";
+import {
+ Timestamp,
+ CoinSelectionResult,
+ CoinWithDenom,
+ PayCoinInfo,
+ getTimestampNow,
+ PreparePayResult,
+ ConfirmPayResult,
+ OperationError,
+} from "../types/walletTypes";
+import {
+ oneShotIter,
+ oneShotIterIndex,
+ oneShotGet,
+ runWithWriteTransaction,
+ oneShotPut,
+ oneShotGetIndexed,
+ oneShotMutate,
+} from "../util/query";
+import {
+ Stores,
+ CoinStatus,
+ DenominationRecord,
+ ProposalRecord,
+ PurchaseRecord,
+ CoinRecord,
+ ProposalStatus,
+ initRetryInfo,
+ updateRetryInfoTimeout,
+} from "../types/dbTypes";
+import * as Amounts from "../util/amounts";
+import {
+ amountToPretty,
+ strcmp,
+ canonicalJson,
+ extractTalerStampOrThrow,
+ extractTalerDurationOrThrow,
+ extractTalerDuration,
+} from "../util/helpers";
+import { Logger } from "../util/logging";
+import { InternalWalletState } from "./state";
+import {
+ parsePayUri,
+ parseRefundUri,
+ getOrderDownloadUrl,
+} from "../util/taleruri";
+import { getTotalRefreshCost, refresh } from "./refresh";
+import { encodeCrock, getRandomBytes } from "../crypto/talerCrypto";
+import { guardOperationException } from "./errors";
+import { assertUnreachable } from "../util/assertUnreachable";
+import { NotificationType } from "../types/notifications";
+
+export interface SpeculativePayData {
+ payCoinInfo: PayCoinInfo;
+ exchangeUrl: string;
+ orderDownloadId: string;
+ proposal: ProposalRecord;
+}
+
+interface CoinsForPaymentArgs {
+ allowedAuditors: Auditor[];
+ allowedExchanges: ExchangeHandle[];
+ depositFeeLimit: AmountJson;
+ paymentAmount: AmountJson;
+ wireFeeAmortization: number;
+ wireFeeLimit: AmountJson;
+ wireFeeTime: Timestamp;
+ wireMethod: string;
+}
+
+interface SelectPayCoinsResult {
+ cds: CoinWithDenom[];
+ totalFees: AmountJson;
+}
+
+const logger = new Logger("pay.ts");
+
+/**
+ * Select coins for a payment under the merchant's constraints.
+ *
+ * @param denoms all available denoms, used to compute refresh fees
+ */
+export function selectPayCoins(
+ denoms: DenominationRecord[],
+ cds: CoinWithDenom[],
+ paymentAmount: AmountJson,
+ depositFeeLimit: AmountJson,
+): SelectPayCoinsResult | undefined {
+ if (cds.length === 0) {
+ return undefined;
+ }
+ // Sort by ascending deposit fee and denomPub if deposit fee is the same
+ // (to guarantee deterministic results)
+ cds.sort(
+ (o1, o2) =>
+ Amounts.cmp(o1.denom.feeDeposit, o2.denom.feeDeposit) ||
+ strcmp(o1.denom.denomPub, o2.denom.denomPub),
+ );
+ const currency = cds[0].denom.value.currency;
+ const cdsResult: CoinWithDenom[] = [];
+ let accDepositFee: AmountJson = Amounts.getZero(currency);
+ let accAmount: AmountJson = Amounts.getZero(currency);
+ for (const { coin, denom } of cds) {
+ if (coin.suspended) {
+ continue;
+ }
+ if (coin.status !== CoinStatus.Fresh) {
+ continue;
+ }
+ if (Amounts.cmp(denom.feeDeposit, coin.currentAmount) >= 0) {
+ continue;
+ }
+ cdsResult.push({ coin, denom });
+ accDepositFee = Amounts.add(denom.feeDeposit, accDepositFee).amount;
+ let leftAmount = Amounts.sub(
+ coin.currentAmount,
+ Amounts.sub(paymentAmount, accAmount).amount,
+ ).amount;
+ accAmount = Amounts.add(coin.currentAmount, accAmount).amount;
+ const coversAmount = Amounts.cmp(accAmount, paymentAmount) >= 0;
+ const coversAmountWithFee =
+ Amounts.cmp(
+ accAmount,
+ Amounts.add(paymentAmount, denom.feeDeposit).amount,
+ ) >= 0;
+ const isBelowFee = Amounts.cmp(accDepositFee, depositFeeLimit) <= 0;
+
+ logger.trace("candidate coin selection", {
+ coversAmount,
+ isBelowFee,
+ accDepositFee,
+ accAmount,
+ paymentAmount,
+ });
+
+ if ((coversAmount && isBelowFee) || coversAmountWithFee) {
+ const depositFeeToCover = Amounts.sub(accDepositFee, depositFeeLimit)
+ .amount;
+ leftAmount = Amounts.sub(leftAmount, depositFeeToCover).amount;
+ logger.trace("deposit fee to cover", amountToPretty(depositFeeToCover));
+ let totalFees: AmountJson = Amounts.getZero(currency);
+ if (coversAmountWithFee && !isBelowFee) {
+ // these are the fees the customer has to pay
+ // because the merchant doesn't cover them
+ totalFees = Amounts.sub(depositFeeLimit, accDepositFee).amount;
+ }
+ totalFees = Amounts.add(
+ totalFees,
+ getTotalRefreshCost(denoms, denom, leftAmount),
+ ).amount;
+ return { cds: cdsResult, totalFees };
+ }
+ }
+ return undefined;
+}
+
+/**
+ * Get exchanges and associated coins that are still spendable, but only
+ * if the sum the coins' remaining value covers the payment amount and fees.
+ */
+async function getCoinsForPayment(
+ ws: InternalWalletState,
+ args: CoinsForPaymentArgs,
+): Promise<CoinSelectionResult | undefined> {
+ const {
+ allowedAuditors,
+ allowedExchanges,
+ depositFeeLimit,
+ paymentAmount,
+ wireFeeAmortization,
+ wireFeeLimit,
+ wireFeeTime,
+ wireMethod,
+ } = args;
+
+ let remainingAmount = paymentAmount;
+
+ const exchanges = await oneShotIter(ws.db, Stores.exchanges).toArray();
+
+ for (const exchange of exchanges) {
+ let isOkay: boolean = false;
+ const exchangeDetails = exchange.details;
+ if (!exchangeDetails) {
+ continue;
+ }
+ const exchangeFees = exchange.wireInfo;
+ if (!exchangeFees) {
+ continue;
+ }
+
+ // is the exchange explicitly allowed?
+ for (const allowedExchange of allowedExchanges) {
+ if (allowedExchange.master_pub === exchangeDetails.masterPublicKey) {
+ isOkay = true;
+ break;
+ }
+ }
+
+ // is the exchange allowed because of one of its auditors?
+ if (!isOkay) {
+ for (const allowedAuditor of allowedAuditors) {
+ for (const auditor of exchangeDetails.auditors) {
+ if (auditor.auditor_pub === allowedAuditor.auditor_pub) {
+ isOkay = true;
+ break;
+ }
+ }
+ if (isOkay) {
+ break;
+ }
+ }
+ }
+
+ if (!isOkay) {
+ continue;
+ }
+
+ const coins = await oneShotIterIndex(
+ ws.db,
+ Stores.coins.exchangeBaseUrlIndex,
+ exchange.baseUrl,
+ ).toArray();
+
+ const denoms = await oneShotIterIndex(
+ ws.db,
+ Stores.denominations.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 oneShotGet(ws.db, Stores.denominations, [
+ exchange.baseUrl,
+ coins[0].denomPub,
+ ]);
+ if (!firstDenom) {
+ throw Error("db inconsistent");
+ }
+ const currency = firstDenom.value.currency;
+ const cds: CoinWithDenom[] = [];
+ for (const coin of coins) {
+ const denom = await oneShotGet(ws.db, 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;
+ }
+ cds.push({ coin, denom });
+ }
+
+ let totalFees = Amounts.getZero(currency);
+ let wireFee: AmountJson | undefined;
+ for (const fee of exchangeFees.feesForType[wireMethod] || []) {
+ if (fee.startStamp <= wireFeeTime && fee.endStamp >= wireFeeTime) {
+ wireFee = fee.wireFee;
+ break;
+ }
+ }
+
+ if (wireFee) {
+ const amortizedWireFee = Amounts.divide(wireFee, wireFeeAmortization);
+ if (Amounts.cmp(wireFeeLimit, amortizedWireFee) < 0) {
+ totalFees = Amounts.add(amortizedWireFee, totalFees).amount;
+ remainingAmount = Amounts.add(amortizedWireFee, remainingAmount).amount;
+ }
+ }
+
+ const res = selectPayCoins(denoms, cds, remainingAmount, depositFeeLimit);
+
+ if (res) {
+ totalFees = Amounts.add(totalFees, res.totalFees).amount;
+ return {
+ cds: res.cds,
+ exchangeUrl: exchange.baseUrl,
+ totalAmount: remainingAmount,
+ totalFees,
+ };
+ }
+ }
+ 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,
+ payCoinInfo: PayCoinInfo,
+ chosenExchange: string,
+ 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 payReq: PayReq = {
+ coins: payCoinInfo.sigs,
+ merchant_pub: d.contractTerms.merchant_pub,
+ mode: "pay",
+ order_id: d.contractTerms.order_id,
+ };
+ const t: PurchaseRecord = {
+ abortDone: false,
+ abortRequested: false,
+ contractTerms: d.contractTerms,
+ contractTermsHash: d.contractTermsHash,
+ lastSessionId: sessionId,
+ merchantSig: d.merchantSig,
+ payReq,
+ refundsDone: {},
+ refundsPending: {},
+ acceptTimestamp: getTimestampNow(),
+ lastRefundStatusTimestamp: undefined,
+ proposalId: proposal.proposalId,
+ lastPayError: undefined,
+ lastRefundStatusError: undefined,
+ payRetryInfo: initRetryInfo(),
+ refundStatusRetryInfo: initRetryInfo(),
+ refundStatusRequested: false,
+ lastRefundApplyError: undefined,
+ refundApplyRetryInfo: initRetryInfo(),
+ firstSuccessfulPayTimestamp: undefined,
+ autoRefundDeadline: undefined,
+ paymentSubmitPending: true,
+ };
+
+ await runWithWriteTransaction(
+ ws.db,
+ [Stores.coins, Stores.purchases, Stores.proposals],
+ 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 c of payCoinInfo.updatedCoins) {
+ await tx.put(Stores.coins, c);
+ }
+ },
+ );
+
+ ws.notify({
+ type: NotificationType.ProposalAccepted,
+ proposalId: proposal.proposalId,
+ });
+ return t;
+}
+
+function getNextUrl(contractTerms: ContractTerms): string {
+ const f = contractTerms.fulfillment_url;
+ if (f.startsWith("http://") || f.startsWith("https://")) {
+ const fu = new URL(contractTerms.fulfillment_url);
+ fu.searchParams.set("order_id", contractTerms.order_id);
+ return fu.href;
+ } else {
+ return f;
+ }
+}
+
+export async function abortFailedPayment(
+ ws: InternalWalletState,
+ proposalId: string,
+): Promise<void> {
+ const purchase = await oneShotGet(ws.db, Stores.purchases, proposalId);
+ if (!purchase) {
+ throw Error("Purchase not found, unable to abort with refund");
+ }
+ if (purchase.firstSuccessfulPayTimestamp) {
+ throw Error("Purchase already finished, not aborting");
+ }
+ if (purchase.abortDone) {
+ console.warn("abort requested on already aborted purchase");
+ return;
+ }
+
+ purchase.abortRequested = true;
+
+ // From now on, we can't retry payment anymore,
+ // so mark this in the DB in case the /pay abort
+ // does not complete on the first try.
+ await oneShotPut(ws.db, Stores.purchases, purchase);
+
+ let resp;
+
+ const abortReq = { ...purchase.payReq, mode: "abort-refund" };
+
+ const payUrl = new URL("pay", purchase.contractTerms.merchant_base_url).href;
+
+ try {
+ resp = await ws.http.postJson(payUrl, abortReq);
+ } catch (e) {
+ // Gives the user the option to retry / abort and refresh
+ console.log("aborting payment failed", e);
+ throw e;
+ }
+
+ if (resp.status !== 200) {
+ throw Error(`unexpected status for /pay (${resp.status})`);
+ }
+
+ const refundResponse = MerchantRefundResponse.checked(await resp.json());
+ await acceptRefundResponse(ws, purchase.proposalId, refundResponse);
+
+ await runWithWriteTransaction(ws.db, [Stores.purchases], async tx => {
+ const p = await tx.get(Stores.purchases, proposalId);
+ if (!p) {
+ return;
+ }
+ p.abortDone = true;
+ await tx.put(Stores.purchases, p);
+ });
+}
+
+async function incrementProposalRetry(
+ ws: InternalWalletState,
+ proposalId: string,
+ err: OperationError | undefined,
+): Promise<void> {
+ await runWithWriteTransaction(ws.db, [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);
+ });
+ ws.notify({ type: NotificationType.ProposalOperationError });
+}
+
+async function incrementPurchasePayRetry(
+ ws: InternalWalletState,
+ proposalId: string,
+ err: OperationError | undefined,
+): Promise<void> {
+ console.log("incrementing purchase pay retry with error", err);
+ await runWithWriteTransaction(ws.db, [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);
+ });
+ ws.notify({ type: NotificationType.PayOperationError });
+}
+
+async function incrementPurchaseQueryRefundRetry(
+ ws: InternalWalletState,
+ proposalId: string,
+ err: OperationError | undefined,
+): Promise<void> {
+ console.log("incrementing purchase refund query retry with error", err);
+ await runWithWriteTransaction(ws.db, [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);
+ });
+ ws.notify({ type: NotificationType.RefundStatusOperationError });
+}
+
+async function incrementPurchaseApplyRefundRetry(
+ ws: InternalWalletState,
+ proposalId: string,
+ err: OperationError | undefined,
+): Promise<void> {
+ console.log("incrementing purchase refund apply retry with error", err);
+ await runWithWriteTransaction(ws.db, [Stores.purchases], async tx => {
+ const pr = await tx.get(Stores.purchases, proposalId);
+ if (!pr) {
+ return;
+ }
+ if (!pr.refundApplyRetryInfo) {
+ return;
+ }
+ pr.refundApplyRetryInfo.retryCounter++;
+ updateRetryInfoTimeout(pr.refundStatusRetryInfo);
+ pr.lastRefundApplyError = err;
+ await tx.put(Stores.purchases, pr);
+ });
+ ws.notify({ type: NotificationType.RefundApplyOperationError });
+}
+
+export async function processDownloadProposal(
+ ws: InternalWalletState,
+ proposalId: string,
+ forceNow: boolean = false,
+): Promise<void> {
+ const onOpErr = (err: OperationError) =>
+ incrementProposalRetry(ws, proposalId, err);
+ await guardOperationException(
+ () => processDownloadProposalImpl(ws, proposalId, forceNow),
+ onOpErr,
+ );
+}
+
+async function resetDownloadProposalRetry(
+ ws: InternalWalletState,
+ proposalId: string,
+) {
+ await oneShotMutate(ws.db, 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 oneShotGet(ws.db, Stores.proposals, proposalId);
+ if (!proposal) {
+ return;
+ }
+ if (proposal.proposalStatus != ProposalStatus.DOWNLOADING) {
+ return;
+ }
+
+ const parsedUrl = new URL(
+ getOrderDownloadUrl(proposal.merchantBaseUrl, proposal.orderId),
+ );
+ parsedUrl.searchParams.set("nonce", proposal.noncePub);
+ const urlWithNonce = parsedUrl.href;
+ console.log("downloading contract from '" + urlWithNonce + "'");
+ let resp;
+ try {
+ resp = await ws.http.get(urlWithNonce);
+ } catch (e) {
+ console.log("contract download failed", e);
+ throw e;
+ }
+
+ if (resp.status !== 200) {
+ throw Error(`contract download failed with status ${resp.status}`);
+ }
+
+ const proposalResp = Proposal.checked(await resp.json());
+
+ const contractTermsHash = await ws.cryptoApi.hashString(
+ canonicalJson(proposalResp.contract_terms),
+ );
+
+ const fulfillmentUrl = proposalResp.contract_terms.fulfillment_url;
+
+ await runWithWriteTransaction(
+ ws.db,
+ [Stores.proposals, Stores.purchases],
+ async tx => {
+ const p = await tx.get(Stores.proposals, proposalId);
+ if (!p) {
+ return;
+ }
+ if (p.proposalStatus !== ProposalStatus.DOWNLOADING) {
+ return;
+ }
+ 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.download = {
+ contractTerms: proposalResp.contract_terms,
+ merchantSig: proposalResp.sig,
+ contractTermsHash,
+ };
+ 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,
+): Promise<string> {
+ const oldProposal = await oneShotGetIndexed(
+ ws.db,
+ 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,
+ timestamp: getTimestampNow(),
+ merchantBaseUrl,
+ orderId,
+ proposalId: proposalId,
+ proposalStatus: ProposalStatus.DOWNLOADING,
+ repurchaseProposalId: undefined,
+ retryInfo: initRetryInfo(),
+ lastError: undefined,
+ downloadSessionId: sessionId,
+ };
+
+ await runWithWriteTransaction(ws.db, [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 oneShotGet(ws.db, 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;
+ let resp;
+ const payReq = { ...purchase.payReq, session_id: sessionId };
+
+ console.log("paying with session ID", sessionId);
+
+ const payUrl = new URL("pay", purchase.contractTerms.merchant_base_url).href;
+
+ try {
+ resp = await ws.http.postJson(payUrl, payReq);
+ } catch (e) {
+ // Gives the user the option to retry / abort and refresh
+ console.log("payment failed", e);
+ throw e;
+ }
+ if (resp.status !== 200) {
+ throw Error(`unexpected status (${resp.status}) for /pay`);
+ }
+ const merchantResp = await resp.json();
+ console.log("got success from pay URL", merchantResp);
+
+ const merchantPub = purchase.contractTerms.merchant_pub;
+ const valid: boolean = await ws.cryptoApi.isValidPaymentSignature(
+ merchantResp.sig,
+ purchase.contractTermsHash,
+ merchantPub,
+ );
+ if (!valid) {
+ console.error("merchant payment signature invalid");
+ // FIXME: properly display error
+ throw Error("merchant payment signature invalid");
+ }
+ const isFirst = purchase.firstSuccessfulPayTimestamp === undefined;
+ purchase.firstSuccessfulPayTimestamp = getTimestampNow();
+ purchase.paymentSubmitPending = false;
+ purchase.lastPayError = undefined;
+ purchase.payRetryInfo = initRetryInfo(false);
+ if (isFirst) {
+ const ar = purchase.contractTerms.auto_refund;
+ if (ar) {
+ console.log("auto_refund present");
+ const autoRefundDelay = extractTalerDuration(ar);
+ console.log("auto_refund valid", autoRefundDelay);
+ if (autoRefundDelay) {
+ purchase.refundStatusRequested = true;
+ purchase.refundStatusRetryInfo = initRetryInfo();
+ purchase.lastRefundStatusError = undefined;
+ purchase.autoRefundDeadline = {
+ t_ms: getTimestampNow().t_ms + autoRefundDelay.d_ms,
+ };
+ }
+ }
+ }
+
+ const modifiedCoins: CoinRecord[] = [];
+ for (const pc of purchase.payReq.coins) {
+ const c = await oneShotGet(ws.db, Stores.coins, pc.coin_pub);
+ if (!c) {
+ console.error("coin not found");
+ throw Error("coin used in payment not found");
+ }
+ c.status = CoinStatus.Dirty;
+ modifiedCoins.push(c);
+ }
+
+ await runWithWriteTransaction(
+ ws.db,
+ [Stores.coins, Stores.purchases],
+ async tx => {
+ for (let c of modifiedCoins) {
+ await tx.put(Stores.coins, c);
+ }
+ await tx.put(Stores.purchases, purchase);
+ },
+ );
+
+ for (const c of purchase.payReq.coins) {
+ refresh(ws, c.coin_pub).catch(e => {
+ console.log("error in refreshing after payment:", e);
+ });
+ }
+
+ const nextUrl = getNextUrl(purchase.contractTerms);
+ ws.cachedNextUrl[purchase.contractTerms.fulfillment_url] = {
+ 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 preparePay(
+ ws: InternalWalletState,
+ talerPayUri: string,
+): Promise<PreparePayResult> {
+ const uriResult = parsePayUri(talerPayUri);
+
+ if (!uriResult) {
+ return {
+ status: "error",
+ error: "URI not supported",
+ };
+ }
+
+ let proposalId = await startDownloadProposal(
+ ws,
+ uriResult.merchantBaseUrl,
+ uriResult.orderId,
+ uriResult.sessionId,
+ );
+
+ let proposal = await oneShotGet(ws.db, 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 oneShotGet(ws.db, 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 contractTerms = d.contractTerms;
+ const merchantSig = d.merchantSig;
+ if (!contractTerms || !merchantSig) {
+ throw Error("BUG: proposal is in invalid state");
+ }
+
+ proposalId = proposal.proposalId;
+
+ // First check if we already payed for it.
+ const purchase = await oneShotGet(ws.db, Stores.purchases, proposalId);
+
+ if (!purchase) {
+ const paymentAmount = Amounts.parseOrThrow(contractTerms.amount);
+ let wireFeeLimit;
+ if (contractTerms.max_wire_fee) {
+ wireFeeLimit = Amounts.parseOrThrow(contractTerms.max_wire_fee);
+ } else {
+ wireFeeLimit = Amounts.getZero(paymentAmount.currency);
+ }
+ // If not already payed, check if we could pay for it.
+ const res = await getCoinsForPayment(ws, {
+ allowedAuditors: contractTerms.auditors,
+ allowedExchanges: contractTerms.exchanges,
+ depositFeeLimit: Amounts.parseOrThrow(contractTerms.max_fee),
+ paymentAmount,
+ wireFeeAmortization: contractTerms.wire_fee_amortization || 1,
+ wireFeeLimit,
+ wireFeeTime: extractTalerStampOrThrow(contractTerms.timestamp),
+ wireMethod: contractTerms.wire_method,
+ });
+
+ if (!res) {
+ console.log("not confirming payment, insufficient coins");
+ return {
+ status: "insufficient-balance",
+ contractTerms: contractTerms,
+ proposalId: proposal.proposalId,
+ };
+ }
+
+ // Only create speculative signature if we don't already have one for this proposal
+ if (
+ !ws.speculativePayData ||
+ (ws.speculativePayData &&
+ ws.speculativePayData.orderDownloadId !== proposalId)
+ ) {
+ const { exchangeUrl, cds, totalAmount } = res;
+ const payCoinInfo = await ws.cryptoApi.signDeposit(
+ contractTerms,
+ cds,
+ totalAmount,
+ );
+ ws.speculativePayData = {
+ exchangeUrl,
+ payCoinInfo,
+ proposal,
+ orderDownloadId: proposalId,
+ };
+ logger.trace("created speculative pay data for payment");
+ }
+
+ return {
+ status: "payment-possible",
+ contractTerms: contractTerms,
+ proposalId: proposal.proposalId,
+ totalFees: res.totalFees,
+ };
+ }
+
+ if (uriResult.sessionId) {
+ await submitPay(ws, proposalId);
+ }
+
+ return {
+ status: "paid",
+ contractTerms: purchase.contractTerms,
+ nextUrl: getNextUrl(purchase.contractTerms),
+ };
+}
+
+/**
+ * Get the speculative pay data, but only if coins have not changed in between.
+ */
+async function getSpeculativePayData(
+ ws: InternalWalletState,
+ proposalId: string,
+): Promise<SpeculativePayData | undefined> {
+ const sp = ws.speculativePayData;
+ if (!sp) {
+ return;
+ }
+ if (sp.orderDownloadId !== proposalId) {
+ return;
+ }
+ const coinKeys = sp.payCoinInfo.updatedCoins.map(x => x.coinPub);
+ const coins: CoinRecord[] = [];
+ for (let coinKey of coinKeys) {
+ const cc = await oneShotGet(ws.db, Stores.coins, coinKey);
+ if (cc) {
+ coins.push(cc);
+ }
+ }
+ for (let i = 0; i < coins.length; i++) {
+ const specCoin = sp.payCoinInfo.originalCoins[i];
+ const currentCoin = coins[i];
+
+ // Coin does not exist anymore!
+ if (!currentCoin) {
+ return;
+ }
+ if (Amounts.cmp(specCoin.currentAmount, currentCoin.currentAmount) !== 0) {
+ return;
+ }
+ }
+ return sp;
+}
+
+/**
+ * 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 oneShotGet(ws.db, 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 oneShotGet(ws.db, Stores.purchases, d.contractTermsHash);
+
+ if (purchase) {
+ if (
+ sessionIdOverride !== undefined &&
+ sessionIdOverride != purchase.lastSessionId
+ ) {
+ logger.trace(`changing session ID to ${sessionIdOverride}`);
+ await oneShotMutate(ws.db, 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 contractAmount = Amounts.parseOrThrow(d.contractTerms.amount);
+
+ let wireFeeLimit;
+ if (!d.contractTerms.max_wire_fee) {
+ wireFeeLimit = Amounts.getZero(contractAmount.currency);
+ } else {
+ wireFeeLimit = Amounts.parseOrThrow(d.contractTerms.max_wire_fee);
+ }
+
+ const res = await getCoinsForPayment(ws, {
+ allowedAuditors: d.contractTerms.auditors,
+ allowedExchanges: d.contractTerms.exchanges,
+ depositFeeLimit: Amounts.parseOrThrow(d.contractTerms.max_fee),
+ paymentAmount: Amounts.parseOrThrow(d.contractTerms.amount),
+ wireFeeAmortization: d.contractTerms.wire_fee_amortization || 1,
+ wireFeeLimit,
+ wireFeeTime: extractTalerStampOrThrow(d.contractTerms.timestamp),
+ wireMethod: d.contractTerms.wire_method,
+ });
+
+ logger.trace("coin selection result", res);
+
+ if (!res) {
+ // Should not happen, since checkPay should be called first
+ console.log("not confirming payment, insufficient coins");
+ throw Error("insufficient balance");
+ }
+
+ const sd = await getSpeculativePayData(ws, proposalId);
+ if (!sd) {
+ const { exchangeUrl, cds, totalAmount } = res;
+ const payCoinInfo = await ws.cryptoApi.signDeposit(
+ d.contractTerms,
+ cds,
+ totalAmount,
+ );
+ purchase = await recordConfirmPay(
+ ws,
+ proposal,
+ payCoinInfo,
+ exchangeUrl,
+ sessionIdOverride,
+ );
+ } else {
+ purchase = await recordConfirmPay(
+ ws,
+ sd.proposal,
+ sd.payCoinInfo,
+ sd.exchangeUrl,
+ sessionIdOverride,
+ );
+ }
+
+ logger.trace("confirmPay: submitting payment after creating purchase record");
+ return submitPay(ws, proposalId);
+}
+
+export async function getFullRefundFees(
+ ws: InternalWalletState,
+ refundPermissions: MerchantRefundPermission[],
+): Promise<AmountJson> {
+ if (refundPermissions.length === 0) {
+ throw Error("no refunds given");
+ }
+ const coin0 = await oneShotGet(
+ ws.db,
+ Stores.coins,
+ refundPermissions[0].coin_pub,
+ );
+ if (!coin0) {
+ throw Error("coin not found");
+ }
+ let feeAcc = Amounts.getZero(
+ Amounts.parseOrThrow(refundPermissions[0].refund_amount).currency,
+ );
+
+ const denoms = await oneShotIterIndex(
+ ws.db,
+ Stores.denominations.exchangeBaseUrlIndex,
+ coin0.exchangeBaseUrl,
+ ).toArray();
+
+ for (const rp of refundPermissions) {
+ const coin = await oneShotGet(ws.db, Stores.coins, rp.coin_pub);
+ if (!coin) {
+ throw Error("coin not found");
+ }
+ const denom = await oneShotGet(ws.db, Stores.denominations, [
+ coin0.exchangeBaseUrl,
+ coin.denomPub,
+ ]);
+ if (!denom) {
+ throw Error(`denom not found (${coin.denomPub})`);
+ }
+ // FIXME: this assumes that the refund already happened.
+ // When it hasn't, the refresh cost is inaccurate. To fix this,
+ // we need introduce a flag to tell if a coin was refunded or
+ // refreshed normally (and what about incremental refunds?)
+ const refundAmount = Amounts.parseOrThrow(rp.refund_amount);
+ const refundFee = Amounts.parseOrThrow(rp.refund_fee);
+ const refreshCost = getTotalRefreshCost(
+ denoms,
+ denom,
+ Amounts.sub(refundAmount, refundFee).amount,
+ );
+ feeAcc = Amounts.add(feeAcc, refreshCost, refundFee).amount;
+ }
+ return feeAcc;
+}
+
+async function acceptRefundResponse(
+ ws: InternalWalletState,
+ proposalId: string,
+ refundResponse: MerchantRefundResponse,
+): Promise<void> {
+ const refundPermissions = refundResponse.refund_permissions;
+
+ let numNewRefunds = 0;
+
+ await runWithWriteTransaction(ws.db, [Stores.purchases], async tx => {
+ const p = await tx.get(Stores.purchases, proposalId);
+ if (!p) {
+ console.error("purchase not found, not adding refunds");
+ return;
+ }
+
+ if (!p.refundStatusRequested) {
+ return;
+ }
+
+ for (const perm of refundPermissions) {
+ if (
+ !p.refundsPending[perm.merchant_sig] &&
+ !p.refundsDone[perm.merchant_sig]
+ ) {
+ p.refundsPending[perm.merchant_sig] = perm;
+ numNewRefunds++;
+ }
+ }
+
+ // Are we done with querying yet, or do we need to do another round
+ // after a retry delay?
+ let queryDone = true;
+
+ if (numNewRefunds === 0) {
+ if (
+ p.autoRefundDeadline &&
+ p.autoRefundDeadline.t_ms > getTimestampNow().t_ms
+ ) {
+ queryDone = false;
+ }
+ }
+
+ if (queryDone) {
+ p.lastRefundStatusTimestamp = getTimestampNow();
+ p.lastRefundStatusError = undefined;
+ p.refundStatusRetryInfo = initRetryInfo();
+ p.refundStatusRequested = false;
+ console.log("refund query done");
+ } else {
+ // No error, but we need to try again!
+ p.lastRefundStatusTimestamp = getTimestampNow();
+ p.refundStatusRetryInfo.retryCounter++;
+ updateRetryInfoTimeout(p.refundStatusRetryInfo);
+ p.lastRefundStatusError = undefined;
+ console.log("refund query not done");
+ }
+
+ if (numNewRefunds) {
+ p.lastRefundApplyError = undefined;
+ p.refundApplyRetryInfo = initRetryInfo();
+ }
+
+ await tx.put(Stores.purchases, p);
+ });
+ ws.notify({
+ type: NotificationType.RefundQueried,
+ });
+ if (numNewRefunds > 0) {
+ await processPurchaseApplyRefund(ws, proposalId);
+ }
+}
+
+async function startRefundQuery(
+ ws: InternalWalletState,
+ proposalId: string,
+): Promise<void> {
+ const success = await runWithWriteTransaction(
+ ws.db,
+ [Stores.purchases],
+ async tx => {
+ const p = await tx.get(Stores.purchases, proposalId);
+ if (!p) {
+ console.log("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<string> {
+ const parseResult = parseRefundUri(talerRefundUri);
+
+ console.log("applying refund");
+
+ if (!parseResult) {
+ throw Error("invalid refund URI");
+ }
+
+ const purchase = await oneShotGetIndexed(
+ ws.db,
+ Stores.purchases.orderIdIndex,
+ [parseResult.merchantBaseUrl, parseResult.orderId],
+ );
+
+ if (!purchase) {
+ throw Error("no purchase for the taler://refund/ URI was found");
+ }
+
+ console.log("processing purchase for refund");
+ await startRefundQuery(ws, purchase.proposalId);
+
+ return purchase.contractTermsHash;
+}
+
+export async function processPurchasePay(
+ ws: InternalWalletState,
+ proposalId: string,
+ forceNow: boolean = false,
+): Promise<void> {
+ const onOpErr = (e: OperationError) =>
+ incrementPurchasePayRetry(ws, proposalId, e);
+ await guardOperationException(
+ () => processPurchasePayImpl(ws, proposalId, forceNow),
+ onOpErr,
+ );
+}
+
+async function resetPurchasePayRetry(
+ ws: InternalWalletState,
+ proposalId: string,
+) {
+ await oneShotMutate(ws.db, 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 oneShotGet(ws.db, Stores.purchases, proposalId);
+ if (!purchase) {
+ return;
+ }
+ if (!purchase.paymentSubmitPending) {
+ return;
+ }
+ logger.trace(`processing purchase pay ${proposalId}`);
+ await submitPay(ws, proposalId);
+}
+
+export async function processPurchaseQueryRefund(
+ ws: InternalWalletState,
+ proposalId: string,
+ forceNow: boolean = false,
+): Promise<void> {
+ const onOpErr = (e: OperationError) =>
+ incrementPurchaseQueryRefundRetry(ws, proposalId, e);
+ await guardOperationException(
+ () => processPurchaseQueryRefundImpl(ws, proposalId, forceNow),
+ onOpErr,
+ );
+}
+
+async function resetPurchaseQueryRefundRetry(
+ ws: InternalWalletState,
+ proposalId: string,
+) {
+ await oneShotMutate(ws.db, 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 oneShotGet(ws.db, Stores.purchases, proposalId);
+ if (!purchase) {
+ return;
+ }
+ if (!purchase.refundStatusRequested) {
+ return;
+ }
+
+ const refundUrlObj = new URL(
+ "refund",
+ purchase.contractTerms.merchant_base_url,
+ );
+ refundUrlObj.searchParams.set("order_id", purchase.contractTerms.order_id);
+ const refundUrl = refundUrlObj.href;
+ let resp;
+ try {
+ resp = await ws.http.get(refundUrl);
+ } catch (e) {
+ console.error("error downloading refund permission", e);
+ throw e;
+ }
+ if (resp.status !== 200) {
+ throw Error(`unexpected status code (${resp.status}) for /refund`);
+ }
+
+ const refundResponse = MerchantRefundResponse.checked(await resp.json());
+ await acceptRefundResponse(ws, proposalId, refundResponse);
+}
+
+export async function processPurchaseApplyRefund(
+ ws: InternalWalletState,
+ proposalId: string,
+ forceNow: boolean = false,
+): Promise<void> {
+ const onOpErr = (e: OperationError) =>
+ incrementPurchaseApplyRefundRetry(ws, proposalId, e);
+ await guardOperationException(
+ () => processPurchaseApplyRefundImpl(ws, proposalId, forceNow),
+ onOpErr,
+ );
+}
+
+async function resetPurchaseApplyRefundRetry(
+ ws: InternalWalletState,
+ proposalId: string,
+) {
+ await oneShotMutate(ws.db, Stores.purchases, proposalId, x => {
+ if (x.refundApplyRetryInfo.active) {
+ x.refundApplyRetryInfo = initRetryInfo();
+ }
+ return x;
+ });
+}
+
+async function processPurchaseApplyRefundImpl(
+ ws: InternalWalletState,
+ proposalId: string,
+ forceNow: boolean,
+): Promise<void> {
+ if (forceNow) {
+ await resetPurchaseApplyRefundRetry(ws, proposalId);
+ }
+ const purchase = await oneShotGet(ws.db, Stores.purchases, proposalId);
+ if (!purchase) {
+ console.error("not submitting refunds, payment not found:");
+ return;
+ }
+ const pendingKeys = Object.keys(purchase.refundsPending);
+ if (pendingKeys.length === 0) {
+ console.log("no pending refunds");
+ return;
+ }
+ for (const pk of pendingKeys) {
+ const perm = purchase.refundsPending[pk];
+ const req: RefundRequest = {
+ coin_pub: perm.coin_pub,
+ h_contract_terms: purchase.contractTermsHash,
+ merchant_pub: purchase.contractTerms.merchant_pub,
+ merchant_sig: perm.merchant_sig,
+ refund_amount: perm.refund_amount,
+ refund_fee: perm.refund_fee,
+ rtransaction_id: perm.rtransaction_id,
+ };
+ console.log("sending refund permission", perm);
+ // FIXME: not correct once we support multiple exchanges per payment
+ const exchangeUrl = purchase.payReq.coins[0].exchange_url;
+ const reqUrl = new URL("refund", exchangeUrl);
+ const resp = await ws.http.postJson(reqUrl.href, req);
+ console.log("sent refund permission");
+ if (resp.status !== 200) {
+ console.error("refund failed", resp);
+ continue;
+ }
+
+ let allRefundsProcessed = false;
+
+ await runWithWriteTransaction(
+ ws.db,
+ [Stores.purchases, Stores.coins],
+ async tx => {
+ const p = await tx.get(Stores.purchases, proposalId);
+ if (!p) {
+ return;
+ }
+ if (p.refundsPending[pk]) {
+ p.refundsDone[pk] = p.refundsPending[pk];
+ delete p.refundsPending[pk];
+ }
+ if (Object.keys(p.refundsPending).length === 0) {
+ p.refundStatusRetryInfo = initRetryInfo();
+ p.lastRefundStatusError = undefined;
+ allRefundsProcessed = true;
+ }
+ await tx.put(Stores.purchases, p);
+ const c = await tx.get(Stores.coins, perm.coin_pub);
+ if (!c) {
+ console.warn("coin not found, can't apply refund");
+ return;
+ }
+ const refundAmount = Amounts.parseOrThrow(perm.refund_amount);
+ const refundFee = Amounts.parseOrThrow(perm.refund_fee);
+ c.status = CoinStatus.Dirty;
+ c.currentAmount = Amounts.add(c.currentAmount, refundAmount).amount;
+ c.currentAmount = Amounts.sub(c.currentAmount, refundFee).amount;
+ await tx.put(Stores.coins, c);
+ },
+ );
+ if (allRefundsProcessed) {
+ ws.notify({
+ type: NotificationType.RefundFinished,
+ });
+ }
+ await refresh(ws, perm.coin_pub);
+ }
+
+ ws.notify({
+ type: NotificationType.RefundsSubmitted,
+ proposalId,
+ });
+}
diff --git a/src/operations/payback.ts b/src/operations/payback.ts
new file mode 100644
index 000000000..2d8a72839
--- /dev/null
+++ b/src/operations/payback.ts
@@ -0,0 +1,93 @@
+/*
+ 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 {
+ oneShotIter,
+ runWithWriteTransaction,
+ oneShotGet,
+ oneShotPut,
+} from "../util/query";
+import { InternalWalletState } from "./state";
+import { Stores, TipRecord, CoinStatus } from "../types/dbTypes";
+
+import { Logger } from "../util/logging";
+import { PaybackConfirmation } from "../types/talerTypes";
+import { updateExchangeFromUrl } from "./exchanges";
+import { NotificationType } from "../types/notifications";
+
+const logger = new Logger("payback.ts");
+
+export async function payback(
+ ws: InternalWalletState,
+ coinPub: string,
+): Promise<void> {
+ let coin = await oneShotGet(ws.db, Stores.coins, coinPub);
+ if (!coin) {
+ throw Error(`Coin ${coinPub} not found, can't request payback`);
+ }
+ const reservePub = coin.reservePub;
+ if (!reservePub) {
+ throw Error(`Can't request payback for a refreshed coin`);
+ }
+ const reserve = await oneShotGet(ws.db, Stores.reserves, reservePub);
+ if (!reserve) {
+ throw Error(`Reserve of coin ${coinPub} not found`);
+ }
+ switch (coin.status) {
+ case CoinStatus.Dormant:
+ throw Error(`Can't do payback for coin ${coinPub} since it's dormant`);
+ }
+ coin.status = CoinStatus.Dormant;
+ // Even if we didn't get the payback yet, we suspend withdrawal, since
+ // technically we might update reserve status before we get the response
+ // from the reserve for the payback request.
+ reserve.hasPayback = true;
+ await runWithWriteTransaction(
+ ws.db,
+ [Stores.coins, Stores.reserves],
+ async tx => {
+ await tx.put(Stores.coins, coin!!);
+ await tx.put(Stores.reserves, reserve);
+ },
+ );
+ ws.notify({
+ type: NotificationType.PaybackStarted,
+ });
+
+ const paybackRequest = await ws.cryptoApi.createPaybackRequest(coin);
+ const reqUrl = new URL("payback", coin.exchangeBaseUrl);
+ const resp = await ws.http.postJson(reqUrl.href, paybackRequest);
+ if (resp.status !== 200) {
+ throw Error();
+ }
+ const paybackConfirmation = PaybackConfirmation.checked(await resp.json());
+ if (paybackConfirmation.reserve_pub !== coin.reservePub) {
+ throw Error(`Coin's reserve doesn't match reserve on payback`);
+ }
+ coin = await oneShotGet(ws.db, Stores.coins, coinPub);
+ if (!coin) {
+ throw Error(`Coin ${coinPub} not found, can't confirm payback`);
+ }
+ coin.status = CoinStatus.Dormant;
+ await oneShotPut(ws.db, Stores.coins, coin);
+ ws.notify({
+ type: NotificationType.PaybackFinished,
+ });
+ await updateExchangeFromUrl(ws, coin.exchangeBaseUrl, true);
+}
diff --git a/src/operations/pending.ts b/src/operations/pending.ts
new file mode 100644
index 000000000..b9fc1d203
--- /dev/null
+++ b/src/operations/pending.ts
@@ -0,0 +1,452 @@
+/*
+ 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 {
+ getTimestampNow,
+ Timestamp,
+ Duration,
+} from "../types/walletTypes";
+import { runWithReadTransaction, TransactionHandle } from "../util/query";
+import { InternalWalletState } from "./state";
+import {
+ Stores,
+ ExchangeUpdateStatus,
+ ReserveRecordStatus,
+ CoinStatus,
+ ProposalStatus,
+} from "../types/dbTypes";
+import { PendingOperationsResponse } from "../types/pending";
+
+function updateRetryDelay(
+ oldDelay: Duration,
+ now: Timestamp,
+ retryTimestamp: Timestamp,
+): Duration {
+ if (retryTimestamp.t_ms <= now.t_ms) {
+ return { d_ms: 0 };
+ }
+ return { d_ms: Math.min(oldDelay.d_ms, retryTimestamp.t_ms - now.t_ms) };
+}
+
+async function gatherExchangePending(
+ tx: TransactionHandle,
+ now: Timestamp,
+ resp: PendingOperationsResponse,
+ onlyDue: boolean = 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: "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: "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: "bug",
+ givesLifeness: false,
+ message:
+ "Exchange record does not have wire info, but no update in progress.",
+ details: {
+ exchangeBaseUrl: e.baseUrl,
+ },
+ });
+ }
+ break;
+ case ExchangeUpdateStatus.FETCH_KEYS:
+ resp.pendingOperations.push({
+ type: "exchange-update",
+ givesLifeness: false,
+ stage: "fetch-keys",
+ exchangeBaseUrl: e.baseUrl,
+ lastError: e.lastError,
+ reason: e.updateReason || "unknown",
+ });
+ break;
+ case ExchangeUpdateStatus.FETCH_WIRE:
+ resp.pendingOperations.push({
+ type: "exchange-update",
+ givesLifeness: false,
+ stage: "fetch-wire",
+ exchangeBaseUrl: e.baseUrl,
+ lastError: e.lastError,
+ reason: e.updateReason || "unknown",
+ });
+ break;
+ default:
+ resp.pendingOperations.push({
+ type: "bug",
+ givesLifeness: false,
+ message: "Unknown exchangeUpdateStatus",
+ details: {
+ exchangeBaseUrl: e.baseUrl,
+ exchangeUpdateStatus: e.updateStatus,
+ },
+ });
+ break;
+ }
+ });
+}
+
+async function gatherReservePending(
+ tx: TransactionHandle,
+ now: Timestamp,
+ resp: PendingOperationsResponse,
+ onlyDue: boolean = 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.bankWithdrawStatusUrl ? "taler-bank" : "manual";
+ if (!reserve.retryInfo.active) {
+ return;
+ }
+ switch (reserve.reserveStatus) {
+ case ReserveRecordStatus.DORMANT:
+ // nothing to report as pending
+ break;
+ case ReserveRecordStatus.UNCONFIRMED:
+ if (onlyDue) {
+ break;
+ }
+ resp.pendingOperations.push({
+ type: "reserve",
+ givesLifeness: false,
+ stage: reserve.reserveStatus,
+ timestampCreated: reserve.created,
+ reserveType,
+ reservePub: reserve.reservePub,
+ retryInfo: reserve.retryInfo,
+ });
+ 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: "reserve",
+ givesLifeness: true,
+ stage: reserve.reserveStatus,
+ timestampCreated: reserve.created,
+ reserveType,
+ reservePub: reserve.reservePub,
+ retryInfo: reserve.retryInfo,
+ });
+ break;
+ default:
+ resp.pendingOperations.push({
+ type: "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: boolean = false,
+): Promise<void> {
+ await tx.iter(Stores.refresh).forEach(r => {
+ if (r.finishedTimestamp) {
+ return;
+ }
+ resp.nextRetryDelay = updateRetryDelay(
+ resp.nextRetryDelay,
+ now,
+ r.retryInfo.nextRetry,
+ );
+ if (onlyDue && r.retryInfo.nextRetry.t_ms > now.t_ms) {
+ return;
+ }
+ let refreshStatus: string;
+ if (r.norevealIndex === undefined) {
+ refreshStatus = "melt";
+ } else {
+ refreshStatus = "reveal";
+ }
+
+ resp.pendingOperations.push({
+ type: "refresh",
+ givesLifeness: true,
+ oldCoinPub: r.meltCoinPub,
+ refreshStatus,
+ refreshOutputSize: r.newDenoms.length,
+ refreshSessionId: r.refreshSessionId,
+ });
+ });
+}
+
+async function gatherCoinsPending(
+ tx: TransactionHandle,
+ now: Timestamp,
+ resp: PendingOperationsResponse,
+ onlyDue: boolean = false,
+): Promise<void> {
+ // Refreshing dirty coins is always due.
+ await tx.iter(Stores.coins).forEach(coin => {
+ if (coin.status == CoinStatus.Dirty) {
+ resp.nextRetryDelay = { d_ms: 0 };
+ resp.pendingOperations.push({
+ givesLifeness: true,
+ type: "dirty-coin",
+ coinPub: coin.coinPub,
+ });
+ }
+ });
+}
+
+async function gatherWithdrawalPending(
+ tx: TransactionHandle,
+ now: Timestamp,
+ resp: PendingOperationsResponse,
+ onlyDue: boolean = false,
+): Promise<void> {
+ await tx.iter(Stores.withdrawalSession).forEach(wsr => {
+ if (wsr.finishTimestamp) {
+ return;
+ }
+ resp.nextRetryDelay = updateRetryDelay(
+ resp.nextRetryDelay,
+ now,
+ wsr.retryInfo.nextRetry,
+ );
+ if (onlyDue && wsr.retryInfo.nextRetry.t_ms > now.t_ms) {
+ return;
+ }
+ const numCoinsWithdrawn = wsr.withdrawn.reduce(
+ (a, x) => a + (x ? 1 : 0),
+ 0,
+ );
+ const numCoinsTotal = wsr.withdrawn.length;
+ resp.pendingOperations.push({
+ type: "withdraw",
+ givesLifeness: true,
+ numCoinsTotal,
+ numCoinsWithdrawn,
+ source: wsr.source,
+ withdrawSessionId: wsr.withdrawSessionId,
+ });
+ });
+}
+
+async function gatherProposalPending(
+ tx: TransactionHandle,
+ now: Timestamp,
+ resp: PendingOperationsResponse,
+ onlyDue: boolean = false,
+): Promise<void> {
+ await tx.iter(Stores.proposals).forEach(proposal => {
+ if (proposal.proposalStatus == ProposalStatus.PROPOSED) {
+ if (onlyDue) {
+ return;
+ }
+ resp.pendingOperations.push({
+ type: "proposal-choice",
+ givesLifeness: false,
+ merchantBaseUrl: proposal.download!!.contractTerms.merchant_base_url,
+ 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: "proposal-download",
+ 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: boolean = 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.accepted) {
+ resp.pendingOperations.push({
+ type: "tip",
+ givesLifeness: true,
+ merchantBaseUrl: tip.merchantBaseUrl,
+ tipId: tip.tipId,
+ merchantTipId: tip.merchantTipId,
+ });
+ }
+ });
+}
+
+async function gatherPurchasePending(
+ tx: TransactionHandle,
+ now: Timestamp,
+ resp: PendingOperationsResponse,
+ onlyDue: boolean = 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: "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: "refund-query",
+ givesLifeness: true,
+ proposalId: pr.proposalId,
+ retryInfo: pr.refundStatusRetryInfo,
+ lastError: pr.lastRefundStatusError,
+ });
+ }
+ }
+ const numRefundsPending = Object.keys(pr.refundsPending).length;
+ if (numRefundsPending > 0) {
+ const numRefundsDone = Object.keys(pr.refundsDone).length;
+ resp.nextRetryDelay = updateRetryDelay(
+ resp.nextRetryDelay,
+ now,
+ pr.refundApplyRetryInfo.nextRetry,
+ );
+ if (!onlyDue || pr.refundApplyRetryInfo.nextRetry.t_ms <= now.t_ms) {
+ resp.pendingOperations.push({
+ type: "refund-apply",
+ numRefundsDone,
+ numRefundsPending,
+ givesLifeness: true,
+ proposalId: pr.proposalId,
+ retryInfo: pr.refundApplyRetryInfo,
+ lastError: pr.lastRefundApplyError,
+ });
+ }
+ }
+ });
+}
+
+export async function getPendingOperations(
+ ws: InternalWalletState,
+ onlyDue: boolean = false,
+): Promise<PendingOperationsResponse> {
+ const resp: PendingOperationsResponse = {
+ nextRetryDelay: { d_ms: Number.MAX_SAFE_INTEGER },
+ pendingOperations: [],
+ };
+ const now = getTimestampNow();
+ await runWithReadTransaction(
+ ws.db,
+ [
+ Stores.exchanges,
+ Stores.reserves,
+ Stores.refresh,
+ Stores.coins,
+ Stores.withdrawalSession,
+ Stores.proposals,
+ Stores.tips,
+ Stores.purchases,
+ ],
+ async tx => {
+ await gatherExchangePending(tx, now, resp, onlyDue);
+ await gatherReservePending(tx, now, resp, onlyDue);
+ await gatherRefreshPending(tx, now, resp, onlyDue);
+ await gatherCoinsPending(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);
+ },
+ );
+ return resp;
+}
diff --git a/src/operations/refresh.ts b/src/operations/refresh.ts
new file mode 100644
index 000000000..4e4449d96
--- /dev/null
+++ b/src/operations/refresh.ts
@@ -0,0 +1,479 @@
+/*
+ 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 { AmountJson } from "../util/amounts";
+import * as Amounts from "../util/amounts";
+import {
+ DenominationRecord,
+ Stores,
+ CoinStatus,
+ RefreshPlanchetRecord,
+ CoinRecord,
+ RefreshSessionRecord,
+ initRetryInfo,
+ updateRetryInfoTimeout,
+} from "../types/dbTypes";
+import { amountToPretty } from "../util/helpers";
+import {
+ oneShotGet,
+ oneShotMutate,
+ runWithWriteTransaction,
+ TransactionAbort,
+ oneShotIterIndex,
+} from "../util/query";
+import { InternalWalletState } from "./state";
+import { Logger } from "../util/logging";
+import { getWithdrawDenomList } from "./withdraw";
+import { updateExchangeFromUrl } from "./exchanges";
+import {
+ getTimestampNow,
+ OperationError,
+} from "../types/walletTypes";
+import { guardOperationException } from "./errors";
+import { NotificationType } from "../types/notifications";
+
+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.map(d => d.value),
+ ).amount;
+ const totalCost = Amounts.sub(amountLeft, resultingAmount).amount;
+ logger.trace(
+ "total refresh cost for",
+ amountToPretty(amountLeft),
+ "is",
+ amountToPretty(totalCost),
+ );
+ return totalCost;
+}
+
+async function refreshMelt(
+ ws: InternalWalletState,
+ refreshSessionId: string,
+): Promise<void> {
+ const refreshSession = await oneShotGet(
+ ws.db,
+ Stores.refresh,
+ refreshSessionId,
+ );
+ if (!refreshSession) {
+ return;
+ }
+ if (refreshSession.norevealIndex !== undefined) {
+ return;
+ }
+
+ const coin = await oneShotGet(
+ ws.db,
+ Stores.coins,
+ refreshSession.meltCoinPub,
+ );
+
+ if (!coin) {
+ console.error("can't melt coin, it does not exist");
+ return;
+ }
+
+ const reqUrl = new URL("refresh/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: refreshSession.valueWithFee,
+ };
+ logger.trace("melt request:", meltReq);
+ const resp = await ws.http.postJson(reqUrl.href, meltReq);
+ if (resp.status !== 200) {
+ throw Error(`unexpected status code ${resp.status} for refresh/melt`);
+ }
+
+ const respJson = await resp.json();
+
+ logger.trace("melt response:", respJson);
+
+ if (resp.status !== 200) {
+ console.error(respJson);
+ throw Error("refresh failed");
+ }
+
+ const norevealIndex = respJson.noreveal_index;
+
+ if (typeof norevealIndex !== "number") {
+ throw Error("invalid response");
+ }
+
+ refreshSession.norevealIndex = norevealIndex;
+
+ await oneShotMutate(ws.db, Stores.refresh, refreshSessionId, rs => {
+ if (rs.norevealIndex !== undefined) {
+ return;
+ }
+ if (rs.finishedTimestamp) {
+ return;
+ }
+ rs.norevealIndex = norevealIndex;
+ return rs;
+ });
+
+ ws.notify({
+ type: NotificationType.RefreshMelted,
+ });
+}
+
+async function refreshReveal(
+ ws: InternalWalletState,
+ refreshSessionId: string,
+): Promise<void> {
+ const refreshSession = await oneShotGet(
+ ws.db,
+ Stores.refresh,
+ refreshSessionId,
+ );
+ 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 oneShotGet(
+ ws.db,
+ 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("refresh/reveal", refreshSession.exchangeBaseUrl);
+ logger.trace("reveal request:", req);
+
+ let resp;
+ try {
+ resp = await ws.http.postJson(reqUrl.href, req);
+ } catch (e) {
+ console.error("got error during /refresh/reveal request");
+ console.error(e);
+ return;
+ }
+
+ logger.trace("session:", refreshSession);
+ logger.trace("reveal response:", resp);
+
+ if (resp.status !== 200) {
+ console.error("error: /refresh/reveal returned status " + resp.status);
+ return;
+ }
+
+ const respJson = await resp.json();
+
+ if (!respJson.ev_sigs || !Array.isArray(respJson.ev_sigs)) {
+ console.error("/refresh/reveal did not contain ev_sigs");
+ return;
+ }
+
+ const coins: CoinRecord[] = [];
+
+ for (let i = 0; i < respJson.ev_sigs.length; i++) {
+ const denom = await oneShotGet(ws.db, Stores.denominations, [
+ refreshSession.exchangeBaseUrl,
+ refreshSession.newDenoms[i],
+ ]);
+ if (!denom) {
+ console.error("denom not found");
+ continue;
+ }
+ const pc =
+ refreshSession.planchetsForGammas[refreshSession.norevealIndex!][i];
+ const denomSig = await ws.cryptoApi.rsaUnblind(
+ respJson.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,
+ reservePub: undefined,
+ status: CoinStatus.Fresh,
+ coinIndex: -1,
+ withdrawSessionId: "",
+ };
+
+ coins.push(coin);
+ }
+
+ await runWithWriteTransaction(
+ ws.db,
+ [Stores.coins, Stores.refresh],
+ async tx => {
+ const rs = await tx.get(Stores.refresh, refreshSessionId);
+ if (!rs) {
+ console.log("no refresh session found");
+ return;
+ }
+ if (rs.finishedTimestamp) {
+ console.log("refresh session already finished");
+ return;
+ }
+ rs.finishedTimestamp = getTimestampNow();
+ rs.retryInfo = initRetryInfo(false);
+ for (let coin of coins) {
+ await tx.put(Stores.coins, coin);
+ }
+ await tx.put(Stores.refresh, rs);
+ },
+ );
+ console.log("refresh finished (end of reveal)");
+ ws.notify({
+ type: NotificationType.RefreshRevealed,
+ });
+}
+
+async function incrementRefreshRetry(
+ ws: InternalWalletState,
+ refreshSessionId: string,
+ err: OperationError | undefined,
+): Promise<void> {
+ await runWithWriteTransaction(ws.db, [Stores.refresh], async tx => {
+ const r = await tx.get(Stores.refresh, refreshSessionId);
+ if (!r) {
+ return;
+ }
+ if (!r.retryInfo) {
+ return;
+ }
+ r.retryInfo.retryCounter++;
+ updateRetryInfoTimeout(r.retryInfo);
+ r.lastError = err;
+ await tx.put(Stores.refresh, r);
+ });
+ ws.notify({ type: NotificationType.RefreshOperationError });
+}
+
+export async function processRefreshSession(
+ ws: InternalWalletState,
+ refreshSessionId: string,
+ forceNow: boolean = false,
+) {
+ return ws.memoProcessRefresh.memo(refreshSessionId, async () => {
+ const onOpErr = (e: OperationError) =>
+ incrementRefreshRetry(ws, refreshSessionId, e);
+ return guardOperationException(
+ () => processRefreshSessionImpl(ws, refreshSessionId, forceNow),
+ onOpErr,
+ );
+ });
+}
+
+async function resetRefreshSessionRetry(
+ ws: InternalWalletState,
+ refreshSessionId: string,
+) {
+ await oneShotMutate(ws.db, Stores.refresh, refreshSessionId, (x) => {
+ if (x.retryInfo.active) {
+ x.retryInfo = initRetryInfo();
+ }
+ return x;
+ });
+}
+
+async function processRefreshSessionImpl(
+ ws: InternalWalletState,
+ refreshSessionId: string,
+ forceNow: boolean,
+) {
+ if (forceNow) {
+ await resetRefreshSessionRetry(ws, refreshSessionId);
+ }
+ const refreshSession = await oneShotGet(
+ ws.db,
+ Stores.refresh,
+ refreshSessionId,
+ );
+ if (!refreshSession) {
+ return;
+ }
+ if (refreshSession.finishedTimestamp) {
+ return;
+ }
+ if (typeof refreshSession.norevealIndex !== "number") {
+ await refreshMelt(ws, refreshSession.refreshSessionId);
+ }
+ await refreshReveal(ws, refreshSession.refreshSessionId);
+ logger.trace("refresh finished");
+}
+
+export async function refresh(
+ ws: InternalWalletState,
+ oldCoinPub: string,
+ force: boolean = false,
+): Promise<void> {
+ const coin = await oneShotGet(ws.db, Stores.coins, oldCoinPub);
+ if (!coin) {
+ console.warn("can't refresh, coin not in database");
+ return;
+ }
+ switch (coin.status) {
+ case CoinStatus.Dirty:
+ break;
+ case CoinStatus.Dormant:
+ return;
+ case CoinStatus.Fresh:
+ if (!force) {
+ return;
+ }
+ break;
+ }
+
+ const exchange = await updateExchangeFromUrl(ws, coin.exchangeBaseUrl);
+ if (!exchange) {
+ throw Error("db inconsistent: exchange of coin not found");
+ }
+
+ const oldDenom = await oneShotGet(ws.db, Stores.denominations, [
+ exchange.baseUrl,
+ coin.denomPub,
+ ]);
+
+ if (!oldDenom) {
+ throw Error("db inconsistent: denomination for coin not found");
+ }
+
+ const availableDenoms: DenominationRecord[] = await oneShotIterIndex(
+ ws.db,
+ Stores.denominations.exchangeBaseUrlIndex,
+ exchange.baseUrl,
+ ).toArray();
+
+ const availableAmount = Amounts.sub(coin.currentAmount, oldDenom.feeRefresh)
+ .amount;
+
+ const newCoinDenoms = getWithdrawDenomList(availableAmount, availableDenoms);
+
+ if (newCoinDenoms.length === 0) {
+ logger.trace(
+ `not refreshing, available amount ${amountToPretty(
+ availableAmount,
+ )} too small`,
+ );
+ await oneShotMutate(ws.db, Stores.coins, oldCoinPub, x => {
+ if (x.status != coin.status) {
+ // Concurrent modification?
+ return;
+ }
+ x.status = CoinStatus.Dormant;
+ return x;
+ });
+ ws.notify({ type: NotificationType.RefreshRefused });
+ 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 runWithWriteTransaction(
+ ws.db,
+ [Stores.refresh, Stores.coins],
+ async tx => {
+ const c = await tx.get(Stores.coins, coin.coinPub);
+ if (!c) {
+ return;
+ }
+ if (c.status !== CoinStatus.Dirty) {
+ return;
+ }
+ const r = Amounts.sub(c.currentAmount, refreshSession.valueWithFee);
+ if (r.saturated) {
+ console.log("can't refresh coin, no amount left");
+ return;
+ }
+ c.currentAmount = r.amount;
+ c.status = CoinStatus.Dormant;
+ await tx.put(Stores.refresh, refreshSession);
+ await tx.put(Stores.coins, c);
+ },
+ );
+ logger.info(`created refresh session ${refreshSession.refreshSessionId}`);
+ ws.notify({ type: NotificationType.RefreshStarted });
+
+ await processRefreshSession(ws, refreshSession.refreshSessionId);
+}
diff --git a/src/operations/reserves.ts b/src/operations/reserves.ts
new file mode 100644
index 000000000..5ad13a67a
--- /dev/null
+++ b/src/operations/reserves.ts
@@ -0,0 +1,630 @@
+/*
+ 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,
+ getTimestampNow,
+ ConfirmReserveRequest,
+ OperationError,
+} from "../types/walletTypes";
+import { canonicalizeBaseUrl } from "../util/helpers";
+import { InternalWalletState } from "./state";
+import {
+ ReserveRecordStatus,
+ ReserveRecord,
+ CurrencyRecord,
+ Stores,
+ WithdrawalSessionRecord,
+ initRetryInfo,
+ updateRetryInfoTimeout,
+} from "../types/dbTypes";
+import {
+ oneShotMutate,
+ oneShotPut,
+ oneShotGet,
+ runWithWriteTransaction,
+ TransactionAbort,
+} from "../util/query";
+import { Logger } from "../util/logging";
+import * as Amounts from "../util/amounts";
+import { updateExchangeFromUrl, getExchangeTrust } from "./exchanges";
+import { WithdrawOperationStatusResponse, ReserveStatus } from "../types/talerTypes";
+import { assertUnreachable } from "../util/assertUnreachable";
+import { encodeCrock } from "../crypto/talerCrypto";
+import { randomBytes } from "../crypto/primitives/nacl-fast";
+import {
+ getVerifiedWithdrawDenomList,
+ processWithdrawSession,
+} from "./withdraw";
+import { guardOperationException, OperationFailedAndReportedError } from "./errors";
+import { NotificationType } from "../types/notifications";
+
+const logger = new Logger("reserves.ts");
+
+/**
+ * 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.UNCONFIRMED;
+ }
+
+ const currency = req.amount.currency;
+
+ const reserveRecord: ReserveRecord = {
+ created: now,
+ withdrawAllocatedAmount: Amounts.getZero(currency),
+ withdrawCompletedAmount: Amounts.getZero(currency),
+ withdrawRemainingAmount: Amounts.getZero(currency),
+ exchangeBaseUrl: canonExchange,
+ hasPayback: false,
+ initiallyRequestedAmount: req.amount,
+ reservePriv: keypair.priv,
+ reservePub: keypair.pub,
+ senderWire: req.senderWire,
+ timestampConfirmed: undefined,
+ timestampReserveInfoPosted: undefined,
+ bankWithdrawStatusUrl: req.bankWithdrawStatusUrl,
+ exchangeWire: req.exchangeWire,
+ reserveStatus,
+ lastSuccessfulStatusQuery: undefined,
+ retryInfo: initRetryInfo(),
+ lastError: undefined,
+ };
+
+ const senderWire = req.senderWire;
+ if (senderWire) {
+ const rec = {
+ paytoUri: senderWire,
+ };
+ await oneShotPut(ws.db, 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 oneShotGet(
+ ws.db,
+ 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 runWithWriteTransaction(
+ ws.db,
+ [Stores.currencies, Stores.reserves, Stores.bankWithdrawUris],
+ async tx => {
+ // Check if we have already created a reserve for that bankWithdrawStatusUrl
+ if (reserveRecord.bankWithdrawStatusUrl) {
+ const bwi = await tx.get(
+ Stores.bankWithdrawUris,
+ reserveRecord.bankWithdrawStatusUrl,
+ );
+ 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.bankWithdrawStatusUrl,
+ });
+ }
+ await tx.put(Stores.currencies, cr);
+ await tx.put(Stores.reserves, reserveRecord);
+ const r: CreateReserveResponse = {
+ exchange: canonExchange,
+ reservePub: keypair.pub,
+ };
+ return r;
+ },
+ );
+
+ ws.notify({ type: NotificationType.ReserveCreated });
+
+ // Asynchronously process the reserve, but return
+ // to the caller already.
+ processReserve(ws, resp.reservePub, true).catch(e => {
+ console.error("Processing reserve failed:", e);
+ });
+
+ return resp;
+}
+
+/**
+ * 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: boolean = false,
+): Promise<void> {
+ return ws.memoProcessReserve.memo(reservePub, async () => {
+ const onOpError = (err: OperationError) =>
+ incrementReserveRetry(ws, reservePub, err);
+ await guardOperationException(
+ () => processReserveImpl(ws, reservePub, forceNow),
+ onOpError,
+ );
+ });
+}
+
+
+async function registerReserveWithBank(
+ ws: InternalWalletState,
+ reservePub: string,
+): Promise<void> {
+ let reserve = await oneShotGet(ws.db, Stores.reserves, reservePub);
+ switch (reserve?.reserveStatus) {
+ case ReserveRecordStatus.WAIT_CONFIRM_BANK:
+ case ReserveRecordStatus.REGISTERING_BANK:
+ break;
+ default:
+ return;
+ }
+ const bankStatusUrl = reserve.bankWithdrawStatusUrl;
+ if (!bankStatusUrl) {
+ return;
+ }
+ console.log("making selection");
+ if (reserve.timestampReserveInfoPosted) {
+ throw Error("bank claims that reserve info selection is not done");
+ }
+ const bankResp = await ws.http.postJson(bankStatusUrl, {
+ reserve_pub: reservePub,
+ selected_exchange: reserve.exchangeWire,
+ });
+ console.log("got response", bankResp);
+ await oneShotMutate(ws.db, 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;
+ r.retryInfo = initRetryInfo();
+ return r;
+ });
+ ws.notify( { type: NotificationType.Wildcard });
+ return processReserveBankStatus(ws, reservePub);
+}
+
+export async function processReserveBankStatus(
+ ws: InternalWalletState,
+ reservePub: string,
+): Promise<void> {
+ const onOpError = (err: OperationError) =>
+ incrementReserveRetry(ws, reservePub, err);
+ await guardOperationException(
+ () => processReserveBankStatusImpl(ws, reservePub),
+ onOpError,
+ );
+}
+
+async function processReserveBankStatusImpl(
+ ws: InternalWalletState,
+ reservePub: string,
+): Promise<void> {
+ let reserve = await oneShotGet(ws.db, Stores.reserves, reservePub);
+ switch (reserve?.reserveStatus) {
+ case ReserveRecordStatus.WAIT_CONFIRM_BANK:
+ case ReserveRecordStatus.REGISTERING_BANK:
+ break;
+ default:
+ return;
+ }
+ const bankStatusUrl = reserve.bankWithdrawStatusUrl;
+ if (!bankStatusUrl) {
+ return;
+ }
+
+ let status: WithdrawOperationStatusResponse;
+ try {
+ const statusResp = await ws.http.get(bankStatusUrl);
+ if (statusResp.status !== 200) {
+ throw Error(`unexpected status ${statusResp.status} for bank status query`);
+ }
+ status = WithdrawOperationStatusResponse.checked(await statusResp.json());
+ } catch (e) {
+ throw e;
+ }
+
+ ws.notify( { type: NotificationType.Wildcard });
+
+ 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 oneShotMutate(ws.db, Stores.reserves, reservePub, r => {
+ switch (r.reserveStatus) {
+ case ReserveRecordStatus.REGISTERING_BANK:
+ case ReserveRecordStatus.WAIT_CONFIRM_BANK:
+ break;
+ default:
+ return;
+ }
+ const now = getTimestampNow();
+ r.timestampConfirmed = now;
+ r.reserveStatus = ReserveRecordStatus.QUERYING_STATUS;
+ r.retryInfo = initRetryInfo();
+ return r;
+ });
+ await processReserveImpl(ws, reservePub, true);
+ } else {
+ await oneShotMutate(ws.db, Stores.reserves, reservePub, r => {
+ switch (r.reserveStatus) {
+ case ReserveRecordStatus.WAIT_CONFIRM_BANK:
+ break;
+ default:
+ return;
+ }
+ r.bankWithdrawConfirmUrl = status.confirm_transfer_url;
+ return r;
+ });
+ await incrementReserveRetry(ws, reservePub, undefined);
+ }
+ ws.notify( { type: NotificationType.Wildcard });
+}
+
+async function incrementReserveRetry(
+ ws: InternalWalletState,
+ reservePub: string,
+ err: OperationError | undefined,
+): Promise<void> {
+ await runWithWriteTransaction(ws.db, [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);
+ });
+ ws.notify({ type: NotificationType.ReserveOperationError });
+}
+
+/**
+ * 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<void> {
+ const reserve = await oneShotGet(ws.db, Stores.reserves, reservePub);
+ if (!reserve) {
+ throw Error("reserve not in db");
+ }
+
+ if (reserve.timestampConfirmed === undefined) {
+ throw Error("reserve not confirmed yet");
+ }
+
+ if (reserve.reserveStatus !== ReserveRecordStatus.QUERYING_STATUS) {
+ return;
+ }
+
+ const reqUrl = new URL("reserve/status", reserve.exchangeBaseUrl);
+ reqUrl.searchParams.set("reserve_pub", reservePub);
+ let resp;
+ try {
+ resp = await ws.http.get(reqUrl.href);
+ if (resp.status === 404) {
+ const m = "The exchange does not know about this reserve (yet).";
+ await incrementReserveRetry(ws, reservePub, undefined);
+ return;
+ }
+ if (resp.status !== 200) {
+ throw Error(`unexpected status code ${resp.status} for reserve/status`)
+ }
+ } catch (e) {
+ const m = e.message;
+ await incrementReserveRetry(ws, reservePub, {
+ type: "network",
+ details: {},
+ message: m,
+ });
+ throw new OperationFailedAndReportedError(m);
+ }
+ const reserveInfo = ReserveStatus.checked(await resp.json());
+ const balance = Amounts.parseOrThrow(reserveInfo.balance);
+ await oneShotMutate(ws.db, Stores.reserves, reserve.reservePub, r => {
+ if (r.reserveStatus !== ReserveRecordStatus.QUERYING_STATUS) {
+ return;
+ }
+
+ // FIXME: check / compare history!
+ if (!r.lastSuccessfulStatusQuery) {
+ // FIXME: check if this matches initial expectations
+ r.withdrawRemainingAmount = balance;
+ } else {
+ const expectedBalance = Amounts.sub(
+ r.withdrawAllocatedAmount,
+ r.withdrawCompletedAmount,
+ );
+ const cmp = Amounts.cmp(balance, expectedBalance.amount);
+ if (cmp == 0) {
+ // Nothing changed.
+ return;
+ }
+ if (cmp > 0) {
+ const extra = Amounts.sub(balance, expectedBalance.amount).amount;
+ r.withdrawRemainingAmount = Amounts.add(
+ r.withdrawRemainingAmount,
+ extra,
+ ).amount;
+ } else {
+ // We're missing some money.
+ }
+ }
+ r.lastSuccessfulStatusQuery = getTimestampNow();
+ r.reserveStatus = ReserveRecordStatus.WITHDRAWING;
+ r.retryInfo = initRetryInfo();
+ return r;
+ });
+ ws.notify( { type: NotificationType.ReserveUpdated });
+}
+
+async function processReserveImpl(
+ ws: InternalWalletState,
+ reservePub: string,
+ forceNow: boolean = false,
+): Promise<void> {
+ const reserve = await oneShotGet(ws.db, 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;
+ }
+ }
+ logger.trace(
+ `Processing reserve ${reservePub} with status ${reserve.reserveStatus}`,
+ );
+ switch (reserve.reserveStatus) {
+ case ReserveRecordStatus.UNCONFIRMED:
+ // nothing to do
+ break;
+ case ReserveRecordStatus.REGISTERING_BANK:
+ await processReserveBankStatus(ws, reservePub);
+ return processReserveImpl(ws, reservePub, true);
+ case ReserveRecordStatus.QUERYING_STATUS:
+ await updateReserve(ws, reservePub);
+ return processReserveImpl(ws, reservePub, true);
+ 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;
+ }
+}
+
+export async function confirmReserve(
+ ws: InternalWalletState,
+ req: ConfirmReserveRequest,
+): Promise<void> {
+ const now = getTimestampNow();
+ await oneShotMutate(ws.db, Stores.reserves, req.reservePub, reserve => {
+ if (reserve.reserveStatus !== ReserveRecordStatus.UNCONFIRMED) {
+ return;
+ }
+ reserve.timestampConfirmed = now;
+ reserve.reserveStatus = ReserveRecordStatus.QUERYING_STATUS;
+ reserve.retryInfo = initRetryInfo();
+ return reserve;
+ });
+
+ ws.notify({ type: NotificationType.ReserveUpdated });
+
+ processReserve(ws, req.reservePub, true).catch(e => {
+ console.log("processing reserve failed:", e);
+ });
+}
+
+/**
+ * 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> {
+ const reserve = await oneShotGet(ws.db, Stores.reserves, reservePub);
+ if (!reserve) {
+ return;
+ }
+ if (reserve.reserveStatus !== ReserveRecordStatus.WITHDRAWING) {
+ return;
+ }
+ logger.trace(`depleting reserve ${reservePub}`);
+
+ const withdrawAmount = reserve.withdrawRemainingAmount;
+
+ logger.trace(`getting denom list`);
+
+ const denomsForWithdraw = await getVerifiedWithdrawDenomList(
+ ws,
+ reserve.exchangeBaseUrl,
+ withdrawAmount,
+ );
+ logger.trace(`got denom list`);
+ if (denomsForWithdraw.length === 0) {
+ const m = `Unable to withdraw from reserve, no denominations are available to withdraw.`;
+ await incrementReserveRetry(ws, reserve.reservePub, {
+ type: "internal",
+ message: m,
+ details: {},
+ });
+ console.log(m);
+ throw new OperationFailedAndReportedError(m);
+ }
+
+ logger.trace("selected denominations");
+
+ const withdrawalSessionId = encodeCrock(randomBytes(32));
+
+ const totalCoinValue = Amounts.sum(denomsForWithdraw.map(x => x.value))
+ .amount;
+
+ const withdrawalRecord: WithdrawalSessionRecord = {
+ withdrawSessionId: withdrawalSessionId,
+ exchangeBaseUrl: reserve.exchangeBaseUrl,
+ source: {
+ type: "reserve",
+ reservePub: reserve.reservePub,
+ },
+ rawWithdrawalAmount: withdrawAmount,
+ startTimestamp: getTimestampNow(),
+ denoms: denomsForWithdraw.map(x => x.denomPub),
+ withdrawn: denomsForWithdraw.map(x => false),
+ planchets: denomsForWithdraw.map(x => undefined),
+ totalCoinValue,
+ retryInfo: initRetryInfo(),
+ lastCoinErrors: denomsForWithdraw.map(x => undefined),
+ lastError: undefined,
+ };
+
+ const totalCoinWithdrawFee = Amounts.sum(
+ denomsForWithdraw.map(x => x.feeWithdraw),
+ ).amount;
+ const totalWithdrawAmount = Amounts.add(totalCoinValue, totalCoinWithdrawFee)
+ .amount;
+
+ function mutateReserve(r: ReserveRecord): ReserveRecord {
+ const remaining = Amounts.sub(
+ r.withdrawRemainingAmount,
+ totalWithdrawAmount,
+ );
+ if (remaining.saturated) {
+ console.error("can't create planchets, saturated");
+ throw TransactionAbort;
+ }
+ const allocated = Amounts.add(
+ r.withdrawAllocatedAmount,
+ totalWithdrawAmount,
+ );
+ if (allocated.saturated) {
+ console.error("can't create planchets, saturated");
+ throw TransactionAbort;
+ }
+ r.withdrawRemainingAmount = remaining.amount;
+ r.withdrawAllocatedAmount = allocated.amount;
+ r.reserveStatus = ReserveRecordStatus.DORMANT;
+ r.retryInfo = initRetryInfo(false);
+ return r;
+ }
+
+ const success = await runWithWriteTransaction(
+ ws.db,
+ [Stores.withdrawalSession, Stores.reserves],
+ async tx => {
+ const myReserve = await tx.get(Stores.reserves, reservePub);
+ if (!myReserve) {
+ return false;
+ }
+ if (myReserve.reserveStatus !== ReserveRecordStatus.WITHDRAWING) {
+ return false;
+ }
+ await tx.mutate(Stores.reserves, reserve.reservePub, mutateReserve);
+ await tx.put(Stores.withdrawalSession, withdrawalRecord);
+ return true;
+ },
+ );
+
+ if (success) {
+ console.log("processing new withdraw session");
+ ws.notify({
+ type: NotificationType.WithdrawSessionCreated,
+ withdrawSessionId: withdrawalSessionId,
+ });
+ await processWithdrawSession(ws, withdrawalSessionId);
+ } else {
+ console.trace("withdraw session already existed");
+ }
+}
diff --git a/src/operations/return.ts b/src/operations/return.ts
new file mode 100644
index 000000000..74885a735
--- /dev/null
+++ b/src/operations/return.ts
@@ -0,0 +1,267 @@
+/*
+ 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 {
+ ReturnCoinsRequest,
+ CoinWithDenom,
+} from "../types/walletTypes";
+import { runWithWriteTransaction, oneShotGet, oneShotIterIndex, oneShotPut } from "../util/query";
+import { InternalWalletState } from "./state";
+import { Stores, TipRecord, CoinStatus, CoinsReturnRecord, CoinRecord } from "../types/dbTypes";
+import * as Amounts from "../util/amounts";
+import { AmountJson } from "../util/amounts";
+import { Logger } from "../util/logging";
+import { canonicalJson } from "../util/helpers";
+import { ContractTerms } from "../types/talerTypes";
+import { selectPayCoins } from "./pay";
+
+const logger = new Logger("return.ts");
+
+async function getCoinsForReturn(
+ ws: InternalWalletState,
+ exchangeBaseUrl: string,
+ amount: AmountJson,
+): Promise<CoinWithDenom[] | undefined> {
+ const exchange = await oneShotGet(
+ ws.db,
+ Stores.exchanges,
+ exchangeBaseUrl,
+ );
+ if (!exchange) {
+ throw Error(`Exchange ${exchangeBaseUrl} not known to the wallet`);
+ }
+
+ const coins: CoinRecord[] = await oneShotIterIndex(
+ ws.db,
+ Stores.coins.exchangeBaseUrlIndex,
+ exchange.baseUrl,
+ ).toArray();
+
+ if (!coins || !coins.length) {
+ return [];
+ }
+
+ const denoms = await oneShotIterIndex(
+ ws.db,
+ Stores.denominations.exchangeBaseUrlIndex,
+ exchange.baseUrl,
+ ).toArray();
+
+ // Denomination of the first coin, we assume that all other
+ // coins have the same currency
+ const firstDenom = await oneShotGet(ws.db, Stores.denominations, [
+ exchange.baseUrl,
+ coins[0].denomPub,
+ ]);
+ if (!firstDenom) {
+ throw Error("db inconsistent");
+ }
+ const currency = firstDenom.value.currency;
+
+ const cds: CoinWithDenom[] = [];
+ for (const coin of coins) {
+ const denom = await oneShotGet(ws.db, 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;
+ }
+ cds.push({ coin, denom });
+ }
+
+ const res = selectPayCoins(denoms, cds, amount, amount);
+ if (res) {
+ return res.cds;
+ }
+ return undefined;
+}
+
+
+/**
+ * Trigger paying coins back into the user's account.
+ */
+export async function returnCoins(
+ ws: InternalWalletState,
+ req: ReturnCoinsRequest,
+): Promise<void> {
+ logger.trace("got returnCoins request", req);
+ const wireType = (req.senderWire as any).type;
+ logger.trace("wireType", wireType);
+ if (!wireType || typeof wireType !== "string") {
+ console.error(`wire type must be a non-empty string, not ${wireType}`);
+ return;
+ }
+ const stampSecNow = Math.floor(new Date().getTime() / 1000);
+ const exchange = await oneShotGet(ws.db, Stores.exchanges, req.exchange);
+ if (!exchange) {
+ console.error(`Exchange ${req.exchange} not known to the wallet`);
+ return;
+ }
+ const exchangeDetails = exchange.details;
+ if (!exchangeDetails) {
+ throw Error("exchange information needs to be updated first.");
+ }
+ logger.trace("selecting coins for return:", req);
+ const cds = await getCoinsForReturn(ws, req.exchange, req.amount);
+ logger.trace(cds);
+
+ if (!cds) {
+ throw Error("coin return impossible, can't select coins");
+ }
+
+ const { priv, pub } = await ws.cryptoApi.createEddsaKeypair();
+
+ const wireHash = await ws.cryptoApi.hashString(
+ canonicalJson(req.senderWire),
+ );
+
+ const contractTerms: ContractTerms = {
+ H_wire: wireHash,
+ amount: Amounts.toString(req.amount),
+ auditors: [],
+ exchanges: [
+ { master_pub: exchangeDetails.masterPublicKey, url: exchange.baseUrl },
+ ],
+ extra: {},
+ fulfillment_url: "",
+ locations: [],
+ max_fee: Amounts.toString(req.amount),
+ merchant: {},
+ merchant_pub: pub,
+ order_id: "none",
+ pay_deadline: `/Date(${stampSecNow + 30 * 5})/`,
+ wire_transfer_deadline: `/Date(${stampSecNow + 60 * 5})/`,
+ merchant_base_url: "taler://return-to-account",
+ products: [],
+ refund_deadline: `/Date(${stampSecNow + 60 * 5})/`,
+ timestamp: `/Date(${stampSecNow})/`,
+ wire_method: wireType,
+ };
+
+ const contractTermsHash = await ws.cryptoApi.hashString(
+ canonicalJson(contractTerms),
+ );
+
+ const payCoinInfo = await ws.cryptoApi.signDeposit(
+ contractTerms,
+ cds,
+ Amounts.parseOrThrow(contractTerms.amount),
+ );
+
+ logger.trace("pci", payCoinInfo);
+
+ const coins = payCoinInfo.sigs.map(s => ({ coinPaySig: s }));
+
+ const coinsReturnRecord: CoinsReturnRecord = {
+ coins,
+ contractTerms,
+ contractTermsHash,
+ exchange: exchange.baseUrl,
+ merchantPriv: priv,
+ wire: req.senderWire,
+ };
+
+ await runWithWriteTransaction(
+ ws.db,
+ [Stores.coinsReturns, Stores.coins],
+ async tx => {
+ await tx.put(Stores.coinsReturns, coinsReturnRecord);
+ for (let c of payCoinInfo.updatedCoins) {
+ await tx.put(Stores.coins, c);
+ }
+ },
+ );
+
+ depositReturnedCoins(ws, coinsReturnRecord);
+}
+
+async function depositReturnedCoins(
+ ws: InternalWalletState,
+ coinsReturnRecord: CoinsReturnRecord,
+): Promise<void> {
+ for (const c of coinsReturnRecord.coins) {
+ if (c.depositedSig) {
+ continue;
+ }
+ const req = {
+ H_wire: coinsReturnRecord.contractTerms.H_wire,
+ coin_pub: c.coinPaySig.coin_pub,
+ coin_sig: c.coinPaySig.coin_sig,
+ contribution: c.coinPaySig.contribution,
+ denom_pub: c.coinPaySig.denom_pub,
+ h_contract_terms: coinsReturnRecord.contractTermsHash,
+ merchant_pub: coinsReturnRecord.contractTerms.merchant_pub,
+ pay_deadline: coinsReturnRecord.contractTerms.pay_deadline,
+ refund_deadline: coinsReturnRecord.contractTerms.refund_deadline,
+ timestamp: coinsReturnRecord.contractTerms.timestamp,
+ ub_sig: c.coinPaySig.ub_sig,
+ wire: coinsReturnRecord.wire,
+ wire_transfer_deadline: coinsReturnRecord.contractTerms.pay_deadline,
+ };
+ logger.trace("req", req);
+ const reqUrl = new URL("deposit", coinsReturnRecord.exchange);
+ const resp = await ws.http.postJson(reqUrl.href, req);
+ if (resp.status !== 200) {
+ console.error("deposit failed due to status code", resp);
+ continue;
+ }
+ const respJson = await resp.json();
+ if (respJson.status !== "DEPOSIT_OK") {
+ console.error("deposit failed", resp);
+ continue;
+ }
+
+ if (!respJson.sig) {
+ console.error("invalid 'sig' field", resp);
+ continue;
+ }
+
+ // FIXME: verify signature
+
+ // For every successful deposit, we replace the old record with an updated one
+ const currentCrr = await oneShotGet(
+ ws.db,
+ Stores.coinsReturns,
+ coinsReturnRecord.contractTermsHash,
+ );
+ if (!currentCrr) {
+ console.error("database inconsistent");
+ continue;
+ }
+ for (const nc of currentCrr.coins) {
+ if (nc.coinPaySig.coin_pub === c.coinPaySig.coin_pub) {
+ nc.depositedSig = respJson.sig;
+ }
+ }
+ await oneShotPut(ws.db, Stores.coinsReturns, currentCrr);
+ }
+}
diff --git a/src/operations/state.ts b/src/operations/state.ts
new file mode 100644
index 000000000..47bf40de3
--- /dev/null
+++ b/src/operations/state.ts
@@ -0,0 +1,68 @@
+/*
+ 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,
+ WalletBalance,
+} from "../types/walletTypes";
+import { SpeculativePayData } from "./pay";
+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";
+
+type NotificationListener = (n: WalletNotification) => void;
+
+const logger = new Logger("state.ts");
+
+export class InternalWalletState {
+ speculativePayData: SpeculativePayData | undefined = undefined;
+ cachedNextUrl: { [fulfillmentUrl: string]: NextUrlResult } = {};
+ memoProcessReserve: AsyncOpMemoMap<void> = new AsyncOpMemoMap();
+ memoMakePlanchet: AsyncOpMemoMap<void> = new AsyncOpMemoMap();
+ memoGetPending: AsyncOpMemoSingle<
+ PendingOperationsResponse
+ > = new AsyncOpMemoSingle();
+ memoGetBalance: AsyncOpMemoSingle<WalletBalance> = new AsyncOpMemoSingle();
+ memoProcessRefresh: AsyncOpMemoMap<void> = new AsyncOpMemoMap();
+ cryptoApi: CryptoApi;
+
+ listeners: NotificationListener[] = [];
+
+ constructor(
+ public db: IDBDatabase,
+ public http: HttpRequestLibrary,
+ cryptoWorkerFactory: CryptoWorkerFactory,
+ ) {
+ this.cryptoApi = new CryptoApi(cryptoWorkerFactory);
+ }
+
+ public notify(n: WalletNotification) {
+ logger.trace("Notification", n);
+ for (const l of this.listeners) {
+ const nc = JSON.parse(JSON.stringify(n));
+ setImmediate(() => {
+ l(nc);
+ });
+ }
+ }
+
+ addNotificationListener(f: (n: WalletNotification) => void): void {
+ this.listeners.push(f);
+ }
+}
diff --git a/src/operations/tip.ts b/src/operations/tip.ts
new file mode 100644
index 000000000..0a710f67e
--- /dev/null
+++ b/src/operations/tip.ts
@@ -0,0 +1,305 @@
+/*
+ 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 { oneShotGet, oneShotPut, oneShotMutate, runWithWriteTransaction } from "../util/query";
+import { InternalWalletState } from "./state";
+import { parseTipUri } from "../util/taleruri";
+import { TipStatus, getTimestampNow, OperationError } from "../types/walletTypes";
+import { TipPickupGetResponse, TipPlanchetDetail, TipResponse } from "../types/talerTypes";
+import * as Amounts from "../util/amounts";
+import { Stores, PlanchetRecord, WithdrawalSessionRecord, initRetryInfo, updateRetryInfoTimeout } from "../types/dbTypes";
+import { getExchangeWithdrawalInfo, getVerifiedWithdrawDenomList, processWithdrawSession } from "./withdraw";
+import { getTalerStampSec, extractTalerStampOrThrow } from "../util/helpers";
+import { updateExchangeFromUrl } from "./exchanges";
+import { getRandomBytes, encodeCrock } from "../crypto/talerCrypto";
+import { guardOperationException } from "./errors";
+import { NotificationType } from "../types/notifications";
+
+
+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);
+ if (merchantResp.status !== 200) {
+ throw Error(`unexpected status ${merchantResp.status} for tip-pickup`);
+ }
+ const respJson = await merchantResp.json();
+ console.log("resp:", respJson);
+ const tipPickupStatus = TipPickupGetResponse.checked(respJson);
+
+ console.log("status", tipPickupStatus);
+
+ let amount = Amounts.parseOrThrow(tipPickupStatus.amount);
+
+ let tipRecord = await oneShotGet(ws.db, Stores.tips, [
+ res.merchantTipId,
+ res.merchantOrigin,
+ ]);
+
+ if (!tipRecord) {
+ const withdrawDetails = await getExchangeWithdrawalInfo(
+ ws,
+ tipPickupStatus.exchange_url,
+ amount,
+ );
+
+ const tipId = encodeCrock(getRandomBytes(32));
+
+ tipRecord = {
+ tipId,
+ accepted: false,
+ amount,
+ deadline: extractTalerStampOrThrow(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,
+ };
+ await oneShotPut(ws.db, Stores.tips, tipRecord);
+ }
+
+ const tipStatus: TipStatus = {
+ accepted: !!tipRecord && tipRecord.accepted,
+ amount: Amounts.parseOrThrow(tipPickupStatus.amount),
+ amountLeft: Amounts.parseOrThrow(tipPickupStatus.amount_left),
+ exchangeUrl: tipPickupStatus.exchange_url,
+ nextUrl: tipPickupStatus.extra.next_url,
+ merchantOrigin: res.merchantOrigin,
+ merchantTipId: res.merchantTipId,
+ expirationTimestamp: getTalerStampSec(tipPickupStatus.stamp_expire)!,
+ timestamp: getTalerStampSec(tipPickupStatus.stamp_created)!,
+ totalFees: tipRecord.totalFees,
+ tipId: tipRecord.tipId,
+ };
+
+ return tipStatus;
+}
+
+async function incrementTipRetry(
+ ws: InternalWalletState,
+ refreshSessionId: string,
+ err: OperationError | undefined,
+): Promise<void> {
+ await runWithWriteTransaction(ws.db, [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: boolean = false,
+): Promise<void> {
+ const onOpErr = (e: OperationError) => incrementTipRetry(ws, tipId, e);
+ await guardOperationException(() => processTipImpl(ws, tipId, forceNow), onOpErr);
+}
+
+async function resetTipRetry(
+ ws: InternalWalletState,
+ tipId: string,
+): Promise<void> {
+ await oneShotMutate(ws.db, Stores.tips, tipId, (x) => {
+ if (x.retryInfo.active) {
+ x.retryInfo = initRetryInfo();
+ }
+ return x;
+ })
+}
+
+async function processTipImpl(
+ ws: InternalWalletState,
+ tipId: string,
+ forceNow: boolean,
+) {
+ if (forceNow) {
+ await resetTipRetry(ws, tipId);
+ }
+ let tipRecord = await oneShotGet(ws.db, Stores.tips, tipId);
+ if (!tipRecord) {
+ return;
+ }
+
+ if (tipRecord.pickedUp) {
+ console.log("tip already picked up");
+ return;
+ }
+
+ if (!tipRecord.planchets) {
+ await updateExchangeFromUrl(ws, tipRecord.exchangeUrl);
+ const denomsForWithdraw = await getVerifiedWithdrawDenomList(
+ ws,
+ tipRecord.exchangeUrl,
+ tipRecord.amount,
+ );
+
+ const planchets = await Promise.all(
+ denomsForWithdraw.map(d => ws.cryptoApi.createTipPlanchet(d)),
+ );
+
+ await oneShotMutate(ws.db, Stores.tips, tipId, r => {
+ if (!r.planchets) {
+ r.planchets = planchets;
+ }
+ return r;
+ });
+ }
+
+ tipRecord = await oneShotGet(ws.db, 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 = TipResponse.checked(await merchantResp.json());
+
+ if (response.reserve_sigs.length !== tipRecord.planchets.length) {
+ throw Error("number of tip responses does not match requested planchets");
+ }
+
+ const planchets: PlanchetRecord[] = [];
+
+ for (let i = 0; i < tipRecord.planchets.length; i++) {
+ const tipPlanchet = tipRecord.planchets[i];
+ 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,
+ };
+ planchets.push(planchet);
+ }
+
+ const withdrawalSessionId = encodeCrock(getRandomBytes(32));
+
+ const withdrawalSession: WithdrawalSessionRecord = {
+ denoms: planchets.map((x) => x.denomPub),
+ exchangeBaseUrl: tipRecord.exchangeUrl,
+ planchets: planchets,
+ source: {
+ type: "tip",
+ tipId: tipRecord.tipId,
+ },
+ startTimestamp: getTimestampNow(),
+ withdrawSessionId: withdrawalSessionId,
+ rawWithdrawalAmount: tipRecord.amount,
+ withdrawn: planchets.map((x) => false),
+ totalCoinValue: Amounts.sum(planchets.map((p) => p.coinValue)).amount,
+ lastCoinErrors: planchets.map((x) => undefined),
+ retryInfo: initRetryInfo(),
+ finishTimestamp: undefined,
+ lastError: undefined,
+ };
+
+
+ await runWithWriteTransaction(ws.db, [Stores.tips, Stores.withdrawalSession], 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.withdrawalSession, withdrawalSession);
+ });
+
+ await processWithdrawSession(ws, withdrawalSessionId);
+
+ return;
+}
+
+export async function acceptTip(
+ ws: InternalWalletState,
+ tipId: string,
+): Promise<void> {
+ const tipRecord = await oneShotGet(ws.db, Stores.tips, tipId);
+ if (!tipRecord) {
+ console.log("tip not found");
+ return;
+ }
+
+ tipRecord.accepted = true;
+ await oneShotPut(ws.db, Stores.tips, tipRecord);
+
+ await processTip(ws, tipId);
+ return;
+}
diff --git a/src/operations/withdraw.ts b/src/operations/withdraw.ts
new file mode 100644
index 000000000..4ecc321f8
--- /dev/null
+++ b/src/operations/withdraw.ts
@@ -0,0 +1,699 @@
+/*
+ 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 { AmountJson } from "../util/amounts";
+import {
+ DenominationRecord,
+ Stores,
+ DenominationStatus,
+ CoinStatus,
+ CoinRecord,
+ PlanchetRecord,
+ initRetryInfo,
+ updateRetryInfoTimeout,
+} from "../types/dbTypes";
+import * as Amounts from "../util/amounts";
+import {
+ getTimestampNow,
+ AcceptWithdrawalResponse,
+ BankWithdrawDetails,
+ ExchangeWithdrawDetails,
+ WithdrawDetails,
+ OperationError,
+} from "../types/walletTypes";
+import { WithdrawOperationStatusResponse } from "../types/talerTypes";
+import { InternalWalletState } from "./state";
+import { parseWithdrawUri } from "../util/taleruri";
+import { Logger } from "../util/logging";
+import {
+ oneShotGet,
+ oneShotPut,
+ oneShotIterIndex,
+ oneShotGetIndexed,
+ runWithWriteTransaction,
+ oneShotMutate,
+} from "../util/query";
+import {
+ updateExchangeFromUrl,
+ getExchangePaytoUri,
+ getExchangeTrust,
+} from "./exchanges";
+import { createReserve, processReserveBankStatus } from "./reserves";
+import { WALLET_PROTOCOL_VERSION } from "../wallet";
+
+import * as LibtoolVersion from "../util/libtoolVersion";
+import { guardOperationException } from "./errors";
+import { NotificationType } from "../types/notifications";
+
+const logger = new Logger("withdraw.ts");
+
+function isWithdrawableDenom(d: DenominationRecord) {
+ const now = getTimestampNow();
+ const started = now.t_ms >= d.stampStart.t_ms;
+ const stillOkay = d.stampExpireWithdraw.t_ms + 60 * 1000 > now.t_ms;
+ return started && stillOkay;
+}
+
+/**
+ * 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[],
+): DenominationRecord[] {
+ let remaining = Amounts.copy(amountAvailable);
+ const ds: DenominationRecord[] = [];
+
+ denoms = denoms.filter(isWithdrawableDenom);
+ denoms.sort((d1, d2) => Amounts.cmp(d2.value, d1.value));
+
+ // This is an arbitrary number of coins
+ // we can withdraw in one go. It's not clear if this limit
+ // is useful ...
+ for (let i = 0; i < 1000; i++) {
+ let found = false;
+ for (const d of denoms) {
+ const cost = Amounts.add(d.value, d.feeWithdraw).amount;
+ if (Amounts.cmp(remaining, cost) < 0) {
+ continue;
+ }
+ found = true;
+ remaining = Amounts.sub(remaining, cost).amount;
+ ds.push(d);
+ break;
+ }
+ if (!found) {
+ break;
+ }
+ }
+ return ds;
+}
+
+/**
+ * Get information about a withdrawal from
+ * a taler://withdraw URI by asking the bank.
+ */
+async function getBankWithdrawalInfo(
+ ws: InternalWalletState,
+ talerWithdrawUri: string,
+): Promise<BankWithdrawDetails> {
+ const uriResult = parseWithdrawUri(talerWithdrawUri);
+ if (!uriResult) {
+ throw Error("can't parse URL");
+ }
+ const resp = await ws.http.get(uriResult.statusUrl);
+ if (resp.status !== 200) {
+ throw Error(`unexpected status (${resp.status}) from bank for ${uriResult.statusUrl}`);
+ }
+ const respJson = await resp.json();
+ console.log("resp:", respJson);
+ const status = WithdrawOperationStatusResponse.checked(respJson);
+ return {
+ amount: Amounts.parseOrThrow(status.amount),
+ confirmTransferUrl: status.confirm_transfer_url,
+ extractedStatusUrl: uriResult.statusUrl,
+ selectionDone: status.selection_done,
+ senderWire: status.sender_wire,
+ suggestedExchange: status.suggested_exchange,
+ transferDone: status.transfer_done,
+ wireTypes: status.wire_types,
+ };
+}
+
+export async function acceptWithdrawal(
+ 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,
+ exchangeWire: 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);
+ console.log("acceptWithdrawal: returning");
+ return {
+ reservePub: reserve.reservePub,
+ confirmTransferUrl: withdrawInfo.confirmTransferUrl,
+ };
+}
+
+async function getPossibleDenoms(
+ ws: InternalWalletState,
+ exchangeBaseUrl: string,
+): Promise<DenominationRecord[]> {
+ return await oneShotIterIndex(
+ ws.db,
+ Stores.denominations.exchangeBaseUrlIndex,
+ exchangeBaseUrl,
+ ).filter(d => {
+ return (
+ d.status === DenominationStatus.Unverified ||
+ d.status === DenominationStatus.VerifiedGood
+ );
+ });
+}
+
+/**
+ * Given a planchet, withdraw a coin from the exchange.
+ */
+async function processPlanchet(
+ ws: InternalWalletState,
+ withdrawalSessionId: string,
+ coinIdx: number,
+): Promise<void> {
+ const withdrawalSession = await oneShotGet(
+ ws.db,
+ Stores.withdrawalSession,
+ withdrawalSessionId,
+ );
+ if (!withdrawalSession) {
+ return;
+ }
+ if (withdrawalSession.withdrawn[coinIdx]) {
+ return;
+ }
+ if (withdrawalSession.source.type === "reserve") {
+ }
+ const planchet = withdrawalSession.planchets[coinIdx];
+ if (!planchet) {
+ console.log("processPlanchet: planchet not found");
+ return;
+ }
+ const exchange = await oneShotGet(
+ ws.db,
+ Stores.exchanges,
+ withdrawalSession.exchangeBaseUrl,
+ );
+ if (!exchange) {
+ console.error("db inconsistent: exchange for planchet not found");
+ return;
+ }
+
+ const denom = await oneShotGet(ws.db, Stores.denominations, [
+ withdrawalSession.exchangeBaseUrl,
+ planchet.denomPub,
+ ]);
+
+ if (!denom) {
+ console.error("db inconsistent: denom for planchet not found");
+ return;
+ }
+
+ 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("reserve/withdraw", exchange.baseUrl).href;
+ const resp = await ws.http.postJson(reqUrl, wd);
+ if (resp.status !== 200) {
+ throw Error(`unexpected status ${resp.status} for withdraw`);
+ }
+
+ const r = await resp.json();
+
+ 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");
+ }
+
+ const coin: CoinRecord = {
+ blindingKey: planchet.blindingKey,
+ coinPriv: planchet.coinPriv,
+ coinPub: planchet.coinPub,
+ currentAmount: planchet.coinValue,
+ denomPub: planchet.denomPub,
+ denomPubHash: planchet.denomPubHash,
+ denomSig,
+ exchangeBaseUrl: withdrawalSession.exchangeBaseUrl,
+ reservePub: planchet.reservePub,
+ status: CoinStatus.Fresh,
+ coinIndex: coinIdx,
+ withdrawSessionId: withdrawalSessionId,
+ };
+
+ let withdrawSessionFinished = false;
+ let reserveDepleted = false;
+
+ const success = await runWithWriteTransaction(
+ ws.db,
+ [Stores.coins, Stores.withdrawalSession, Stores.reserves],
+ async tx => {
+ const ws = await tx.get(Stores.withdrawalSession, withdrawalSessionId);
+ if (!ws) {
+ return false;
+ }
+ if (ws.withdrawn[coinIdx]) {
+ // Already withdrawn
+ return false;
+ }
+ ws.withdrawn[coinIdx] = true;
+ ws.lastCoinErrors[coinIdx] = undefined;
+ let numDone = 0;
+ for (let i = 0; i < ws.withdrawn.length; i++) {
+ if (ws.withdrawn[i]) {
+ numDone++;
+ }
+ }
+ if (numDone === ws.denoms.length) {
+ ws.finishTimestamp = getTimestampNow();
+ ws.lastError = undefined;
+ ws.retryInfo = initRetryInfo(false);
+ withdrawSessionFinished = true;
+ }
+ await tx.put(Stores.withdrawalSession, ws);
+ if (!planchet.isFromTip) {
+ const r = await tx.get(Stores.reserves, planchet.reservePub);
+ if (r) {
+ r.withdrawCompletedAmount = Amounts.add(
+ r.withdrawCompletedAmount,
+ Amounts.add(denom.value, denom.feeWithdraw).amount,
+ ).amount;
+ if (Amounts.cmp(r.withdrawCompletedAmount, r.withdrawAllocatedAmount) == 0) {
+ reserveDepleted = true;
+ }
+ await tx.put(Stores.reserves, r);
+ }
+ }
+ await tx.add(Stores.coins, coin);
+ return true;
+ },
+ );
+
+ if (success) {
+ ws.notify( {
+ type: NotificationType.CoinWithdrawn,
+ } );
+ }
+
+ if (withdrawSessionFinished) {
+ ws.notify({
+ type: NotificationType.WithdrawSessionFinished,
+ withdrawSessionId: withdrawalSessionId,
+ });
+ }
+
+ if (reserveDepleted && withdrawalSession.source.type === "reserve") {
+ ws.notify({
+ type: NotificationType.ReserveDepleted,
+ reservePub: withdrawalSession.source.reservePub,
+ });
+ }
+}
+
+/**
+ * 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 getVerifiedWithdrawDenomList(
+ ws: InternalWalletState,
+ exchangeBaseUrl: string,
+ amount: AmountJson,
+): Promise<DenominationRecord[]> {
+ const exchange = await oneShotGet(ws.db, Stores.exchanges, exchangeBaseUrl);
+ if (!exchange) {
+ console.log("exchange not found");
+ throw Error(`exchange ${exchangeBaseUrl} not found`);
+ }
+ const exchangeDetails = exchange.details;
+ if (!exchangeDetails) {
+ console.log("exchange details not available");
+ throw Error(`exchange ${exchangeBaseUrl} details not available`);
+ }
+
+ console.log("getting possible denoms");
+
+ const possibleDenoms = await getPossibleDenoms(ws, exchange.baseUrl);
+
+ console.log("got possible denoms");
+
+ let allValid = false;
+
+ let selectedDenoms: DenominationRecord[];
+
+ do {
+ allValid = true;
+ const nextPossibleDenoms = [];
+ selectedDenoms = getWithdrawDenomList(amount, possibleDenoms);
+ console.log("got withdraw denom list");
+ for (const denom of selectedDenoms || []) {
+ if (denom.status === DenominationStatus.Unverified) {
+ console.log(
+ "checking validity",
+ denom,
+ exchangeDetails.masterPublicKey,
+ );
+ const valid = await ws.cryptoApi.isValidDenom(
+ denom,
+ exchangeDetails.masterPublicKey,
+ );
+ console.log("done checking validity");
+ if (!valid) {
+ denom.status = DenominationStatus.VerifiedBad;
+ allValid = false;
+ } else {
+ denom.status = DenominationStatus.VerifiedGood;
+ nextPossibleDenoms.push(denom);
+ }
+ await oneShotPut(ws.db, Stores.denominations, denom);
+ } else {
+ nextPossibleDenoms.push(denom);
+ }
+ }
+ } while (selectedDenoms.length > 0 && !allValid);
+
+ console.log("returning denoms");
+
+ return selectedDenoms;
+}
+
+async function makePlanchet(
+ ws: InternalWalletState,
+ withdrawalSessionId: string,
+ coinIndex: number,
+): Promise<void> {
+ const withdrawalSession = await oneShotGet(
+ ws.db,
+ Stores.withdrawalSession,
+ withdrawalSessionId,
+ );
+ if (!withdrawalSession) {
+ return;
+ }
+ const src = withdrawalSession.source;
+ if (src.type !== "reserve") {
+ throw Error("invalid state");
+ }
+ const reserve = await oneShotGet(ws.db, Stores.reserves, src.reservePub);
+ if (!reserve) {
+ return;
+ }
+ const denom = await oneShotGet(ws.db, Stores.denominations, [
+ withdrawalSession.exchangeBaseUrl,
+ withdrawalSession.denoms[coinIndex],
+ ]);
+ if (!denom) {
+ return;
+ }
+ 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,
+ coinPriv: r.coinPriv,
+ coinPub: r.coinPub,
+ coinValue: r.coinValue,
+ denomPub: r.denomPub,
+ denomPubHash: r.denomPubHash,
+ isFromTip: false,
+ reservePub: r.reservePub,
+ withdrawSig: r.withdrawSig,
+ };
+ await runWithWriteTransaction(ws.db, [Stores.withdrawalSession], async tx => {
+ const myWs = await tx.get(Stores.withdrawalSession, withdrawalSessionId);
+ if (!myWs) {
+ return;
+ }
+ if (myWs.planchets[coinIndex]) {
+ return;
+ }
+ myWs.planchets[coinIndex] = newPlanchet;
+ await tx.put(Stores.withdrawalSession, myWs);
+ });
+}
+
+async function processWithdrawCoin(
+ ws: InternalWalletState,
+ withdrawalSessionId: string,
+ coinIndex: number,
+) {
+ logger.trace("starting withdraw for coin", coinIndex);
+ const withdrawalSession = await oneShotGet(
+ ws.db,
+ Stores.withdrawalSession,
+ withdrawalSessionId,
+ );
+ if (!withdrawalSession) {
+ console.log("ws doesn't exist");
+ return;
+ }
+
+ const coin = await oneShotGetIndexed(
+ ws.db,
+ Stores.coins.byWithdrawalWithIdx,
+ [withdrawalSessionId, coinIndex],
+ );
+
+ if (coin) {
+ console.log("coin already exists");
+ return;
+ }
+
+ if (!withdrawalSession.planchets[coinIndex]) {
+ const key = `${withdrawalSessionId}-${coinIndex}`;
+ await ws.memoMakePlanchet.memo(key, async () => {
+ logger.trace("creating planchet for coin", coinIndex);
+ return makePlanchet(ws, withdrawalSessionId, coinIndex);
+ });
+ }
+ await processPlanchet(ws, withdrawalSessionId, coinIndex);
+}
+
+async function incrementWithdrawalRetry(
+ ws: InternalWalletState,
+ withdrawalSessionId: string,
+ err: OperationError | undefined,
+): Promise<void> {
+ await runWithWriteTransaction(ws.db, [Stores.withdrawalSession], async tx => {
+ const wsr = await tx.get(Stores.withdrawalSession, withdrawalSessionId);
+ if (!wsr) {
+ return;
+ }
+ if (!wsr.retryInfo) {
+ return;
+ }
+ wsr.retryInfo.retryCounter++;
+ updateRetryInfoTimeout(wsr.retryInfo);
+ wsr.lastError = err;
+ await tx.put(Stores.withdrawalSession, wsr);
+ });
+ ws.notify({ type: NotificationType.WithdrawOperationError });
+}
+
+export async function processWithdrawSession(
+ ws: InternalWalletState,
+ withdrawalSessionId: string,
+ forceNow: boolean = false,
+): Promise<void> {
+ const onOpErr = (e: OperationError) =>
+ incrementWithdrawalRetry(ws, withdrawalSessionId, e);
+ await guardOperationException(
+ () => processWithdrawSessionImpl(ws, withdrawalSessionId, forceNow),
+ onOpErr,
+ );
+}
+
+async function resetWithdrawSessionRetry(
+ ws: InternalWalletState,
+ withdrawalSessionId: string,
+) {
+ await oneShotMutate(ws.db, Stores.withdrawalSession, withdrawalSessionId, (x) => {
+ if (x.retryInfo.active) {
+ x.retryInfo = initRetryInfo();
+ }
+ return x;
+ });
+}
+
+async function processWithdrawSessionImpl(
+ ws: InternalWalletState,
+ withdrawalSessionId: string,
+ forceNow: boolean,
+): Promise<void> {
+ logger.trace("processing withdraw session", withdrawalSessionId);
+ if (forceNow) {
+ await resetWithdrawSessionRetry(ws, withdrawalSessionId);
+ }
+ const withdrawalSession = await oneShotGet(
+ ws.db,
+ Stores.withdrawalSession,
+ withdrawalSessionId,
+ );
+ if (!withdrawalSession) {
+ logger.trace("withdraw session doesn't exist");
+ return;
+ }
+
+ const ps = withdrawalSession.denoms.map((d, i) =>
+ processWithdrawCoin(ws, withdrawalSessionId, i),
+ );
+ await Promise.all(ps);
+ return;
+}
+
+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 getVerifiedWithdrawDenomList(
+ ws,
+ baseUrl,
+ amount,
+ );
+ let acc = Amounts.getZero(amount.currency);
+ for (const d of selectedDenoms) {
+ acc = Amounts.add(acc, d.feeWithdraw).amount;
+ }
+ const actualCoinCost = selectedDenoms
+ .map((d: DenominationRecord) => Amounts.add(d.value, d.feeWithdraw).amount)
+ .reduce((a, b) => Amounts.add(a, b).amount);
+
+ const exchangeWireAccounts: string[] = [];
+ for (let account of exchangeWireInfo.accounts) {
+ exchangeWireAccounts.push(account.url);
+ }
+
+ const { isTrusted, isAudited } = await getExchangeTrust(ws, exchangeInfo);
+
+ let earliestDepositExpiration = selectedDenoms[0].stampExpireDeposit;
+ for (let i = 1; i < selectedDenoms.length; i++) {
+ const expireDeposit = selectedDenoms[i].stampExpireDeposit;
+ if (expireDeposit.t_ms < earliestDepositExpiration.t_ms) {
+ earliestDepositExpiration = expireDeposit;
+ }
+ }
+
+ const possibleDenoms = await oneShotIterIndex(
+ ws.db,
+ Stores.denominations.exchangeBaseUrlIndex,
+ baseUrl,
+ ).filter(d => d.isOffered);
+
+ const trustedAuditorPubs = [];
+ const currencyRecord = await oneShotGet(
+ ws.db,
+ Stores.currencies,
+ amount.currency,
+ );
+ if (currencyRecord) {
+ trustedAuditorPubs.push(...currencyRecord.auditors.map(a => a.auditorPub));
+ }
+
+ let versionMatch;
+ if (exchangeDetails.protocolVersion) {
+ versionMatch = LibtoolVersion.compare(
+ WALLET_PROTOCOL_VERSION,
+ exchangeDetails.protocolVersion,
+ );
+
+ if (
+ versionMatch &&
+ !versionMatch.compatible &&
+ versionMatch.currentCmp === -1
+ ) {
+ console.warn(
+ `wallet version ${WALLET_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 ret: ExchangeWithdrawDetails = {
+ earliestDepositExpiration,
+ exchangeInfo,
+ exchangeWireAccounts,
+ exchangeVersion: exchangeDetails.protocolVersion || "unknown",
+ isAudited,
+ isTrusted,
+ numOfferedDenoms: possibleDenoms.length,
+ overhead: Amounts.sub(amount, actualCoinCost).amount,
+ selectedDenoms,
+ trustedAuditorPubs,
+ versionMatch,
+ walletVersion: WALLET_PROTOCOL_VERSION,
+ wireFees: exchangeWireInfo,
+ withdrawFee: acc,
+ termsOfServiceAccepted: tosAccepted,
+ };
+ return ret;
+}
+
+export async function getWithdrawDetailsForUri(
+ ws: InternalWalletState,
+ talerWithdrawUri: string,
+ maybeSelectedExchange?: string,
+): Promise<WithdrawDetails> {
+ const info = await getBankWithdrawalInfo(ws, talerWithdrawUri);
+ let rci: ExchangeWithdrawDetails | undefined = undefined;
+ if (maybeSelectedExchange) {
+ rci = await getExchangeWithdrawalInfo(
+ ws,
+ maybeSelectedExchange,
+ info.amount,
+ );
+ }
+ return {
+ bankWithdrawDetails: info,
+ exchangeWithdrawDetails: rci,
+ };
+}