summaryrefslogtreecommitdiff
path: root/packages/taler-wallet-core/src/operations
diff options
context:
space:
mode:
authorFlorian Dold <florian.dold@gmail.com>2020-08-03 13:00:48 +0530
committerFlorian Dold <florian.dold@gmail.com>2020-08-03 13:01:05 +0530
commitffd2a62c3f7df94365980302fef3bc3376b48182 (patch)
tree270af6f16b4cc7f5da2afdba55c8bc9dbea5eca5 /packages/taler-wallet-core/src/operations
parentaa481e42675fb7c4dcbbeec0ba1c61e1953b9596 (diff)
downloadwallet-core-ffd2a62c3f7df94365980302fef3bc3376b48182.tar.gz
wallet-core-ffd2a62c3f7df94365980302fef3bc3376b48182.tar.bz2
wallet-core-ffd2a62c3f7df94365980302fef3bc3376b48182.zip
modularize repo, use pnpm, improve typechecking
Diffstat (limited to 'packages/taler-wallet-core/src/operations')
-rw-r--r--packages/taler-wallet-core/src/operations/balance.d.ts.map1
-rw-r--r--packages/taler-wallet-core/src/operations/balance.ts153
-rw-r--r--packages/taler-wallet-core/src/operations/errors.d.ts.map1
-rw-r--r--packages/taler-wallet-core/src/operations/errors.ts121
-rw-r--r--packages/taler-wallet-core/src/operations/exchanges.d.ts.map1
-rw-r--r--packages/taler-wallet-core/src/operations/exchanges.ts555
-rw-r--r--packages/taler-wallet-core/src/operations/pay.d.ts.map1
-rw-r--r--packages/taler-wallet-core/src/operations/pay.ts1148
-rw-r--r--packages/taler-wallet-core/src/operations/pending.d.ts.map1
-rw-r--r--packages/taler-wallet-core/src/operations/pending.ts458
-rw-r--r--packages/taler-wallet-core/src/operations/recoup.d.ts.map1
-rw-r--r--packages/taler-wallet-core/src/operations/recoup.ts412
-rw-r--r--packages/taler-wallet-core/src/operations/refresh.d.ts.map1
-rw-r--r--packages/taler-wallet-core/src/operations/refresh.ts573
-rw-r--r--packages/taler-wallet-core/src/operations/refund.d.ts.map1
-rw-r--r--packages/taler-wallet-core/src/operations/refund.ts438
-rw-r--r--packages/taler-wallet-core/src/operations/reserves.d.ts.map1
-rw-r--r--packages/taler-wallet-core/src/operations/reserves.ts841
-rw-r--r--packages/taler-wallet-core/src/operations/state.d.ts.map1
-rw-r--r--packages/taler-wallet-core/src/operations/state.ts65
-rw-r--r--packages/taler-wallet-core/src/operations/testing.d.ts.map1
-rw-r--r--packages/taler-wallet-core/src/operations/testing.ts156
-rw-r--r--packages/taler-wallet-core/src/operations/tip.d.ts.map1
-rw-r--r--packages/taler-wallet-core/src/operations/tip.ts343
-rw-r--r--packages/taler-wallet-core/src/operations/transactions.d.ts.map1
-rw-r--r--packages/taler-wallet-core/src/operations/transactions.ts288
-rw-r--r--packages/taler-wallet-core/src/operations/versions.d.ts.map1
-rw-r--r--packages/taler-wallet-core/src/operations/versions.ts38
-rw-r--r--packages/taler-wallet-core/src/operations/withdraw-test.ts332
-rw-r--r--packages/taler-wallet-core/src/operations/withdraw.d.ts.map1
-rw-r--r--packages/taler-wallet-core/src/operations/withdraw.ts759
31 files changed, 6695 insertions, 0 deletions
diff --git a/packages/taler-wallet-core/src/operations/balance.d.ts.map b/packages/taler-wallet-core/src/operations/balance.d.ts.map
new file mode 100644
index 000000000..264d3139b
--- /dev/null
+++ b/packages/taler-wallet-core/src/operations/balance.d.ts.map
@@ -0,0 +1 @@
+{"version":3,"file":"balance.d.ts","sourceRoot":"","sources":["balance.ts"],"names":[],"mappings":"AAgBA;;GAEG;AACH,OAAO,EAAE,gBAAgB,EAAE,MAAM,sBAAsB,CAAC;AACxD,OAAO,EAAE,iBAAiB,EAAE,MAAM,eAAe,CAAC;AAClD,OAAO,EAAE,mBAAmB,EAAE,MAAM,SAAS,CAAC;AAc9C;;GAEG;AACH,wBAAsB,4BAA4B,CAChD,EAAE,EAAE,mBAAmB,EACvB,EAAE,EAAE,iBAAiB,GACpB,OAAO,CAAC,gBAAgB,CAAC,CAqF3B;AAED;;GAEG;AACH,wBAAsB,WAAW,CAC/B,EAAE,EAAE,mBAAmB,GACtB,OAAO,CAAC,gBAAgB,CAAC,CAmB3B"} \ No newline at end of file
diff --git a/packages/taler-wallet-core/src/operations/balance.ts b/packages/taler-wallet-core/src/operations/balance.ts
new file mode 100644
index 000000000..26f0aaeee
--- /dev/null
+++ b/packages/taler-wallet-core/src/operations/balance.ts
@@ -0,0 +1,153 @@
+/*
+ This file is part of GNU Taler
+ (C) 2019 GNUnet e.V.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ * Imports.
+ */
+import { BalancesResponse } from "../types/walletTypes";
+import { TransactionHandle } from "../util/query";
+import { InternalWalletState } from "./state";
+import { Stores, CoinStatus } from "../types/dbTypes";
+import * as Amounts from "../util/amounts";
+import { AmountJson } from "../util/amounts";
+import { Logger } from "../util/logging";
+
+const logger = new Logger("withdraw.ts");
+
+interface WalletBalance {
+ available: AmountJson;
+ pendingIncoming: AmountJson;
+ pendingOutgoing: AmountJson;
+}
+
+/**
+ * Get balance information.
+ */
+export async function getBalancesInsideTransaction(
+ ws: InternalWalletState,
+ tx: TransactionHandle,
+): Promise<BalancesResponse> {
+ const balanceStore: Record<string, WalletBalance> = {};
+
+ /**
+ * Add amount to a balance field, both for
+ * the slicing by exchange and currency.
+ */
+ const initBalance = (currency: string): WalletBalance => {
+ const b = balanceStore[currency];
+ if (!b) {
+ balanceStore[currency] = {
+ available: Amounts.getZero(currency),
+ pendingIncoming: Amounts.getZero(currency),
+ pendingOutgoing: Amounts.getZero(currency),
+ };
+ }
+ return balanceStore[currency];
+ };
+
+ // Initialize balance to zero, even if we didn't start withdrawing yet.
+ await tx.iter(Stores.reserves).forEach((r) => {
+ const b = initBalance(r.currency);
+ if (!r.initialWithdrawalStarted) {
+ b.pendingIncoming = Amounts.add(
+ b.pendingIncoming,
+ r.initialDenomSel.totalCoinValue,
+ ).amount;
+ }
+ });
+
+ await tx.iter(Stores.coins).forEach((c) => {
+ // Only count fresh coins, as dormant coins will
+ // already be in a refresh session.
+ if (c.status === CoinStatus.Fresh) {
+ const b = initBalance(c.currentAmount.currency);
+ b.available = Amounts.add(b.available, c.currentAmount).amount;
+ }
+ });
+
+ await tx.iter(Stores.refreshGroups).forEach((r) => {
+ // Don't count finished refreshes, since the refresh already resulted
+ // in coins being added to the wallet.
+ if (r.timestampFinished) {
+ return;
+ }
+ for (let i = 0; i < r.oldCoinPubs.length; i++) {
+ const session = r.refreshSessionPerCoin[i];
+ if (session) {
+ const b = initBalance(session.amountRefreshOutput.currency);
+ // We are always assuming the refresh will succeed, thus we
+ // report the output as available balance.
+ b.available = Amounts.add(session.amountRefreshOutput).amount;
+ }
+ }
+ });
+
+ await tx.iter(Stores.withdrawalGroups).forEach((wds) => {
+ if (wds.timestampFinish) {
+ return;
+ }
+ const b = initBalance(wds.denomsSel.totalWithdrawCost.currency);
+ b.pendingIncoming = Amounts.add(
+ b.pendingIncoming,
+ wds.denomsSel.totalCoinValue,
+ ).amount;
+ });
+
+ const balancesResponse: BalancesResponse = {
+ balances: [],
+ };
+
+ Object.keys(balanceStore)
+ .sort()
+ .forEach((c) => {
+ const v = balanceStore[c];
+ balancesResponse.balances.push({
+ available: Amounts.stringify(v.available),
+ pendingIncoming: Amounts.stringify(v.pendingIncoming),
+ pendingOutgoing: Amounts.stringify(v.pendingOutgoing),
+ hasPendingTransactions: false,
+ requiresUserInput: false,
+ });
+ });
+
+ return balancesResponse;
+}
+
+/**
+ * Get detailed balance information, sliced by exchange and by currency.
+ */
+export async function getBalances(
+ ws: InternalWalletState,
+): Promise<BalancesResponse> {
+ logger.trace("starting to compute balance");
+
+ const wbal = await ws.db.runWithReadTransaction(
+ [
+ Stores.coins,
+ Stores.refreshGroups,
+ Stores.reserves,
+ Stores.purchases,
+ Stores.withdrawalGroups,
+ ],
+ async (tx) => {
+ return getBalancesInsideTransaction(ws, tx);
+ },
+ );
+
+ logger.trace("finished computing wallet balance");
+
+ return wbal;
+}
diff --git a/packages/taler-wallet-core/src/operations/errors.d.ts.map b/packages/taler-wallet-core/src/operations/errors.d.ts.map
new file mode 100644
index 000000000..e5763f31a
--- /dev/null
+++ b/packages/taler-wallet-core/src/operations/errors.d.ts.map
@@ -0,0 +1 @@
+{"version":3,"file":"errors.d.ts","sourceRoot":"","sources":["errors.ts"],"names":[],"mappings":"AAgBA;;;;GAIG;AAEH;;GAEG;AACH,OAAO,EAAE,qBAAqB,EAAE,MAAM,sBAAsB,CAAC;AAC7D,OAAO,EAAE,cAAc,EAAE,MAAM,mBAAmB,CAAC;AAEnD;;;GAGG;AACH,qBAAa,+BAAgC,SAAQ,KAAK;IACrC,cAAc,EAAE,qBAAqB;gBAArC,cAAc,EAAE,qBAAqB;CAMzD;AAED;;;GAGG;AACH,qBAAa,oBAAqB,SAAQ,KAAK;IAS1B,cAAc,EAAE,qBAAqB;IARxD,MAAM,CAAC,QAAQ,CACb,EAAE,EAAE,cAAc,EAClB,OAAO,EAAE,MAAM,EACf,OAAO,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAC/B,oBAAoB;gBAIJ,cAAc,EAAE,qBAAqB;CAMzD;AAED,wBAAgB,gBAAgB,CAC9B,EAAE,EAAE,cAAc,EAClB,OAAO,EAAE,MAAM,EACf,OAAO,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAC/B,qBAAqB,CAOvB;AAED;;;;GAIG;AACH,wBAAsB,uBAAuB,CAAC,CAAC,EAC7C,EAAE,EAAE,MAAM,OAAO,CAAC,CAAC,CAAC,EACpB,SAAS,EAAE,CAAC,CAAC,EAAE,qBAAqB,KAAK,OAAO,CAAC,IAAI,CAAC,GACrD,OAAO,CAAC,CAAC,CAAC,CAqCZ"} \ No newline at end of file
diff --git a/packages/taler-wallet-core/src/operations/errors.ts b/packages/taler-wallet-core/src/operations/errors.ts
new file mode 100644
index 000000000..198d3f8c5
--- /dev/null
+++ b/packages/taler-wallet-core/src/operations/errors.ts
@@ -0,0 +1,121 @@
+/*
+ This file is part of GNU Taler
+ (C) 2019-2020 Taler Systems SA
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ * Classes and helpers for error handling specific to wallet operations.
+ *
+ * @author Florian Dold <dold@taler.net>
+ */
+
+/**
+ * Imports.
+ */
+import { OperationErrorDetails } from "../types/walletTypes";
+import { TalerErrorCode } from "../TalerErrorCode";
+
+/**
+ * This exception is there to let the caller know that an error happened,
+ * but the error has already been reported by writing it to the database.
+ */
+export class OperationFailedAndReportedError extends Error {
+ constructor(public operationError: OperationErrorDetails) {
+ super(operationError.message);
+
+ // Set the prototype explicitly.
+ Object.setPrototypeOf(this, OperationFailedAndReportedError.prototype);
+ }
+}
+
+/**
+ * This exception is thrown when an error occured and the caller is
+ * responsible for recording the failure in the database.
+ */
+export class OperationFailedError extends Error {
+ static fromCode(
+ ec: TalerErrorCode,
+ message: string,
+ details: Record<string, unknown>,
+ ): OperationFailedError {
+ return new OperationFailedError(makeErrorDetails(ec, message, details));
+ }
+
+ constructor(public operationError: OperationErrorDetails) {
+ super(operationError.message);
+
+ // Set the prototype explicitly.
+ Object.setPrototypeOf(this, OperationFailedError.prototype);
+ }
+}
+
+export function makeErrorDetails(
+ ec: TalerErrorCode,
+ message: string,
+ details: Record<string, unknown>,
+): OperationErrorDetails {
+ return {
+ talerErrorCode: ec,
+ talerErrorHint: `Error: ${TalerErrorCode[ec]}`,
+ details: details,
+ message,
+ };
+}
+
+/**
+ * Run an operation and call the onOpError callback
+ * when there was an exception or operation error that must be reported.
+ * The cause will be re-thrown to the caller.
+ */
+export async function guardOperationException<T>(
+ op: () => Promise<T>,
+ onOpError: (e: OperationErrorDetails) => Promise<void>,
+): Promise<T> {
+ try {
+ return await op();
+ } catch (e) {
+ if (e instanceof OperationFailedAndReportedError) {
+ throw e;
+ }
+ if (e instanceof OperationFailedError) {
+ await onOpError(e.operationError);
+ throw new OperationFailedAndReportedError(e.operationError);
+ }
+ if (e instanceof Error) {
+ const opErr = makeErrorDetails(
+ TalerErrorCode.WALLET_UNEXPECTED_EXCEPTION,
+ `unexpected exception (message: ${e.message})`,
+ {},
+ );
+ await onOpError(opErr);
+ throw new OperationFailedAndReportedError(opErr);
+ }
+ // Something was thrown that is not even an exception!
+ // Try to stringify it.
+ let excString: string;
+ try {
+ excString = e.toString();
+ } catch (e) {
+ // Something went horribly wrong.
+ excString = "can't stringify exception";
+ }
+ const opErr = makeErrorDetails(
+ TalerErrorCode.WALLET_UNEXPECTED_EXCEPTION,
+ `unexpected exception (not an exception, ${excString})`,
+ {},
+ );
+ await onOpError(opErr);
+ throw new OperationFailedAndReportedError(opErr);
+ }
+}
diff --git a/packages/taler-wallet-core/src/operations/exchanges.d.ts.map b/packages/taler-wallet-core/src/operations/exchanges.d.ts.map
new file mode 100644
index 000000000..963a271fd
--- /dev/null
+++ b/packages/taler-wallet-core/src/operations/exchanges.d.ts.map
@@ -0,0 +1 @@
+{"version":3,"file":"exchanges.d.ts","sourceRoot":"","sources":["exchanges.ts"],"names":[],"mappings":"AAgBA,OAAO,EAAE,mBAAmB,EAAE,MAAM,SAAS,CAAC;AAO9C,OAAO,EACL,cAAc,EAQf,MAAM,kBAAkB,CAAC;AA0R1B,wBAAsB,4BAA4B,CAChD,EAAE,EAAE,mBAAmB,EACvB,eAAe,EAAE,MAAM,EACvB,IAAI,EAAE,MAAM,GAAG,SAAS,GACvB,OAAO,CAAC,IAAI,CAAC,CAUf;AAsFD,wBAAsB,qBAAqB,CACzC,EAAE,EAAE,mBAAmB,EACvB,OAAO,EAAE,MAAM,EACf,QAAQ,UAAQ,GACf,OAAO,CAAC,cAAc,CAAC,CAOzB;AAoED;;GAEG;AACH,wBAAsB,gBAAgB,CACpC,EAAE,EAAE,mBAAmB,EACvB,YAAY,EAAE,cAAc,GAC3B,OAAO,CAAC;IAAE,SAAS,EAAE,OAAO,CAAC;IAAC,SAAS,EAAE,OAAO,CAAA;CAAE,CAAC,CA4BrD;AAED,wBAAsB,mBAAmB,CACvC,EAAE,EAAE,mBAAmB,EACvB,eAAe,EAAE,MAAM,EACvB,oBAAoB,EAAE,MAAM,EAAE,GAC7B,OAAO,CAAC,MAAM,CAAC,CAqBjB"} \ No newline at end of file
diff --git a/packages/taler-wallet-core/src/operations/exchanges.ts b/packages/taler-wallet-core/src/operations/exchanges.ts
new file mode 100644
index 000000000..ee49fddb5
--- /dev/null
+++ b/packages/taler-wallet-core/src/operations/exchanges.ts
@@ -0,0 +1,555 @@
+/*
+ This file is part of GNU Taler
+ (C) 2019 GNUnet e.V.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+import { InternalWalletState } from "./state";
+import {
+ Denomination,
+ codecForExchangeKeysJson,
+ codecForExchangeWireJson,
+} from "../types/talerTypes";
+import { OperationErrorDetails } from "../types/walletTypes";
+import {
+ ExchangeRecord,
+ ExchangeUpdateStatus,
+ Stores,
+ DenominationRecord,
+ DenominationStatus,
+ WireFee,
+ ExchangeUpdateReason,
+ ExchangeUpdatedEventRecord,
+} from "../types/dbTypes";
+import { canonicalizeBaseUrl } from "../util/helpers";
+import * as Amounts from "../util/amounts";
+import { parsePaytoUri } from "../util/payto";
+import {
+ OperationFailedAndReportedError,
+ guardOperationException,
+ makeErrorDetails,
+} from "./errors";
+import {
+ WALLET_CACHE_BREAKER_CLIENT_VERSION,
+ WALLET_EXCHANGE_PROTOCOL_VERSION,
+} from "./versions";
+import { getTimestampNow } from "../util/time";
+import { compare } from "../util/libtoolVersion";
+import { createRecoupGroup, processRecoupGroup } from "./recoup";
+import { TalerErrorCode } from "../TalerErrorCode";
+import {
+ readSuccessResponseJsonOrThrow,
+ readSuccessResponseTextOrThrow,
+} from "../util/http";
+import { Logger } from "../util/logging";
+import { URL } from "../util/url";
+
+const logger = new Logger("exchanges.ts");
+
+async function denominationRecordFromKeys(
+ ws: InternalWalletState,
+ exchangeBaseUrl: string,
+ denomIn: Denomination,
+): Promise<DenominationRecord> {
+ const denomPubHash = await ws.cryptoApi.hashEncoded(denomIn.denom_pub);
+ const d: DenominationRecord = {
+ denomPub: denomIn.denom_pub,
+ denomPubHash,
+ exchangeBaseUrl,
+ feeDeposit: Amounts.parseOrThrow(denomIn.fee_deposit),
+ feeRefresh: Amounts.parseOrThrow(denomIn.fee_refresh),
+ feeRefund: Amounts.parseOrThrow(denomIn.fee_refund),
+ feeWithdraw: Amounts.parseOrThrow(denomIn.fee_withdraw),
+ isOffered: true,
+ isRevoked: false,
+ masterSig: denomIn.master_sig,
+ stampExpireDeposit: denomIn.stamp_expire_deposit,
+ stampExpireLegal: denomIn.stamp_expire_legal,
+ stampExpireWithdraw: denomIn.stamp_expire_withdraw,
+ stampStart: denomIn.stamp_start,
+ status: DenominationStatus.Unverified,
+ value: Amounts.parseOrThrow(denomIn.value),
+ };
+ return d;
+}
+
+async function setExchangeError(
+ ws: InternalWalletState,
+ baseUrl: string,
+ err: OperationErrorDetails,
+): Promise<void> {
+ console.log(`last error for exchange ${baseUrl}:`, err);
+ const mut = (exchange: ExchangeRecord): ExchangeRecord => {
+ exchange.lastError = err;
+ return exchange;
+ };
+ await ws.db.mutate(Stores.exchanges, baseUrl, mut);
+}
+
+/**
+ * Fetch the exchange's /keys and update our database accordingly.
+ *
+ * Exceptions thrown in this method must be caught and reported
+ * in the pending operations.
+ */
+async function updateExchangeWithKeys(
+ ws: InternalWalletState,
+ baseUrl: string,
+): Promise<void> {
+ const existingExchangeRecord = await ws.db.get(Stores.exchanges, baseUrl);
+
+ if (existingExchangeRecord?.updateStatus != ExchangeUpdateStatus.FetchKeys) {
+ return;
+ }
+
+ const keysUrl = new URL("keys", baseUrl);
+ keysUrl.searchParams.set("cacheBreaker", WALLET_CACHE_BREAKER_CLIENT_VERSION);
+
+ const resp = await ws.http.get(keysUrl.href);
+ const exchangeKeysJson = await readSuccessResponseJsonOrThrow(
+ resp,
+ codecForExchangeKeysJson(),
+ );
+
+ if (exchangeKeysJson.denoms.length === 0) {
+ const opErr = makeErrorDetails(
+ TalerErrorCode.WALLET_EXCHANGE_DENOMINATIONS_INSUFFICIENT,
+ "exchange doesn't offer any denominations",
+ {
+ exchangeBaseUrl: baseUrl,
+ },
+ );
+ await setExchangeError(ws, baseUrl, opErr);
+ throw new OperationFailedAndReportedError(opErr);
+ }
+
+ const protocolVersion = exchangeKeysJson.version;
+
+ const versionRes = compare(WALLET_EXCHANGE_PROTOCOL_VERSION, protocolVersion);
+ if (versionRes?.compatible != true) {
+ const opErr = makeErrorDetails(
+ TalerErrorCode.WALLET_EXCHANGE_PROTOCOL_VERSION_INCOMPATIBLE,
+ "exchange protocol version not compatible with wallet",
+ {
+ exchangeProtocolVersion: protocolVersion,
+ walletProtocolVersion: WALLET_EXCHANGE_PROTOCOL_VERSION,
+ },
+ );
+ await setExchangeError(ws, baseUrl, opErr);
+ throw new OperationFailedAndReportedError(opErr);
+ }
+
+ const currency = Amounts.parseOrThrow(exchangeKeysJson.denoms[0].value)
+ .currency;
+
+ const newDenominations = await Promise.all(
+ exchangeKeysJson.denoms.map((d) =>
+ denominationRecordFromKeys(ws, baseUrl, d),
+ ),
+ );
+
+ const lastUpdateTimestamp = getTimestampNow();
+
+ const recoupGroupId: string | undefined = undefined;
+
+ await ws.db.runWithWriteTransaction(
+ [Stores.exchanges, Stores.denominations, Stores.recoupGroups, Stores.coins],
+ async (tx) => {
+ const r = await tx.get(Stores.exchanges, baseUrl);
+ if (!r) {
+ console.warn(`exchange ${baseUrl} no longer present`);
+ return;
+ }
+ if (r.details) {
+ // FIXME: We need to do some consistency checks!
+ }
+ // FIXME: validate signing keys and merge with old set
+ r.details = {
+ auditors: exchangeKeysJson.auditors,
+ currency: currency,
+ lastUpdateTime: lastUpdateTimestamp,
+ masterPublicKey: exchangeKeysJson.master_public_key,
+ protocolVersion: protocolVersion,
+ signingKeys: exchangeKeysJson.signkeys,
+ };
+ r.updateStatus = ExchangeUpdateStatus.FetchWire;
+ r.lastError = undefined;
+ await tx.put(Stores.exchanges, r);
+
+ for (const newDenom of newDenominations) {
+ const oldDenom = await tx.get(Stores.denominations, [
+ baseUrl,
+ newDenom.denomPub,
+ ]);
+ if (oldDenom) {
+ // FIXME: Do consistency check
+ } else {
+ await tx.put(Stores.denominations, newDenom);
+ }
+ }
+
+ // Handle recoup
+ const recoupDenomList = exchangeKeysJson.recoup ?? [];
+ const newlyRevokedCoinPubs: string[] = [];
+ logger.trace("recoup list from exchange", recoupDenomList);
+ for (const recoupInfo of recoupDenomList) {
+ const oldDenom = await tx.getIndexed(
+ Stores.denominations.denomPubHashIndex,
+ recoupInfo.h_denom_pub,
+ );
+ if (!oldDenom) {
+ // We never even knew about the revoked denomination, all good.
+ continue;
+ }
+ if (oldDenom.isRevoked) {
+ // We already marked the denomination as revoked,
+ // this implies we revoked all coins
+ console.log("denom already revoked");
+ continue;
+ }
+ console.log("revoking denom", recoupInfo.h_denom_pub);
+ oldDenom.isRevoked = true;
+ await tx.put(Stores.denominations, oldDenom);
+ const affectedCoins = await tx
+ .iterIndexed(Stores.coins.denomPubHashIndex, recoupInfo.h_denom_pub)
+ .toArray();
+ for (const ac of affectedCoins) {
+ newlyRevokedCoinPubs.push(ac.coinPub);
+ }
+ }
+ if (newlyRevokedCoinPubs.length != 0) {
+ console.log("recouping coins", newlyRevokedCoinPubs);
+ await createRecoupGroup(ws, tx, newlyRevokedCoinPubs);
+ }
+ },
+ );
+
+ if (recoupGroupId) {
+ // Asynchronously start recoup. This doesn't need to finish
+ // for the exchange update to be considered finished.
+ processRecoupGroup(ws, recoupGroupId).catch((e) => {
+ console.log("error while recouping coins:", e);
+ });
+ }
+}
+
+async function updateExchangeFinalize(
+ ws: InternalWalletState,
+ exchangeBaseUrl: string,
+): Promise<void> {
+ const exchange = await ws.db.get(Stores.exchanges, exchangeBaseUrl);
+ if (!exchange) {
+ return;
+ }
+ if (exchange.updateStatus != ExchangeUpdateStatus.FinalizeUpdate) {
+ return;
+ }
+ await ws.db.runWithWriteTransaction(
+ [Stores.exchanges, Stores.exchangeUpdatedEvents],
+ async (tx) => {
+ const r = await tx.get(Stores.exchanges, exchangeBaseUrl);
+ if (!r) {
+ return;
+ }
+ if (r.updateStatus != ExchangeUpdateStatus.FinalizeUpdate) {
+ return;
+ }
+ r.addComplete = true;
+ r.updateStatus = ExchangeUpdateStatus.Finished;
+ await tx.put(Stores.exchanges, r);
+ const updateEvent: ExchangeUpdatedEventRecord = {
+ exchangeBaseUrl: exchange.baseUrl,
+ timestamp: getTimestampNow(),
+ };
+ await tx.put(Stores.exchangeUpdatedEvents, updateEvent);
+ },
+ );
+}
+
+async function updateExchangeWithTermsOfService(
+ ws: InternalWalletState,
+ exchangeBaseUrl: string,
+): Promise<void> {
+ const exchange = await ws.db.get(Stores.exchanges, exchangeBaseUrl);
+ if (!exchange) {
+ return;
+ }
+ if (exchange.updateStatus != ExchangeUpdateStatus.FetchTerms) {
+ return;
+ }
+ const reqUrl = new URL("terms", exchangeBaseUrl);
+ reqUrl.searchParams.set("cacheBreaker", WALLET_CACHE_BREAKER_CLIENT_VERSION);
+ const headers = {
+ Accept: "text/plain",
+ };
+
+ const resp = await ws.http.get(reqUrl.href, { headers });
+ const tosText = await readSuccessResponseTextOrThrow(resp);
+ const tosEtag = resp.headers.get("etag") || undefined;
+
+ await ws.db.runWithWriteTransaction([Stores.exchanges], async (tx) => {
+ const r = await tx.get(Stores.exchanges, exchangeBaseUrl);
+ if (!r) {
+ return;
+ }
+ if (r.updateStatus != ExchangeUpdateStatus.FetchTerms) {
+ return;
+ }
+ r.termsOfServiceText = tosText;
+ r.termsOfServiceLastEtag = tosEtag;
+ r.updateStatus = ExchangeUpdateStatus.FinalizeUpdate;
+ await tx.put(Stores.exchanges, r);
+ });
+}
+
+export async function acceptExchangeTermsOfService(
+ ws: InternalWalletState,
+ exchangeBaseUrl: string,
+ etag: string | undefined,
+): Promise<void> {
+ await ws.db.runWithWriteTransaction([Stores.exchanges], async (tx) => {
+ const r = await tx.get(Stores.exchanges, exchangeBaseUrl);
+ if (!r) {
+ return;
+ }
+ r.termsOfServiceAcceptedEtag = etag;
+ r.termsOfServiceAcceptedTimestamp = getTimestampNow();
+ await tx.put(Stores.exchanges, r);
+ });
+}
+
+/**
+ * Fetch wire information for an exchange and store it in the database.
+ *
+ * @param exchangeBaseUrl Exchange base URL, assumed to be already normalized.
+ */
+async function updateExchangeWithWireInfo(
+ ws: InternalWalletState,
+ exchangeBaseUrl: string,
+): Promise<void> {
+ const exchange = await ws.db.get(Stores.exchanges, exchangeBaseUrl);
+ if (!exchange) {
+ return;
+ }
+ if (exchange.updateStatus != ExchangeUpdateStatus.FetchWire) {
+ return;
+ }
+ const details = exchange.details;
+ if (!details) {
+ throw Error("invalid exchange state");
+ }
+ const reqUrl = new URL("wire", exchangeBaseUrl);
+ reqUrl.searchParams.set("cacheBreaker", WALLET_CACHE_BREAKER_CLIENT_VERSION);
+
+ const resp = await ws.http.get(reqUrl.href);
+ const wireInfo = await readSuccessResponseJsonOrThrow(
+ resp,
+ codecForExchangeWireJson(),
+ );
+
+ for (const a of wireInfo.accounts) {
+ logger.trace("validating exchange acct");
+ const isValid = await ws.cryptoApi.isValidWireAccount(
+ a.payto_uri,
+ a.master_sig,
+ details.masterPublicKey,
+ );
+ if (!isValid) {
+ throw Error("exchange acct signature invalid");
+ }
+ }
+ const feesForType: { [wireMethod: string]: WireFee[] } = {};
+ for (const wireMethod of Object.keys(wireInfo.fees)) {
+ const feeList: WireFee[] = [];
+ for (const x of wireInfo.fees[wireMethod]) {
+ const startStamp = x.start_date;
+ const endStamp = x.end_date;
+ const fee: WireFee = {
+ closingFee: Amounts.parseOrThrow(x.closing_fee),
+ endStamp,
+ sig: x.sig,
+ startStamp,
+ wireFee: Amounts.parseOrThrow(x.wire_fee),
+ };
+ const isValid = await ws.cryptoApi.isValidWireFee(
+ wireMethod,
+ fee,
+ details.masterPublicKey,
+ );
+ if (!isValid) {
+ throw Error("exchange wire fee signature invalid");
+ }
+ feeList.push(fee);
+ }
+ feesForType[wireMethod] = feeList;
+ }
+
+ await ws.db.runWithWriteTransaction([Stores.exchanges], async (tx) => {
+ const r = await tx.get(Stores.exchanges, exchangeBaseUrl);
+ if (!r) {
+ return;
+ }
+ if (r.updateStatus != ExchangeUpdateStatus.FetchWire) {
+ return;
+ }
+ r.wireInfo = {
+ accounts: wireInfo.accounts,
+ feesForType: feesForType,
+ };
+ r.updateStatus = ExchangeUpdateStatus.FetchTerms;
+ r.lastError = undefined;
+ await tx.put(Stores.exchanges, r);
+ });
+}
+
+export async function updateExchangeFromUrl(
+ ws: InternalWalletState,
+ baseUrl: string,
+ forceNow = false,
+): Promise<ExchangeRecord> {
+ const onOpErr = (e: OperationErrorDetails): Promise<void> =>
+ setExchangeError(ws, baseUrl, e);
+ return await guardOperationException(
+ () => updateExchangeFromUrlImpl(ws, baseUrl, forceNow),
+ onOpErr,
+ );
+}
+
+/**
+ * Update or add exchange DB entry by fetching the /keys and /wire information.
+ * Optionally link the reserve entry to the new or existing
+ * exchange entry in then DB.
+ */
+async function updateExchangeFromUrlImpl(
+ ws: InternalWalletState,
+ baseUrl: string,
+ forceNow = false,
+): Promise<ExchangeRecord> {
+ const now = getTimestampNow();
+ baseUrl = canonicalizeBaseUrl(baseUrl);
+
+ const r = await ws.db.get(Stores.exchanges, baseUrl);
+ if (!r) {
+ const newExchangeRecord: ExchangeRecord = {
+ builtIn: false,
+ addComplete: false,
+ permanent: true,
+ baseUrl: baseUrl,
+ details: undefined,
+ wireInfo: undefined,
+ updateStatus: ExchangeUpdateStatus.FetchKeys,
+ updateStarted: now,
+ updateReason: ExchangeUpdateReason.Initial,
+ timestampAdded: getTimestampNow(),
+ termsOfServiceAcceptedEtag: undefined,
+ termsOfServiceAcceptedTimestamp: undefined,
+ termsOfServiceLastEtag: undefined,
+ termsOfServiceText: undefined,
+ updateDiff: undefined,
+ };
+ await ws.db.put(Stores.exchanges, newExchangeRecord);
+ } else {
+ await ws.db.runWithWriteTransaction([Stores.exchanges], async (t) => {
+ const rec = await t.get(Stores.exchanges, baseUrl);
+ if (!rec) {
+ return;
+ }
+ if (rec.updateStatus != ExchangeUpdateStatus.FetchKeys && !forceNow) {
+ return;
+ }
+ if (rec.updateStatus != ExchangeUpdateStatus.FetchKeys && forceNow) {
+ rec.updateReason = ExchangeUpdateReason.Forced;
+ }
+ rec.updateStarted = now;
+ rec.updateStatus = ExchangeUpdateStatus.FetchKeys;
+ rec.lastError = undefined;
+ t.put(Stores.exchanges, rec);
+ });
+ }
+
+ await updateExchangeWithKeys(ws, baseUrl);
+ await updateExchangeWithWireInfo(ws, baseUrl);
+ await updateExchangeWithTermsOfService(ws, baseUrl);
+ await updateExchangeFinalize(ws, baseUrl);
+
+ const updatedExchange = await ws.db.get(Stores.exchanges, baseUrl);
+
+ if (!updatedExchange) {
+ // This should practically never happen
+ throw Error("exchange not found");
+ }
+ return updatedExchange;
+}
+
+/**
+ * Check if and how an exchange is trusted and/or audited.
+ */
+export async function getExchangeTrust(
+ ws: InternalWalletState,
+ exchangeInfo: ExchangeRecord,
+): Promise<{ isTrusted: boolean; isAudited: boolean }> {
+ let isTrusted = false;
+ let isAudited = false;
+ const exchangeDetails = exchangeInfo.details;
+ if (!exchangeDetails) {
+ throw Error(`exchange ${exchangeInfo.baseUrl} details not available`);
+ }
+ const currencyRecord = await ws.db.get(
+ Stores.currencies,
+ exchangeDetails.currency,
+ );
+ if (currencyRecord) {
+ for (const trustedExchange of currencyRecord.exchanges) {
+ if (trustedExchange.exchangePub === exchangeDetails.masterPublicKey) {
+ isTrusted = true;
+ break;
+ }
+ }
+ for (const trustedAuditor of currencyRecord.auditors) {
+ for (const exchangeAuditor of exchangeDetails.auditors) {
+ if (trustedAuditor.auditorPub === exchangeAuditor.auditor_pub) {
+ isAudited = true;
+ break;
+ }
+ }
+ }
+ }
+ return { isTrusted, isAudited };
+}
+
+export async function getExchangePaytoUri(
+ ws: InternalWalletState,
+ exchangeBaseUrl: string,
+ supportedTargetTypes: string[],
+): Promise<string> {
+ // We do the update here, since the exchange might not even exist
+ // yet in our database.
+ const exchangeRecord = await updateExchangeFromUrl(ws, exchangeBaseUrl);
+ if (!exchangeRecord) {
+ throw Error(`Exchange '${exchangeBaseUrl}' not found.`);
+ }
+ const exchangeWireInfo = exchangeRecord.wireInfo;
+ if (!exchangeWireInfo) {
+ throw Error(`Exchange wire info for '${exchangeBaseUrl}' not found.`);
+ }
+ for (const account of exchangeWireInfo.accounts) {
+ const res = parsePaytoUri(account.payto_uri);
+ if (!res) {
+ continue;
+ }
+ if (supportedTargetTypes.includes(res.targetType)) {
+ return account.payto_uri;
+ }
+ }
+ throw Error("no matching exchange account found");
+}
diff --git a/packages/taler-wallet-core/src/operations/pay.d.ts.map b/packages/taler-wallet-core/src/operations/pay.d.ts.map
new file mode 100644
index 000000000..7ab4d7be6
--- /dev/null
+++ b/packages/taler-wallet-core/src/operations/pay.d.ts.map
@@ -0,0 +1 @@
+{"version":3,"file":"pay.d.ts","sourceRoot":"","sources":["pay.ts"],"names":[],"mappings":"AA6CA,OAAO,EACL,gBAAgB,EAEhB,gBAAgB,EAGjB,MAAM,sBAAsB,CAAC;AAE9B,OAAO,EAAE,UAAU,EAAE,MAAM,iBAAiB,CAAC;AAK7C,OAAO,EAAE,mBAAmB,EAAE,MAAM,SAAS,CAAC;AAY9C;;;GAGG;AACH,MAAM,WAAW,gBAAgB;IAC/B;;OAEG;IACH,aAAa,EAAE,UAAU,CAAC;IAE1B;;OAEG;IACH,QAAQ,EAAE,MAAM,EAAE,CAAC;IAEnB;;OAEG;IACH,iBAAiB,EAAE,UAAU,EAAE,CAAC;IAEhC;;OAEG;IACH,gBAAgB,EAAE,UAAU,CAAC;IAE7B;;OAEG;IACH,mBAAmB,EAAE,UAAU,CAAC;CACjC;AAED;;;GAGG;AACH,MAAM,WAAW,iBAAiB;IAChC;;OAEG;IACH,OAAO,EAAE,MAAM,CAAC;IAEhB;;OAEG;IACH,QAAQ,EAAE,MAAM,CAAC;IAEjB;;;OAGG;IACH,eAAe,EAAE,UAAU,CAAC;IAE5B;;OAEG;IACH,UAAU,EAAE,UAAU,CAAC;CACxB;AAED,MAAM,WAAW,WAAW;IAC1B,SAAS,EAAE,UAAU,CAAC;CACvB;AAED;;;;;;GAMG;AACH,wBAAsB,mBAAmB,CACvC,EAAE,EAAE,mBAAmB,EACvB,GAAG,EAAE,gBAAgB,GACpB,OAAO,CAAC,WAAW,CAAC,CA+BtB;AAED;;;;;GAKG;AACH,wBAAgB,cAAc,CAC5B,IAAI,EAAE,iBAAiB,EAAE,EACzB,mBAAmB,EAAE,UAAU,EAC/B,gBAAgB,EAAE,UAAU,EAC5B,eAAe,EAAE,UAAU,GAC1B,gBAAgB,GAAG,SAAS,CAsF9B;AA+RD,wBAAsB,uBAAuB,CAC3C,EAAE,EAAE,mBAAmB,EACvB,UAAU,EAAE,MAAM,EAClB,QAAQ,UAAQ,GACf,OAAO,CAAC,IAAI,CAAC,CAOf;AAwMD,wBAAsB,SAAS,CAC7B,EAAE,EAAE,mBAAmB,EACvB,UAAU,EAAE,MAAM,GACjB,OAAO,CAAC,gBAAgB,CAAC,CAmF3B;AAED;;;;;GAKG;AACH,wBAAsB,gBAAgB,CACpC,EAAE,EAAE,mBAAmB,EACvB,WAAW,EAAE,MAAM,GAClB,OAAO,CAAC,gBAAgB,CAAC,CAgH3B;AAED;;GAEG;AACH,wBAAsB,UAAU,CAC9B,EAAE,EAAE,mBAAmB,EACvB,UAAU,EAAE,MAAM,EAClB,iBAAiB,EAAE,MAAM,GAAG,SAAS,GACpC,OAAO,CAAC,gBAAgB,CAAC,CAwF3B;AAED,wBAAsB,kBAAkB,CACtC,EAAE,EAAE,mBAAmB,EACvB,UAAU,EAAE,MAAM,EAClB,QAAQ,UAAQ,GACf,OAAO,CAAC,IAAI,CAAC,CAOf;AAiCD,wBAAsB,cAAc,CAClC,EAAE,EAAE,mBAAmB,EACvB,UAAU,EAAE,MAAM,GACjB,OAAO,CAAC,IAAI,CAAC,CAsBf"} \ No newline at end of file
diff --git a/packages/taler-wallet-core/src/operations/pay.ts b/packages/taler-wallet-core/src/operations/pay.ts
new file mode 100644
index 000000000..f23e326f8
--- /dev/null
+++ b/packages/taler-wallet-core/src/operations/pay.ts
@@ -0,0 +1,1148 @@
+/*
+ This file is part of GNU Taler
+ (C) 2019 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ * Implementation of the payment operation, including downloading and
+ * claiming of proposals.
+ *
+ * @author Florian Dold
+ */
+
+/**
+ * Imports.
+ */
+import { encodeCrock, getRandomBytes } from "../crypto/talerCrypto";
+import {
+ CoinStatus,
+ initRetryInfo,
+ ProposalRecord,
+ ProposalStatus,
+ PurchaseRecord,
+ Stores,
+ updateRetryInfoTimeout,
+ PayEventRecord,
+ WalletContractData,
+} from "../types/dbTypes";
+import { NotificationType } from "../types/notifications";
+import {
+ codecForProposal,
+ codecForContractTerms,
+ CoinDepositPermission,
+ codecForMerchantPayResponse,
+} from "../types/talerTypes";
+import {
+ ConfirmPayResult,
+ OperationErrorDetails,
+ PreparePayResult,
+ RefreshReason,
+ PreparePayResultType,
+} from "../types/walletTypes";
+import * as Amounts from "../util/amounts";
+import { AmountJson } from "../util/amounts";
+import { Logger } from "../util/logging";
+import { parsePayUri } from "../util/taleruri";
+import { guardOperationException, OperationFailedError } from "./errors";
+import { createRefreshGroup, getTotalRefreshCost } from "./refresh";
+import { InternalWalletState } from "./state";
+import { getTimestampNow, timestampAddDuration } from "../util/time";
+import { strcmp, canonicalJson } from "../util/helpers";
+import { readSuccessResponseJsonOrThrow } from "../util/http";
+import { TalerErrorCode } from "../TalerErrorCode";
+import { URL } from "../util/url";
+
+/**
+ * Logger.
+ */
+const logger = new Logger("pay.ts");
+
+/**
+ * Result of selecting coins, contains the exchange, and selected
+ * coins with their denomination.
+ */
+export interface PayCoinSelection {
+ /**
+ * Amount requested by the merchant.
+ */
+ paymentAmount: AmountJson;
+
+ /**
+ * Public keys of the coins that were selected.
+ */
+ coinPubs: string[];
+
+ /**
+ * Amount that each coin contributes.
+ */
+ coinContributions: AmountJson[];
+
+ /**
+ * How much of the wire fees is the customer paying?
+ */
+ customerWireFees: AmountJson;
+
+ /**
+ * How much of the deposit fees is the customer paying?
+ */
+ customerDepositFees: AmountJson;
+}
+
+/**
+ * Structure to describe a coin that is available to be
+ * used in a payment.
+ */
+export interface AvailableCoinInfo {
+ /**
+ * Public key of the coin.
+ */
+ coinPub: string;
+
+ /**
+ * Coin's denomination public key.
+ */
+ denomPub: string;
+
+ /**
+ * Amount still remaining (typically the full amount,
+ * as coins are always refreshed after use.)
+ */
+ availableAmount: AmountJson;
+
+ /**
+ * Deposit fee for the coin.
+ */
+ feeDeposit: AmountJson;
+}
+
+export interface PayCostInfo {
+ totalCost: AmountJson;
+}
+
+/**
+ * Compute the total cost of a payment to the customer.
+ *
+ * This includes the amount taken by the merchant, fees (wire/deposit) contributed
+ * by the customer, refreshing fees, fees for withdraw-after-refresh and "trimmings"
+ * of coins that are too small to spend.
+ */
+export async function getTotalPaymentCost(
+ ws: InternalWalletState,
+ pcs: PayCoinSelection,
+): Promise<PayCostInfo> {
+ const costs = [];
+ for (let i = 0; i < pcs.coinPubs.length; i++) {
+ const coin = await ws.db.get(Stores.coins, pcs.coinPubs[i]);
+ if (!coin) {
+ throw Error("can't calculate payment cost, coin not found");
+ }
+ const denom = await ws.db.get(Stores.denominations, [
+ coin.exchangeBaseUrl,
+ coin.denomPub,
+ ]);
+ if (!denom) {
+ throw Error(
+ "can't calculate payment cost, denomination for coin not found",
+ );
+ }
+ const allDenoms = await ws.db
+ .iterIndex(
+ Stores.denominations.exchangeBaseUrlIndex,
+ coin.exchangeBaseUrl,
+ )
+ .toArray();
+ const amountLeft = Amounts.sub(denom.value, pcs.coinContributions[i])
+ .amount;
+ const refreshCost = getTotalRefreshCost(allDenoms, denom, amountLeft);
+ costs.push(pcs.coinContributions[i]);
+ costs.push(refreshCost);
+ }
+ return {
+ totalCost: Amounts.sum(costs).amount,
+ };
+}
+
+/**
+ * Given a list of available coins, select coins to spend under the merchant's
+ * constraints.
+ *
+ * This function is only exported for the sake of unit tests.
+ */
+export function selectPayCoins(
+ acis: AvailableCoinInfo[],
+ contractTermsAmount: AmountJson,
+ customerWireFees: AmountJson,
+ depositFeeLimit: AmountJson,
+): PayCoinSelection | undefined {
+ if (acis.length === 0) {
+ return undefined;
+ }
+ const coinPubs: string[] = [];
+ const coinContributions: AmountJson[] = [];
+ // Sort by available amount (descending), deposit fee (ascending) and
+ // denomPub (ascending) if deposit fee is the same
+ // (to guarantee deterministic results)
+ acis.sort(
+ (o1, o2) =>
+ -Amounts.cmp(o1.availableAmount, o2.availableAmount) ||
+ Amounts.cmp(o1.feeDeposit, o2.feeDeposit) ||
+ strcmp(o1.denomPub, o2.denomPub),
+ );
+ const paymentAmount = Amounts.add(contractTermsAmount, customerWireFees)
+ .amount;
+ const currency = paymentAmount.currency;
+ let amountPayRemaining = paymentAmount;
+ let amountDepositFeeLimitRemaining = depositFeeLimit;
+ const customerDepositFees = Amounts.getZero(currency);
+ for (const aci of acis) {
+ // Don't use this coin if depositing it is more expensive than
+ // the amount it would give the merchant.
+ if (Amounts.cmp(aci.feeDeposit, aci.availableAmount) >= 0) {
+ continue;
+ }
+ if (amountPayRemaining.value === 0 && amountPayRemaining.fraction === 0) {
+ // We have spent enough!
+ break;
+ }
+
+ // How much does the user spend on deposit fees for this coin?
+ const depositFeeSpend = Amounts.sub(
+ aci.feeDeposit,
+ amountDepositFeeLimitRemaining,
+ ).amount;
+
+ if (Amounts.isZero(depositFeeSpend)) {
+ // Fees are still covered by the merchant.
+ amountDepositFeeLimitRemaining = Amounts.sub(
+ amountDepositFeeLimitRemaining,
+ aci.feeDeposit,
+ ).amount;
+ } else {
+ amountDepositFeeLimitRemaining = Amounts.getZero(currency);
+ }
+
+ let coinSpend: AmountJson;
+ const amountActualAvailable = Amounts.sub(
+ aci.availableAmount,
+ depositFeeSpend,
+ ).amount;
+
+ if (Amounts.cmp(amountActualAvailable, amountPayRemaining) > 0) {
+ // Partial spending, as the coin is worth more than the remaining
+ // amount to pay.
+ coinSpend = Amounts.add(amountPayRemaining, depositFeeSpend).amount;
+ // Make sure we contribute at least the deposit fee, otherwise
+ // contributing this coin would cause a loss for the merchant.
+ if (Amounts.cmp(coinSpend, aci.feeDeposit) < 0) {
+ coinSpend = aci.feeDeposit;
+ }
+ amountPayRemaining = Amounts.getZero(currency);
+ } else {
+ // Spend the full remaining amount on the coin
+ coinSpend = aci.availableAmount;
+ amountPayRemaining = Amounts.add(amountPayRemaining, depositFeeSpend)
+ .amount;
+ amountPayRemaining = Amounts.sub(amountPayRemaining, aci.availableAmount)
+ .amount;
+ }
+
+ coinPubs.push(aci.coinPub);
+ coinContributions.push(coinSpend);
+ }
+ if (Amounts.isZero(amountPayRemaining)) {
+ return {
+ paymentAmount: contractTermsAmount,
+ coinContributions,
+ coinPubs,
+ customerDepositFees,
+ customerWireFees,
+ };
+ }
+ return undefined;
+}
+
+/**
+ * Select coins from the wallet's database that can be used
+ * to pay for the given contract.
+ *
+ * If payment is impossible, undefined is returned.
+ */
+async function getCoinsForPayment(
+ ws: InternalWalletState,
+ contractData: WalletContractData,
+): Promise<PayCoinSelection | undefined> {
+ const remainingAmount = contractData.amount;
+
+ const exchanges = await ws.db.iter(Stores.exchanges).toArray();
+
+ for (const exchange of exchanges) {
+ let isOkay = false;
+ const exchangeDetails = exchange.details;
+ if (!exchangeDetails) {
+ continue;
+ }
+ const exchangeFees = exchange.wireInfo;
+ if (!exchangeFees) {
+ continue;
+ }
+
+ // is the exchange explicitly allowed?
+ for (const allowedExchange of contractData.allowedExchanges) {
+ if (allowedExchange.exchangePub === exchangeDetails.masterPublicKey) {
+ isOkay = true;
+ break;
+ }
+ }
+
+ // is the exchange allowed because of one of its auditors?
+ if (!isOkay) {
+ for (const allowedAuditor of contractData.allowedAuditors) {
+ for (const auditor of exchangeDetails.auditors) {
+ if (auditor.auditor_pub === allowedAuditor.auditorPub) {
+ isOkay = true;
+ break;
+ }
+ }
+ if (isOkay) {
+ break;
+ }
+ }
+ }
+
+ if (!isOkay) {
+ continue;
+ }
+
+ const coins = await ws.db
+ .iterIndex(Stores.coins.exchangeBaseUrlIndex, exchange.baseUrl)
+ .toArray();
+
+ if (!coins || coins.length === 0) {
+ continue;
+ }
+
+ // Denomination of the first coin, we assume that all other
+ // coins have the same currency
+ const firstDenom = await ws.db.get(Stores.denominations, [
+ exchange.baseUrl,
+ coins[0].denomPub,
+ ]);
+ if (!firstDenom) {
+ throw Error("db inconsistent");
+ }
+ const currency = firstDenom.value.currency;
+ const acis: AvailableCoinInfo[] = [];
+ for (const coin of coins) {
+ const denom = await ws.db.get(Stores.denominations, [
+ exchange.baseUrl,
+ coin.denomPub,
+ ]);
+ if (!denom) {
+ throw Error("db inconsistent");
+ }
+ if (denom.value.currency !== currency) {
+ console.warn(
+ `same pubkey for different currencies at exchange ${exchange.baseUrl}`,
+ );
+ continue;
+ }
+ if (coin.suspended) {
+ continue;
+ }
+ if (coin.status !== CoinStatus.Fresh) {
+ continue;
+ }
+ acis.push({
+ availableAmount: coin.currentAmount,
+ coinPub: coin.coinPub,
+ denomPub: coin.denomPub,
+ feeDeposit: denom.feeDeposit,
+ });
+ }
+
+ let wireFee: AmountJson | undefined;
+ for (const fee of exchangeFees.feesForType[contractData.wireMethod] || []) {
+ if (
+ fee.startStamp <= contractData.timestamp &&
+ fee.endStamp >= contractData.timestamp
+ ) {
+ wireFee = fee.wireFee;
+ break;
+ }
+ }
+
+ let customerWireFee: AmountJson;
+
+ if (wireFee) {
+ const amortizedWireFee = Amounts.divide(
+ wireFee,
+ contractData.wireFeeAmortization,
+ );
+ if (Amounts.cmp(contractData.maxWireFee, amortizedWireFee) < 0) {
+ customerWireFee = amortizedWireFee;
+ } else {
+ customerWireFee = Amounts.getZero(currency);
+ }
+ } else {
+ customerWireFee = Amounts.getZero(currency);
+ }
+
+ // Try if paying using this exchange works
+ const res = selectPayCoins(
+ acis,
+ remainingAmount,
+ customerWireFee,
+ contractData.maxDepositFee,
+ );
+ if (res) {
+ return res;
+ }
+ }
+ return undefined;
+}
+
+/**
+ * Record all information that is necessary to
+ * pay for a proposal in the wallet's database.
+ */
+async function recordConfirmPay(
+ ws: InternalWalletState,
+ proposal: ProposalRecord,
+ coinSelection: PayCoinSelection,
+ coinDepositPermissions: CoinDepositPermission[],
+ sessionIdOverride: string | undefined,
+): Promise<PurchaseRecord> {
+ const d = proposal.download;
+ if (!d) {
+ throw Error("proposal is in invalid state");
+ }
+ let sessionId;
+ if (sessionIdOverride) {
+ sessionId = sessionIdOverride;
+ } else {
+ sessionId = proposal.downloadSessionId;
+ }
+ logger.trace(`recording payment with session ID ${sessionId}`);
+ const payCostInfo = await getTotalPaymentCost(ws, coinSelection);
+ const t: PurchaseRecord = {
+ abortDone: false,
+ abortRequested: false,
+ contractTermsRaw: d.contractTermsRaw,
+ contractData: d.contractData,
+ lastSessionId: sessionId,
+ payCoinSelection: coinSelection,
+ payCostInfo,
+ coinDepositPermissions,
+ timestampAccept: getTimestampNow(),
+ timestampLastRefundStatus: undefined,
+ proposalId: proposal.proposalId,
+ lastPayError: undefined,
+ lastRefundStatusError: undefined,
+ payRetryInfo: initRetryInfo(),
+ refundStatusRetryInfo: initRetryInfo(),
+ refundStatusRequested: false,
+ timestampFirstSuccessfulPay: undefined,
+ autoRefundDeadline: undefined,
+ paymentSubmitPending: true,
+ refunds: {},
+ };
+
+ await ws.db.runWithWriteTransaction(
+ [Stores.coins, Stores.purchases, Stores.proposals, Stores.refreshGroups],
+ async (tx) => {
+ const p = await tx.get(Stores.proposals, proposal.proposalId);
+ if (p) {
+ p.proposalStatus = ProposalStatus.ACCEPTED;
+ p.lastError = undefined;
+ p.retryInfo = initRetryInfo(false);
+ await tx.put(Stores.proposals, p);
+ }
+ await tx.put(Stores.purchases, t);
+ for (let i = 0; i < coinSelection.coinPubs.length; i++) {
+ const coin = await tx.get(Stores.coins, coinSelection.coinPubs[i]);
+ if (!coin) {
+ throw Error("coin allocated for payment doesn't exist anymore");
+ }
+ coin.status = CoinStatus.Dormant;
+ const remaining = Amounts.sub(
+ coin.currentAmount,
+ coinSelection.coinContributions[i],
+ );
+ if (remaining.saturated) {
+ throw Error("not enough remaining balance on coin for payment");
+ }
+ coin.currentAmount = remaining.amount;
+ await tx.put(Stores.coins, coin);
+ }
+ const refreshCoinPubs = coinSelection.coinPubs.map((x) => ({
+ coinPub: x,
+ }));
+ await createRefreshGroup(ws, tx, refreshCoinPubs, RefreshReason.Pay);
+ },
+ );
+
+ ws.notify({
+ type: NotificationType.ProposalAccepted,
+ proposalId: proposal.proposalId,
+ });
+ return t;
+}
+
+function getNextUrl(contractData: WalletContractData): string {
+ const f = contractData.fulfillmentUrl;
+ if (f.startsWith("http://") || f.startsWith("https://")) {
+ const fu = new URL(contractData.fulfillmentUrl);
+ fu.searchParams.set("order_id", contractData.orderId);
+ return fu.href;
+ } else {
+ return f;
+ }
+}
+
+async function incrementProposalRetry(
+ ws: InternalWalletState,
+ proposalId: string,
+ err: OperationErrorDetails | undefined,
+): Promise<void> {
+ await ws.db.runWithWriteTransaction([Stores.proposals], async (tx) => {
+ const pr = await tx.get(Stores.proposals, proposalId);
+ if (!pr) {
+ return;
+ }
+ if (!pr.retryInfo) {
+ return;
+ }
+ pr.retryInfo.retryCounter++;
+ updateRetryInfoTimeout(pr.retryInfo);
+ pr.lastError = err;
+ await tx.put(Stores.proposals, pr);
+ });
+ if (err) {
+ ws.notify({ type: NotificationType.ProposalOperationError, error: err });
+ }
+}
+
+async function incrementPurchasePayRetry(
+ ws: InternalWalletState,
+ proposalId: string,
+ err: OperationErrorDetails | undefined,
+): Promise<void> {
+ console.log("incrementing purchase pay retry with error", err);
+ await ws.db.runWithWriteTransaction([Stores.purchases], async (tx) => {
+ const pr = await tx.get(Stores.purchases, proposalId);
+ if (!pr) {
+ return;
+ }
+ if (!pr.payRetryInfo) {
+ return;
+ }
+ pr.payRetryInfo.retryCounter++;
+ updateRetryInfoTimeout(pr.payRetryInfo);
+ pr.lastPayError = err;
+ await tx.put(Stores.purchases, pr);
+ });
+ if (err) {
+ ws.notify({ type: NotificationType.PayOperationError, error: err });
+ }
+}
+
+export async function processDownloadProposal(
+ ws: InternalWalletState,
+ proposalId: string,
+ forceNow = false,
+): Promise<void> {
+ const onOpErr = (err: OperationErrorDetails): Promise<void> =>
+ incrementProposalRetry(ws, proposalId, err);
+ await guardOperationException(
+ () => processDownloadProposalImpl(ws, proposalId, forceNow),
+ onOpErr,
+ );
+}
+
+async function resetDownloadProposalRetry(
+ ws: InternalWalletState,
+ proposalId: string,
+): Promise<void> {
+ await ws.db.mutate(Stores.proposals, proposalId, (x) => {
+ if (x.retryInfo.active) {
+ x.retryInfo = initRetryInfo();
+ }
+ return x;
+ });
+}
+
+async function processDownloadProposalImpl(
+ ws: InternalWalletState,
+ proposalId: string,
+ forceNow: boolean,
+): Promise<void> {
+ if (forceNow) {
+ await resetDownloadProposalRetry(ws, proposalId);
+ }
+ const proposal = await ws.db.get(Stores.proposals, proposalId);
+ if (!proposal) {
+ return;
+ }
+ if (proposal.proposalStatus != ProposalStatus.DOWNLOADING) {
+ return;
+ }
+
+ const orderClaimUrl = new URL(
+ `orders/${proposal.orderId}/claim`,
+ proposal.merchantBaseUrl,
+ ).href;
+ logger.trace("downloading contract from '" + orderClaimUrl + "'");
+
+ const requestBody: {
+ nonce: string;
+ token?: string;
+ } = {
+ nonce: proposal.noncePub,
+ };
+ if (proposal.claimToken) {
+ requestBody.token = proposal.claimToken;
+ }
+
+ const resp = await ws.http.postJson(orderClaimUrl, requestBody);
+ const proposalResp = await readSuccessResponseJsonOrThrow(
+ resp,
+ codecForProposal(),
+ );
+
+ // The proposalResp contains the contract terms as raw JSON,
+ // as the coded to parse them doesn't necessarily round-trip.
+ // We need this raw JSON to compute the contract terms hash.
+
+ const contractTermsHash = await ws.cryptoApi.hashString(
+ canonicalJson(proposalResp.contract_terms),
+ );
+
+ const parsedContractTerms = codecForContractTerms().decode(
+ proposalResp.contract_terms,
+ );
+ const fulfillmentUrl = parsedContractTerms.fulfillment_url;
+
+ await ws.db.runWithWriteTransaction(
+ [Stores.proposals, Stores.purchases],
+ async (tx) => {
+ const p = await tx.get(Stores.proposals, proposalId);
+ if (!p) {
+ return;
+ }
+ if (p.proposalStatus !== ProposalStatus.DOWNLOADING) {
+ return;
+ }
+ const amount = Amounts.parseOrThrow(parsedContractTerms.amount);
+ let maxWireFee: AmountJson;
+ if (parsedContractTerms.max_wire_fee) {
+ maxWireFee = Amounts.parseOrThrow(parsedContractTerms.max_wire_fee);
+ } else {
+ maxWireFee = Amounts.getZero(amount.currency);
+ }
+ p.download = {
+ contractData: {
+ amount,
+ contractTermsHash: contractTermsHash,
+ fulfillmentUrl: parsedContractTerms.fulfillment_url,
+ merchantBaseUrl: parsedContractTerms.merchant_base_url,
+ merchantPub: parsedContractTerms.merchant_pub,
+ merchantSig: proposalResp.sig,
+ orderId: parsedContractTerms.order_id,
+ summary: parsedContractTerms.summary,
+ autoRefund: parsedContractTerms.auto_refund,
+ maxWireFee,
+ payDeadline: parsedContractTerms.pay_deadline,
+ refundDeadline: parsedContractTerms.refund_deadline,
+ wireFeeAmortization: parsedContractTerms.wire_fee_amortization || 1,
+ allowedAuditors: parsedContractTerms.auditors.map((x) => ({
+ auditorBaseUrl: x.url,
+ auditorPub: x.master_pub,
+ })),
+ allowedExchanges: parsedContractTerms.exchanges.map((x) => ({
+ exchangeBaseUrl: x.url,
+ exchangePub: x.master_pub,
+ })),
+ timestamp: parsedContractTerms.timestamp,
+ wireMethod: parsedContractTerms.wire_method,
+ wireInfoHash: parsedContractTerms.h_wire,
+ maxDepositFee: Amounts.parseOrThrow(parsedContractTerms.max_fee),
+ merchant: parsedContractTerms.merchant,
+ products: parsedContractTerms.products,
+ summaryI18n: parsedContractTerms.summary_i18n,
+ },
+ contractTermsRaw: JSON.stringify(proposalResp.contract_terms),
+ };
+ if (
+ fulfillmentUrl.startsWith("http://") ||
+ fulfillmentUrl.startsWith("https://")
+ ) {
+ const differentPurchase = await tx.getIndexed(
+ Stores.purchases.fulfillmentUrlIndex,
+ fulfillmentUrl,
+ );
+ if (differentPurchase) {
+ console.log("repurchase detected");
+ p.proposalStatus = ProposalStatus.REPURCHASE;
+ p.repurchaseProposalId = differentPurchase.proposalId;
+ await tx.put(Stores.proposals, p);
+ return;
+ }
+ }
+ p.proposalStatus = ProposalStatus.PROPOSED;
+ await tx.put(Stores.proposals, p);
+ },
+ );
+
+ ws.notify({
+ type: NotificationType.ProposalDownloaded,
+ proposalId: proposal.proposalId,
+ });
+}
+
+/**
+ * Download a proposal and store it in the database.
+ * Returns an id for it to retrieve it later.
+ *
+ * @param sessionId Current session ID, if the proposal is being
+ * downloaded in the context of a session ID.
+ */
+async function startDownloadProposal(
+ ws: InternalWalletState,
+ merchantBaseUrl: string,
+ orderId: string,
+ sessionId: string | undefined,
+ claimToken: string | undefined,
+): Promise<string> {
+ const oldProposal = await ws.db.getIndexed(
+ Stores.proposals.urlAndOrderIdIndex,
+ [merchantBaseUrl, orderId],
+ );
+ if (oldProposal) {
+ await processDownloadProposal(ws, oldProposal.proposalId);
+ return oldProposal.proposalId;
+ }
+
+ const { priv, pub } = await ws.cryptoApi.createEddsaKeypair();
+ const proposalId = encodeCrock(getRandomBytes(32));
+
+ const proposalRecord: ProposalRecord = {
+ download: undefined,
+ noncePriv: priv,
+ noncePub: pub,
+ claimToken,
+ timestamp: getTimestampNow(),
+ merchantBaseUrl,
+ orderId,
+ proposalId: proposalId,
+ proposalStatus: ProposalStatus.DOWNLOADING,
+ repurchaseProposalId: undefined,
+ retryInfo: initRetryInfo(),
+ lastError: undefined,
+ downloadSessionId: sessionId,
+ };
+
+ await ws.db.runWithWriteTransaction([Stores.proposals], async (tx) => {
+ const existingRecord = await tx.getIndexed(
+ Stores.proposals.urlAndOrderIdIndex,
+ [merchantBaseUrl, orderId],
+ );
+ if (existingRecord) {
+ // Created concurrently
+ return;
+ }
+ await tx.put(Stores.proposals, proposalRecord);
+ });
+
+ await processDownloadProposal(ws, proposalId);
+ return proposalId;
+}
+
+export async function submitPay(
+ ws: InternalWalletState,
+ proposalId: string,
+): Promise<ConfirmPayResult> {
+ const purchase = await ws.db.get(Stores.purchases, proposalId);
+ if (!purchase) {
+ throw Error("Purchase not found: " + proposalId);
+ }
+ if (purchase.abortRequested) {
+ throw Error("not submitting payment for aborted purchase");
+ }
+ const sessionId = purchase.lastSessionId;
+
+ console.log("paying with session ID", sessionId);
+
+ const payUrl = new URL(
+ `orders/${purchase.contractData.orderId}/pay`,
+ purchase.contractData.merchantBaseUrl,
+ ).href;
+
+ const reqBody = {
+ coins: purchase.coinDepositPermissions,
+ session_id: purchase.lastSessionId,
+ };
+
+ logger.trace("making pay request", JSON.stringify(reqBody, undefined, 2));
+
+ const resp = await ws.http.postJson(payUrl, reqBody);
+
+ const merchantResp = await readSuccessResponseJsonOrThrow(
+ resp,
+ codecForMerchantPayResponse(),
+ );
+
+ logger.trace("got success from pay URL", merchantResp);
+
+ const now = getTimestampNow();
+
+ const merchantPub = purchase.contractData.merchantPub;
+ const valid: boolean = await ws.cryptoApi.isValidPaymentSignature(
+ merchantResp.sig,
+ purchase.contractData.contractTermsHash,
+ merchantPub,
+ );
+ if (!valid) {
+ console.error("merchant payment signature invalid");
+ // FIXME: properly display error
+ throw Error("merchant payment signature invalid");
+ }
+ const isFirst = purchase.timestampFirstSuccessfulPay === undefined;
+ purchase.timestampFirstSuccessfulPay = now;
+ purchase.paymentSubmitPending = false;
+ purchase.lastPayError = undefined;
+ purchase.payRetryInfo = initRetryInfo(false);
+ if (isFirst) {
+ const ar = purchase.contractData.autoRefund;
+ if (ar) {
+ console.log("auto_refund present");
+ purchase.refundStatusRequested = true;
+ purchase.refundStatusRetryInfo = initRetryInfo();
+ purchase.lastRefundStatusError = undefined;
+ purchase.autoRefundDeadline = timestampAddDuration(now, ar);
+ }
+ }
+
+ await ws.db.runWithWriteTransaction(
+ [Stores.purchases, Stores.payEvents],
+ async (tx) => {
+ await tx.put(Stores.purchases, purchase);
+ const payEvent: PayEventRecord = {
+ proposalId,
+ sessionId,
+ timestamp: now,
+ isReplay: !isFirst,
+ };
+ await tx.put(Stores.payEvents, payEvent);
+ },
+ );
+
+ const nextUrl = getNextUrl(purchase.contractData);
+ ws.cachedNextUrl[purchase.contractData.fulfillmentUrl] = {
+ nextUrl,
+ lastSessionId: sessionId,
+ };
+
+ return { nextUrl };
+}
+
+/**
+ * Check if a payment for the given taler://pay/ URI is possible.
+ *
+ * If the payment is possible, the signature are already generated but not
+ * yet send to the merchant.
+ */
+export async function preparePayForUri(
+ ws: InternalWalletState,
+ talerPayUri: string,
+): Promise<PreparePayResult> {
+ const uriResult = parsePayUri(talerPayUri);
+
+ if (!uriResult) {
+ throw OperationFailedError.fromCode(
+ TalerErrorCode.WALLET_INVALID_TALER_PAY_URI,
+ `invalid taler://pay URI (${talerPayUri})`,
+ {
+ talerPayUri,
+ },
+ );
+ }
+
+ let proposalId = await startDownloadProposal(
+ ws,
+ uriResult.merchantBaseUrl,
+ uriResult.orderId,
+ uriResult.sessionId,
+ uriResult.claimToken,
+ );
+
+ let proposal = await ws.db.get(Stores.proposals, proposalId);
+ if (!proposal) {
+ throw Error(`could not get proposal ${proposalId}`);
+ }
+ if (proposal.proposalStatus === ProposalStatus.REPURCHASE) {
+ const existingProposalId = proposal.repurchaseProposalId;
+ if (!existingProposalId) {
+ throw Error("invalid proposal state");
+ }
+ console.log("using existing purchase for same product");
+ proposal = await ws.db.get(Stores.proposals, existingProposalId);
+ if (!proposal) {
+ throw Error("existing proposal is in wrong state");
+ }
+ }
+ const d = proposal.download;
+ if (!d) {
+ console.error("bad proposal", proposal);
+ throw Error("proposal is in invalid state");
+ }
+ const contractData = d.contractData;
+ const merchantSig = d.contractData.merchantSig;
+ if (!merchantSig) {
+ throw Error("BUG: proposal is in invalid state");
+ }
+
+ proposalId = proposal.proposalId;
+
+ // First check if we already payed for it.
+ const purchase = await ws.db.get(Stores.purchases, proposalId);
+
+ if (!purchase) {
+ // If not already paid, check if we could pay for it.
+ const res = await getCoinsForPayment(ws, contractData);
+
+ if (!res) {
+ logger.info("not confirming payment, insufficient coins");
+ return {
+ status: PreparePayResultType.InsufficientBalance,
+ contractTerms: JSON.parse(d.contractTermsRaw),
+ proposalId: proposal.proposalId,
+ };
+ }
+
+ const costInfo = await getTotalPaymentCost(ws, res);
+ logger.trace("costInfo", costInfo);
+ logger.trace("coinsForPayment", res);
+
+ return {
+ status: PreparePayResultType.PaymentPossible,
+ contractTerms: JSON.parse(d.contractTermsRaw),
+ proposalId: proposal.proposalId,
+ amountEffective: Amounts.stringify(costInfo.totalCost),
+ amountRaw: Amounts.stringify(res.paymentAmount),
+ };
+ }
+
+ if (purchase.lastSessionId !== uriResult.sessionId) {
+ logger.trace(
+ "automatically re-submitting payment with different session ID",
+ );
+ await ws.db.runWithWriteTransaction([Stores.purchases], async (tx) => {
+ const p = await tx.get(Stores.purchases, proposalId);
+ if (!p) {
+ return;
+ }
+ p.lastSessionId = uriResult.sessionId;
+ await tx.put(Stores.purchases, p);
+ });
+ const r = await submitPay(ws, proposalId);
+ return {
+ status: PreparePayResultType.AlreadyConfirmed,
+ contractTerms: JSON.parse(purchase.contractTermsRaw),
+ paid: true,
+ nextUrl: r.nextUrl,
+ };
+ } else if (!purchase.timestampFirstSuccessfulPay) {
+ return {
+ status: PreparePayResultType.AlreadyConfirmed,
+ contractTerms: JSON.parse(purchase.contractTermsRaw),
+ paid: false,
+ };
+ } else if (purchase.paymentSubmitPending) {
+ return {
+ status: PreparePayResultType.AlreadyConfirmed,
+ contractTerms: JSON.parse(purchase.contractTermsRaw),
+ paid: false,
+ };
+ }
+ // FIXME: we don't handle aborted payments correctly here.
+ throw Error("BUG: invariant violation (purchase status)");
+}
+
+/**
+ * Add a contract to the wallet and sign coins, and send them.
+ */
+export async function confirmPay(
+ ws: InternalWalletState,
+ proposalId: string,
+ sessionIdOverride: string | undefined,
+): Promise<ConfirmPayResult> {
+ logger.trace(
+ `executing confirmPay with proposalId ${proposalId} and sessionIdOverride ${sessionIdOverride}`,
+ );
+ const proposal = await ws.db.get(Stores.proposals, proposalId);
+
+ if (!proposal) {
+ throw Error(`proposal with id ${proposalId} not found`);
+ }
+
+ const d = proposal.download;
+ if (!d) {
+ throw Error("proposal is in invalid state");
+ }
+
+ let purchase = await ws.db.get(
+ Stores.purchases,
+ d.contractData.contractTermsHash,
+ );
+
+ if (purchase) {
+ if (
+ sessionIdOverride !== undefined &&
+ sessionIdOverride != purchase.lastSessionId
+ ) {
+ logger.trace(`changing session ID to ${sessionIdOverride}`);
+ await ws.db.mutate(Stores.purchases, purchase.proposalId, (x) => {
+ x.lastSessionId = sessionIdOverride;
+ x.paymentSubmitPending = true;
+ return x;
+ });
+ }
+ logger.trace("confirmPay: submitting payment for existing purchase");
+ return submitPay(ws, proposalId);
+ }
+
+ logger.trace("confirmPay: purchase record does not exist yet");
+
+ const res = await getCoinsForPayment(ws, d.contractData);
+
+ logger.trace("coin selection result", res);
+
+ if (!res) {
+ // Should not happen, since checkPay should be called first
+ logger.warn("not confirming payment, insufficient coins");
+ throw Error("insufficient balance");
+ }
+
+ const depositPermissions: CoinDepositPermission[] = [];
+ for (let i = 0; i < res.coinPubs.length; i++) {
+ const coin = await ws.db.get(Stores.coins, res.coinPubs[i]);
+ if (!coin) {
+ throw Error("can't pay, allocated coin not found anymore");
+ }
+ const denom = await ws.db.get(Stores.denominations, [
+ coin.exchangeBaseUrl,
+ coin.denomPub,
+ ]);
+ if (!denom) {
+ throw Error(
+ "can't pay, denomination of allocated coin not found anymore",
+ );
+ }
+ const dp = await ws.cryptoApi.signDepositPermission({
+ coinPriv: coin.coinPriv,
+ coinPub: coin.coinPub,
+ contractTermsHash: d.contractData.contractTermsHash,
+ denomPubHash: coin.denomPubHash,
+ denomSig: coin.denomSig,
+ exchangeBaseUrl: coin.exchangeBaseUrl,
+ feeDeposit: denom.feeDeposit,
+ merchantPub: d.contractData.merchantPub,
+ refundDeadline: d.contractData.refundDeadline,
+ spendAmount: res.coinContributions[i],
+ timestamp: d.contractData.timestamp,
+ wireInfoHash: d.contractData.wireInfoHash,
+ });
+ depositPermissions.push(dp);
+ }
+ purchase = await recordConfirmPay(
+ ws,
+ proposal,
+ res,
+ depositPermissions,
+ sessionIdOverride,
+ );
+
+ return submitPay(ws, proposalId);
+}
+
+export async function processPurchasePay(
+ ws: InternalWalletState,
+ proposalId: string,
+ forceNow = false,
+): Promise<void> {
+ const onOpErr = (e: OperationErrorDetails): Promise<void> =>
+ incrementPurchasePayRetry(ws, proposalId, e);
+ await guardOperationException(
+ () => processPurchasePayImpl(ws, proposalId, forceNow),
+ onOpErr,
+ );
+}
+
+async function resetPurchasePayRetry(
+ ws: InternalWalletState,
+ proposalId: string,
+): Promise<void> {
+ await ws.db.mutate(Stores.purchases, proposalId, (x) => {
+ if (x.payRetryInfo.active) {
+ x.payRetryInfo = initRetryInfo();
+ }
+ return x;
+ });
+}
+
+async function processPurchasePayImpl(
+ ws: InternalWalletState,
+ proposalId: string,
+ forceNow: boolean,
+): Promise<void> {
+ if (forceNow) {
+ await resetPurchasePayRetry(ws, proposalId);
+ }
+ const purchase = await ws.db.get(Stores.purchases, proposalId);
+ if (!purchase) {
+ return;
+ }
+ if (!purchase.paymentSubmitPending) {
+ return;
+ }
+ logger.trace(`processing purchase pay ${proposalId}`);
+ await submitPay(ws, proposalId);
+}
+
+export async function refuseProposal(
+ ws: InternalWalletState,
+ proposalId: string,
+): Promise<void> {
+ const success = await ws.db.runWithWriteTransaction(
+ [Stores.proposals],
+ async (tx) => {
+ const proposal = await tx.get(Stores.proposals, proposalId);
+ if (!proposal) {
+ logger.trace(`proposal ${proposalId} not found, won't refuse proposal`);
+ return false;
+ }
+ if (proposal.proposalStatus !== ProposalStatus.PROPOSED) {
+ return false;
+ }
+ proposal.proposalStatus = ProposalStatus.REFUSED;
+ await tx.put(Stores.proposals, proposal);
+ return true;
+ },
+ );
+ if (success) {
+ ws.notify({
+ type: NotificationType.ProposalRefused,
+ });
+ }
+}
diff --git a/packages/taler-wallet-core/src/operations/pending.d.ts.map b/packages/taler-wallet-core/src/operations/pending.d.ts.map
new file mode 100644
index 000000000..08897f538
--- /dev/null
+++ b/packages/taler-wallet-core/src/operations/pending.d.ts.map
@@ -0,0 +1 @@
+{"version":3,"file":"pending.d.ts","sourceRoot":"","sources":["pending.ts"],"names":[],"mappings":"AAyBA,OAAO,EACL,yBAAyB,EAI1B,MAAM,kBAAkB,CAAC;AAS1B,OAAO,EAAE,mBAAmB,EAAE,MAAM,SAAS,CAAC;AA6X9C,wBAAsB,oBAAoB,CACxC,EAAE,EAAE,mBAAmB,EACvB,EAAE,OAAe,EAAE;;CAAK,GACvB,OAAO,CAAC,yBAAyB,CAAC,CAkCpC"} \ No newline at end of file
diff --git a/packages/taler-wallet-core/src/operations/pending.ts b/packages/taler-wallet-core/src/operations/pending.ts
new file mode 100644
index 000000000..acad5e634
--- /dev/null
+++ b/packages/taler-wallet-core/src/operations/pending.ts
@@ -0,0 +1,458 @@
+/*
+ This file is part of GNU Taler
+ (C) 2019 GNUnet e.V.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ * Imports.
+ */
+import {
+ ExchangeUpdateStatus,
+ ProposalStatus,
+ ReserveRecordStatus,
+ Stores,
+} from "../types/dbTypes";
+import {
+ PendingOperationsResponse,
+ PendingOperationType,
+ ExchangeUpdateOperationStage,
+ ReserveType,
+} from "../types/pending";
+import {
+ Duration,
+ getTimestampNow,
+ Timestamp,
+ getDurationRemaining,
+ durationMin,
+} from "../util/time";
+import { TransactionHandle } from "../util/query";
+import { InternalWalletState } from "./state";
+import { getBalancesInsideTransaction } from "./balance";
+
+function updateRetryDelay(
+ oldDelay: Duration,
+ now: Timestamp,
+ retryTimestamp: Timestamp,
+): Duration {
+ const remaining = getDurationRemaining(retryTimestamp, now);
+ const nextDelay = durationMin(oldDelay, remaining);
+ return nextDelay;
+}
+
+async function gatherExchangePending(
+ tx: TransactionHandle,
+ now: Timestamp,
+ resp: PendingOperationsResponse,
+ onlyDue = false,
+): Promise<void> {
+ if (onlyDue) {
+ // FIXME: exchanges should also be updated regularly
+ return;
+ }
+ await tx.iter(Stores.exchanges).forEach((e) => {
+ switch (e.updateStatus) {
+ case ExchangeUpdateStatus.Finished:
+ if (e.lastError) {
+ resp.pendingOperations.push({
+ type: PendingOperationType.Bug,
+ givesLifeness: false,
+ message:
+ "Exchange record is in FINISHED state but has lastError set",
+ details: {
+ exchangeBaseUrl: e.baseUrl,
+ },
+ });
+ }
+ if (!e.details) {
+ resp.pendingOperations.push({
+ type: PendingOperationType.Bug,
+ givesLifeness: false,
+ message:
+ "Exchange record does not have details, but no update in progress.",
+ details: {
+ exchangeBaseUrl: e.baseUrl,
+ },
+ });
+ }
+ if (!e.wireInfo) {
+ resp.pendingOperations.push({
+ type: PendingOperationType.Bug,
+ givesLifeness: false,
+ message:
+ "Exchange record does not have wire info, but no update in progress.",
+ details: {
+ exchangeBaseUrl: e.baseUrl,
+ },
+ });
+ }
+ break;
+ case ExchangeUpdateStatus.FetchKeys:
+ resp.pendingOperations.push({
+ type: PendingOperationType.ExchangeUpdate,
+ givesLifeness: false,
+ stage: ExchangeUpdateOperationStage.FetchKeys,
+ exchangeBaseUrl: e.baseUrl,
+ lastError: e.lastError,
+ reason: e.updateReason || "unknown",
+ });
+ break;
+ case ExchangeUpdateStatus.FetchWire:
+ resp.pendingOperations.push({
+ type: PendingOperationType.ExchangeUpdate,
+ givesLifeness: false,
+ stage: ExchangeUpdateOperationStage.FetchWire,
+ exchangeBaseUrl: e.baseUrl,
+ lastError: e.lastError,
+ reason: e.updateReason || "unknown",
+ });
+ break;
+ case ExchangeUpdateStatus.FinalizeUpdate:
+ resp.pendingOperations.push({
+ type: PendingOperationType.ExchangeUpdate,
+ givesLifeness: false,
+ stage: ExchangeUpdateOperationStage.FinalizeUpdate,
+ exchangeBaseUrl: e.baseUrl,
+ lastError: e.lastError,
+ reason: e.updateReason || "unknown",
+ });
+ break;
+ default:
+ resp.pendingOperations.push({
+ type: PendingOperationType.Bug,
+ givesLifeness: false,
+ message: "Unknown exchangeUpdateStatus",
+ details: {
+ exchangeBaseUrl: e.baseUrl,
+ exchangeUpdateStatus: e.updateStatus,
+ },
+ });
+ break;
+ }
+ });
+}
+
+async function gatherReservePending(
+ tx: TransactionHandle,
+ now: Timestamp,
+ resp: PendingOperationsResponse,
+ onlyDue = false,
+): Promise<void> {
+ // FIXME: this should be optimized by using an index for "onlyDue==true".
+ await tx.iter(Stores.reserves).forEach((reserve) => {
+ const reserveType = reserve.bankInfo
+ ? ReserveType.TalerBankWithdraw
+ : ReserveType.Manual;
+ if (!reserve.retryInfo.active) {
+ return;
+ }
+ switch (reserve.reserveStatus) {
+ case ReserveRecordStatus.DORMANT:
+ // nothing to report as pending
+ break;
+ case ReserveRecordStatus.WAIT_CONFIRM_BANK:
+ case ReserveRecordStatus.WITHDRAWING:
+ case ReserveRecordStatus.QUERYING_STATUS:
+ case ReserveRecordStatus.REGISTERING_BANK:
+ resp.nextRetryDelay = updateRetryDelay(
+ resp.nextRetryDelay,
+ now,
+ reserve.retryInfo.nextRetry,
+ );
+ if (onlyDue && reserve.retryInfo.nextRetry.t_ms > now.t_ms) {
+ return;
+ }
+ resp.pendingOperations.push({
+ type: PendingOperationType.Reserve,
+ givesLifeness: true,
+ stage: reserve.reserveStatus,
+ timestampCreated: reserve.timestampCreated,
+ reserveType,
+ reservePub: reserve.reservePub,
+ retryInfo: reserve.retryInfo,
+ });
+ break;
+ default:
+ resp.pendingOperations.push({
+ type: PendingOperationType.Bug,
+ givesLifeness: false,
+ message: "Unknown reserve record status",
+ details: {
+ reservePub: reserve.reservePub,
+ reserveStatus: reserve.reserveStatus,
+ },
+ });
+ break;
+ }
+ });
+}
+
+async function gatherRefreshPending(
+ tx: TransactionHandle,
+ now: Timestamp,
+ resp: PendingOperationsResponse,
+ onlyDue = false,
+): Promise<void> {
+ await tx.iter(Stores.refreshGroups).forEach((r) => {
+ if (r.timestampFinished) {
+ return;
+ }
+ resp.nextRetryDelay = updateRetryDelay(
+ resp.nextRetryDelay,
+ now,
+ r.retryInfo.nextRetry,
+ );
+ if (onlyDue && r.retryInfo.nextRetry.t_ms > now.t_ms) {
+ return;
+ }
+
+ resp.pendingOperations.push({
+ type: PendingOperationType.Refresh,
+ givesLifeness: true,
+ refreshGroupId: r.refreshGroupId,
+ finishedPerCoin: r.finishedPerCoin,
+ retryInfo: r.retryInfo,
+ });
+ });
+}
+
+async function gatherWithdrawalPending(
+ tx: TransactionHandle,
+ now: Timestamp,
+ resp: PendingOperationsResponse,
+ onlyDue = false,
+): Promise<void> {
+ await tx.iter(Stores.withdrawalGroups).forEachAsync(async (wsr) => {
+ if (wsr.timestampFinish) {
+ return;
+ }
+ resp.nextRetryDelay = updateRetryDelay(
+ resp.nextRetryDelay,
+ now,
+ wsr.retryInfo.nextRetry,
+ );
+ if (onlyDue && wsr.retryInfo.nextRetry.t_ms > now.t_ms) {
+ return;
+ }
+ let numCoinsWithdrawn = 0;
+ let numCoinsTotal = 0;
+ await tx
+ .iterIndexed(Stores.planchets.byGroup, wsr.withdrawalGroupId)
+ .forEach((x) => {
+ numCoinsTotal++;
+ if (x.withdrawalDone) {
+ numCoinsWithdrawn++;
+ }
+ });
+ resp.pendingOperations.push({
+ type: PendingOperationType.Withdraw,
+ givesLifeness: true,
+ numCoinsTotal,
+ numCoinsWithdrawn,
+ source: wsr.source,
+ withdrawalGroupId: wsr.withdrawalGroupId,
+ lastError: wsr.lastError,
+ });
+ });
+}
+
+async function gatherProposalPending(
+ tx: TransactionHandle,
+ now: Timestamp,
+ resp: PendingOperationsResponse,
+ onlyDue = false,
+): Promise<void> {
+ await tx.iter(Stores.proposals).forEach((proposal) => {
+ if (proposal.proposalStatus == ProposalStatus.PROPOSED) {
+ if (onlyDue) {
+ return;
+ }
+ const dl = proposal.download;
+ if (!dl) {
+ resp.pendingOperations.push({
+ type: PendingOperationType.Bug,
+ message: "proposal is in invalid state",
+ details: {},
+ givesLifeness: false,
+ });
+ } else {
+ resp.pendingOperations.push({
+ type: PendingOperationType.ProposalChoice,
+ givesLifeness: false,
+ merchantBaseUrl: dl.contractData.merchantBaseUrl,
+ proposalId: proposal.proposalId,
+ proposalTimestamp: proposal.timestamp,
+ });
+ }
+ } else if (proposal.proposalStatus == ProposalStatus.DOWNLOADING) {
+ resp.nextRetryDelay = updateRetryDelay(
+ resp.nextRetryDelay,
+ now,
+ proposal.retryInfo.nextRetry,
+ );
+ if (onlyDue && proposal.retryInfo.nextRetry.t_ms > now.t_ms) {
+ return;
+ }
+ resp.pendingOperations.push({
+ type: PendingOperationType.ProposalDownload,
+ givesLifeness: true,
+ merchantBaseUrl: proposal.merchantBaseUrl,
+ orderId: proposal.orderId,
+ proposalId: proposal.proposalId,
+ proposalTimestamp: proposal.timestamp,
+ lastError: proposal.lastError,
+ retryInfo: proposal.retryInfo,
+ });
+ }
+ });
+}
+
+async function gatherTipPending(
+ tx: TransactionHandle,
+ now: Timestamp,
+ resp: PendingOperationsResponse,
+ onlyDue = false,
+): Promise<void> {
+ await tx.iter(Stores.tips).forEach((tip) => {
+ if (tip.pickedUp) {
+ return;
+ }
+ resp.nextRetryDelay = updateRetryDelay(
+ resp.nextRetryDelay,
+ now,
+ tip.retryInfo.nextRetry,
+ );
+ if (onlyDue && tip.retryInfo.nextRetry.t_ms > now.t_ms) {
+ return;
+ }
+ if (tip.acceptedTimestamp) {
+ resp.pendingOperations.push({
+ type: PendingOperationType.TipPickup,
+ givesLifeness: true,
+ merchantBaseUrl: tip.merchantBaseUrl,
+ tipId: tip.tipId,
+ merchantTipId: tip.merchantTipId,
+ });
+ }
+ });
+}
+
+async function gatherPurchasePending(
+ tx: TransactionHandle,
+ now: Timestamp,
+ resp: PendingOperationsResponse,
+ onlyDue = false,
+): Promise<void> {
+ await tx.iter(Stores.purchases).forEach((pr) => {
+ if (pr.paymentSubmitPending) {
+ resp.nextRetryDelay = updateRetryDelay(
+ resp.nextRetryDelay,
+ now,
+ pr.payRetryInfo.nextRetry,
+ );
+ if (!onlyDue || pr.payRetryInfo.nextRetry.t_ms <= now.t_ms) {
+ resp.pendingOperations.push({
+ type: PendingOperationType.Pay,
+ givesLifeness: true,
+ isReplay: false,
+ proposalId: pr.proposalId,
+ retryInfo: pr.payRetryInfo,
+ lastError: pr.lastPayError,
+ });
+ }
+ }
+ if (pr.refundStatusRequested) {
+ resp.nextRetryDelay = updateRetryDelay(
+ resp.nextRetryDelay,
+ now,
+ pr.refundStatusRetryInfo.nextRetry,
+ );
+ if (!onlyDue || pr.refundStatusRetryInfo.nextRetry.t_ms <= now.t_ms) {
+ resp.pendingOperations.push({
+ type: PendingOperationType.RefundQuery,
+ givesLifeness: true,
+ proposalId: pr.proposalId,
+ retryInfo: pr.refundStatusRetryInfo,
+ lastError: pr.lastRefundStatusError,
+ });
+ }
+ }
+ });
+}
+
+async function gatherRecoupPending(
+ tx: TransactionHandle,
+ now: Timestamp,
+ resp: PendingOperationsResponse,
+ onlyDue = false,
+): Promise<void> {
+ await tx.iter(Stores.recoupGroups).forEach((rg) => {
+ if (rg.timestampFinished) {
+ return;
+ }
+ resp.nextRetryDelay = updateRetryDelay(
+ resp.nextRetryDelay,
+ now,
+ rg.retryInfo.nextRetry,
+ );
+ if (onlyDue && rg.retryInfo.nextRetry.t_ms > now.t_ms) {
+ return;
+ }
+ resp.pendingOperations.push({
+ type: PendingOperationType.Recoup,
+ givesLifeness: true,
+ recoupGroupId: rg.recoupGroupId,
+ retryInfo: rg.retryInfo,
+ lastError: rg.lastError,
+ });
+ });
+}
+
+export async function getPendingOperations(
+ ws: InternalWalletState,
+ { onlyDue = false } = {},
+): Promise<PendingOperationsResponse> {
+ const now = getTimestampNow();
+ return await ws.db.runWithReadTransaction(
+ [
+ Stores.exchanges,
+ Stores.reserves,
+ Stores.refreshGroups,
+ Stores.coins,
+ Stores.withdrawalGroups,
+ Stores.proposals,
+ Stores.tips,
+ Stores.purchases,
+ Stores.recoupGroups,
+ Stores.planchets,
+ ],
+ async (tx) => {
+ const walletBalance = await getBalancesInsideTransaction(ws, tx);
+ const resp: PendingOperationsResponse = {
+ nextRetryDelay: { d_ms: Number.MAX_SAFE_INTEGER },
+ onlyDue: onlyDue,
+ walletBalance,
+ pendingOperations: [],
+ };
+ await gatherExchangePending(tx, now, resp, onlyDue);
+ await gatherReservePending(tx, now, resp, onlyDue);
+ await gatherRefreshPending(tx, now, resp, onlyDue);
+ await gatherWithdrawalPending(tx, now, resp, onlyDue);
+ await gatherProposalPending(tx, now, resp, onlyDue);
+ await gatherTipPending(tx, now, resp, onlyDue);
+ await gatherPurchasePending(tx, now, resp, onlyDue);
+ await gatherRecoupPending(tx, now, resp, onlyDue);
+ return resp;
+ },
+ );
+}
diff --git a/packages/taler-wallet-core/src/operations/recoup.d.ts.map b/packages/taler-wallet-core/src/operations/recoup.d.ts.map
new file mode 100644
index 000000000..c5c9254d1
--- /dev/null
+++ b/packages/taler-wallet-core/src/operations/recoup.d.ts.map
@@ -0,0 +1 @@
+{"version":3,"file":"recoup.d.ts","sourceRoot":"","sources":["recoup.ts"],"names":[],"mappings":"AAgBA;;;;;GAKG;AAEH;;GAEG;AACH,OAAO,EAAE,mBAAmB,EAAE,MAAM,SAAS,CAAC;AAqB9C,OAAO,EAAE,iBAAiB,EAAE,MAAM,eAAe,CAAC;AAyPlD,wBAAsB,kBAAkB,CACtC,EAAE,EAAE,mBAAmB,EACvB,aAAa,EAAE,MAAM,EACrB,QAAQ,UAAQ,GACf,OAAO,CAAC,IAAI,CAAC,CASf;AA0BD,wBAAsB,iBAAiB,CACrC,EAAE,EAAE,mBAAmB,EACvB,EAAE,EAAE,iBAAiB,EACrB,QAAQ,EAAE,MAAM,EAAE,GACjB,OAAO,CAAC,MAAM,CAAC,CAmCjB"} \ No newline at end of file
diff --git a/packages/taler-wallet-core/src/operations/recoup.ts b/packages/taler-wallet-core/src/operations/recoup.ts
new file mode 100644
index 000000000..cc91ab0e9
--- /dev/null
+++ b/packages/taler-wallet-core/src/operations/recoup.ts
@@ -0,0 +1,412 @@
+/*
+ This file is part of GNU Taler
+ (C) 2019-2020 Taler Systems SA
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ * Implementation of the recoup operation, which allows to recover the
+ * value of coins held in a revoked denomination.
+ *
+ * @author Florian Dold <dold@taler.net>
+ */
+
+/**
+ * Imports.
+ */
+import { InternalWalletState } from "./state";
+import {
+ Stores,
+ CoinStatus,
+ CoinSourceType,
+ CoinRecord,
+ WithdrawCoinSource,
+ RefreshCoinSource,
+ ReserveRecordStatus,
+ RecoupGroupRecord,
+ initRetryInfo,
+ updateRetryInfoTimeout,
+} from "../types/dbTypes";
+
+import { codecForRecoupConfirmation } from "../types/talerTypes";
+import { NotificationType } from "../types/notifications";
+import { forceQueryReserve } from "./reserves";
+
+import { Amounts } from "../util/amounts";
+import { createRefreshGroup, processRefreshGroup } from "./refresh";
+import { RefreshReason, OperationErrorDetails } from "../types/walletTypes";
+import { TransactionHandle } from "../util/query";
+import { encodeCrock, getRandomBytes } from "../crypto/talerCrypto";
+import { getTimestampNow } from "../util/time";
+import { guardOperationException } from "./errors";
+import { readSuccessResponseJsonOrThrow } from "../util/http";
+import { URL } from "../util/url";
+
+async function incrementRecoupRetry(
+ ws: InternalWalletState,
+ recoupGroupId: string,
+ err: OperationErrorDetails | undefined,
+): Promise<void> {
+ await ws.db.runWithWriteTransaction([Stores.recoupGroups], async (tx) => {
+ const r = await tx.get(Stores.recoupGroups, recoupGroupId);
+ if (!r) {
+ return;
+ }
+ if (!r.retryInfo) {
+ return;
+ }
+ r.retryInfo.retryCounter++;
+ updateRetryInfoTimeout(r.retryInfo);
+ r.lastError = err;
+ await tx.put(Stores.recoupGroups, r);
+ });
+ if (err) {
+ ws.notify({ type: NotificationType.RecoupOperationError, error: err });
+ }
+}
+
+async function putGroupAsFinished(
+ ws: InternalWalletState,
+ tx: TransactionHandle,
+ recoupGroup: RecoupGroupRecord,
+ coinIdx: number,
+): Promise<void> {
+ if (recoupGroup.timestampFinished) {
+ return;
+ }
+ recoupGroup.recoupFinishedPerCoin[coinIdx] = true;
+ let allFinished = true;
+ for (const b of recoupGroup.recoupFinishedPerCoin) {
+ if (!b) {
+ allFinished = false;
+ }
+ }
+ if (allFinished) {
+ recoupGroup.timestampFinished = getTimestampNow();
+ recoupGroup.retryInfo = initRetryInfo(false);
+ recoupGroup.lastError = undefined;
+ if (recoupGroup.scheduleRefreshCoins.length > 0) {
+ const refreshGroupId = await createRefreshGroup(
+ ws,
+ tx,
+ recoupGroup.scheduleRefreshCoins.map((x) => ({ coinPub: x })),
+ RefreshReason.Recoup,
+ );
+ processRefreshGroup(ws, refreshGroupId.refreshGroupId).then((e) => {
+ console.error("error while refreshing after recoup", e);
+ });
+ }
+ }
+ await tx.put(Stores.recoupGroups, recoupGroup);
+}
+
+async function recoupTipCoin(
+ ws: InternalWalletState,
+ recoupGroupId: string,
+ coinIdx: number,
+ coin: CoinRecord,
+): Promise<void> {
+ // We can't really recoup a coin we got via tipping.
+ // Thus we just put the coin to sleep.
+ // FIXME: somehow report this to the user
+ await ws.db.runWithWriteTransaction([Stores.recoupGroups], async (tx) => {
+ const recoupGroup = await tx.get(Stores.recoupGroups, recoupGroupId);
+ if (!recoupGroup) {
+ return;
+ }
+ if (recoupGroup.recoupFinishedPerCoin[coinIdx]) {
+ return;
+ }
+ await putGroupAsFinished(ws, tx, recoupGroup, coinIdx);
+ });
+}
+
+async function recoupWithdrawCoin(
+ ws: InternalWalletState,
+ recoupGroupId: string,
+ coinIdx: number,
+ coin: CoinRecord,
+ cs: WithdrawCoinSource,
+): Promise<void> {
+ const reservePub = cs.reservePub;
+ const reserve = await ws.db.get(Stores.reserves, reservePub);
+ if (!reserve) {
+ // FIXME: We should at least emit some pending operation / warning for this?
+ return;
+ }
+
+ ws.notify({
+ type: NotificationType.RecoupStarted,
+ });
+
+ const recoupRequest = await ws.cryptoApi.createRecoupRequest(coin);
+ const reqUrl = new URL(`/coins/${coin.coinPub}/recoup`, coin.exchangeBaseUrl);
+ const resp = await ws.http.postJson(reqUrl.href, recoupRequest);
+ const recoupConfirmation = await readSuccessResponseJsonOrThrow(
+ resp,
+ codecForRecoupConfirmation(),
+ );
+
+ if (recoupConfirmation.reserve_pub !== reservePub) {
+ throw Error(`Coin's reserve doesn't match reserve on recoup`);
+ }
+
+ const exchange = await ws.db.get(Stores.exchanges, coin.exchangeBaseUrl);
+ if (!exchange) {
+ // FIXME: report inconsistency?
+ return;
+ }
+ const exchangeDetails = exchange.details;
+ if (!exchangeDetails) {
+ // FIXME: report inconsistency?
+ return;
+ }
+
+ // FIXME: verify that our expectations about the amount match
+
+ await ws.db.runWithWriteTransaction(
+ [Stores.coins, Stores.reserves, Stores.recoupGroups],
+ async (tx) => {
+ const recoupGroup = await tx.get(Stores.recoupGroups, recoupGroupId);
+ if (!recoupGroup) {
+ return;
+ }
+ if (recoupGroup.recoupFinishedPerCoin[coinIdx]) {
+ return;
+ }
+ const updatedCoin = await tx.get(Stores.coins, coin.coinPub);
+ if (!updatedCoin) {
+ return;
+ }
+ const updatedReserve = await tx.get(Stores.reserves, reserve.reservePub);
+ if (!updatedReserve) {
+ return;
+ }
+ updatedCoin.status = CoinStatus.Dormant;
+ const currency = updatedCoin.currentAmount.currency;
+ updatedCoin.currentAmount = Amounts.getZero(currency);
+ updatedReserve.reserveStatus = ReserveRecordStatus.QUERYING_STATUS;
+ await tx.put(Stores.coins, updatedCoin);
+ await tx.put(Stores.reserves, updatedReserve);
+ await putGroupAsFinished(ws, tx, recoupGroup, coinIdx);
+ },
+ );
+
+ ws.notify({
+ type: NotificationType.RecoupFinished,
+ });
+
+ forceQueryReserve(ws, reserve.reservePub).catch((e) => {
+ console.log("re-querying reserve after recoup failed:", e);
+ });
+}
+
+async function recoupRefreshCoin(
+ ws: InternalWalletState,
+ recoupGroupId: string,
+ coinIdx: number,
+ coin: CoinRecord,
+ cs: RefreshCoinSource,
+): Promise<void> {
+ ws.notify({
+ type: NotificationType.RecoupStarted,
+ });
+
+ const recoupRequest = await ws.cryptoApi.createRecoupRequest(coin);
+ const reqUrl = new URL(`/coins/${coin.coinPub}/recoup`, coin.exchangeBaseUrl);
+ console.log("making recoup request");
+
+ const resp = await ws.http.postJson(reqUrl.href, recoupRequest);
+ const recoupConfirmation = await readSuccessResponseJsonOrThrow(
+ resp,
+ codecForRecoupConfirmation(),
+ );
+
+ if (recoupConfirmation.old_coin_pub != cs.oldCoinPub) {
+ throw Error(`Coin's oldCoinPub doesn't match reserve on recoup`);
+ }
+
+ const exchange = await ws.db.get(Stores.exchanges, coin.exchangeBaseUrl);
+ if (!exchange) {
+ // FIXME: report inconsistency?
+ return;
+ }
+ const exchangeDetails = exchange.details;
+ if (!exchangeDetails) {
+ // FIXME: report inconsistency?
+ return;
+ }
+
+ await ws.db.runWithWriteTransaction(
+ [Stores.coins, Stores.reserves, Stores.recoupGroups, Stores.refreshGroups],
+ async (tx) => {
+ const recoupGroup = await tx.get(Stores.recoupGroups, recoupGroupId);
+ if (!recoupGroup) {
+ return;
+ }
+ if (recoupGroup.recoupFinishedPerCoin[coinIdx]) {
+ return;
+ }
+ const oldCoin = await tx.get(Stores.coins, cs.oldCoinPub);
+ const revokedCoin = await tx.get(Stores.coins, coin.coinPub);
+ if (!revokedCoin) {
+ return;
+ }
+ if (!oldCoin) {
+ return;
+ }
+ revokedCoin.status = CoinStatus.Dormant;
+ oldCoin.currentAmount = Amounts.add(
+ oldCoin.currentAmount,
+ recoupGroup.oldAmountPerCoin[coinIdx],
+ ).amount;
+ console.log(
+ "recoup: setting old coin amount to",
+ Amounts.stringify(oldCoin.currentAmount),
+ );
+ recoupGroup.scheduleRefreshCoins.push(oldCoin.coinPub);
+ await tx.put(Stores.coins, revokedCoin);
+ await tx.put(Stores.coins, oldCoin);
+ await putGroupAsFinished(ws, tx, recoupGroup, coinIdx);
+ },
+ );
+}
+
+async function resetRecoupGroupRetry(
+ ws: InternalWalletState,
+ recoupGroupId: string,
+): Promise<void> {
+ await ws.db.mutate(Stores.recoupGroups, recoupGroupId, (x) => {
+ if (x.retryInfo.active) {
+ x.retryInfo = initRetryInfo();
+ }
+ return x;
+ });
+}
+
+export async function processRecoupGroup(
+ ws: InternalWalletState,
+ recoupGroupId: string,
+ forceNow = false,
+): Promise<void> {
+ await ws.memoProcessRecoup.memo(recoupGroupId, async () => {
+ const onOpErr = (e: OperationErrorDetails): Promise<void> =>
+ incrementRecoupRetry(ws, recoupGroupId, e);
+ return await guardOperationException(
+ async () => await processRecoupGroupImpl(ws, recoupGroupId, forceNow),
+ onOpErr,
+ );
+ });
+}
+
+async function processRecoupGroupImpl(
+ ws: InternalWalletState,
+ recoupGroupId: string,
+ forceNow = false,
+): Promise<void> {
+ if (forceNow) {
+ await resetRecoupGroupRetry(ws, recoupGroupId);
+ }
+ console.log("in processRecoupGroupImpl");
+ const recoupGroup = await ws.db.get(Stores.recoupGroups, recoupGroupId);
+ if (!recoupGroup) {
+ return;
+ }
+ console.log(recoupGroup);
+ if (recoupGroup.timestampFinished) {
+ console.log("recoup group finished");
+ return;
+ }
+ const ps = recoupGroup.coinPubs.map((x, i) =>
+ processRecoup(ws, recoupGroupId, i),
+ );
+ await Promise.all(ps);
+}
+
+export async function createRecoupGroup(
+ ws: InternalWalletState,
+ tx: TransactionHandle,
+ coinPubs: string[],
+): Promise<string> {
+ const recoupGroupId = encodeCrock(getRandomBytes(32));
+
+ const recoupGroup: RecoupGroupRecord = {
+ recoupGroupId,
+ coinPubs: coinPubs,
+ lastError: undefined,
+ timestampFinished: undefined,
+ timestampStarted: getTimestampNow(),
+ retryInfo: initRetryInfo(),
+ recoupFinishedPerCoin: coinPubs.map(() => false),
+ // Will be populated later
+ oldAmountPerCoin: [],
+ scheduleRefreshCoins: [],
+ };
+
+ for (let coinIdx = 0; coinIdx < coinPubs.length; coinIdx++) {
+ const coinPub = coinPubs[coinIdx];
+ const coin = await tx.get(Stores.coins, coinPub);
+ if (!coin) {
+ await putGroupAsFinished(ws, tx, recoupGroup, coinIdx);
+ continue;
+ }
+ if (Amounts.isZero(coin.currentAmount)) {
+ await putGroupAsFinished(ws, tx, recoupGroup, coinIdx);
+ continue;
+ }
+ recoupGroup.oldAmountPerCoin[coinIdx] = coin.currentAmount;
+ coin.currentAmount = Amounts.getZero(coin.currentAmount.currency);
+ await tx.put(Stores.coins, coin);
+ }
+
+ await tx.put(Stores.recoupGroups, recoupGroup);
+
+ return recoupGroupId;
+}
+
+async function processRecoup(
+ ws: InternalWalletState,
+ recoupGroupId: string,
+ coinIdx: number,
+): Promise<void> {
+ const recoupGroup = await ws.db.get(Stores.recoupGroups, recoupGroupId);
+ if (!recoupGroup) {
+ return;
+ }
+ if (recoupGroup.timestampFinished) {
+ return;
+ }
+ if (recoupGroup.recoupFinishedPerCoin[coinIdx]) {
+ return;
+ }
+
+ const coinPub = recoupGroup.coinPubs[coinIdx];
+
+ const coin = await ws.db.get(Stores.coins, coinPub);
+ if (!coin) {
+ throw Error(`Coin ${coinPub} not found, can't request payback`);
+ }
+
+ const cs = coin.coinSource;
+
+ switch (cs.type) {
+ case CoinSourceType.Tip:
+ return recoupTipCoin(ws, recoupGroupId, coinIdx, coin);
+ case CoinSourceType.Refresh:
+ return recoupRefreshCoin(ws, recoupGroupId, coinIdx, coin, cs);
+ case CoinSourceType.Withdraw:
+ return recoupWithdrawCoin(ws, recoupGroupId, coinIdx, coin, cs);
+ default:
+ throw Error("unknown coin source type");
+ }
+}
diff --git a/packages/taler-wallet-core/src/operations/refresh.d.ts.map b/packages/taler-wallet-core/src/operations/refresh.d.ts.map
new file mode 100644
index 000000000..01cbe7458
--- /dev/null
+++ b/packages/taler-wallet-core/src/operations/refresh.d.ts.map
@@ -0,0 +1 @@
+{"version":3,"file":"refresh.d.ts","sourceRoot":"","sources":["refresh.ts"],"names":[],"mappings":"AAgBA,OAAO,EAAW,UAAU,EAAE,MAAM,iBAAiB,CAAC;AACtD,OAAO,EACL,kBAAkB,EAUnB,MAAM,kBAAkB,CAAC;AAE1B,OAAO,EAAE,iBAAiB,EAAE,MAAM,eAAe,CAAC;AAClD,OAAO,EAAE,mBAAmB,EAAE,MAAM,SAAS,CAAC;AAI9C,OAAO,EAEL,aAAa,EACb,aAAa,EACb,cAAc,EACf,MAAM,sBAAsB,CAAC;AAc9B;;;;;;;;;;GAUG;AACH,wBAAgB,mBAAmB,CACjC,MAAM,EAAE,kBAAkB,EAAE,EAC5B,cAAc,EAAE,kBAAkB,EAClC,UAAU,EAAE,UAAU,GACrB,UAAU,CAiBZ;AA0WD,wBAAsB,mBAAmB,CACvC,EAAE,EAAE,mBAAmB,EACvB,cAAc,EAAE,MAAM,EACtB,QAAQ,UAAQ,GACf,OAAO,CAAC,IAAI,CAAC,CASf;AAyED;;GAEG;AACH,wBAAsB,kBAAkB,CACtC,EAAE,EAAE,mBAAmB,EACvB,EAAE,EAAE,iBAAiB,EACrB,WAAW,EAAE,aAAa,EAAE,EAC5B,MAAM,EAAE,aAAa,GACpB,OAAO,CAAC,cAAc,CAAC,CA8BzB"} \ No newline at end of file
diff --git a/packages/taler-wallet-core/src/operations/refresh.ts b/packages/taler-wallet-core/src/operations/refresh.ts
new file mode 100644
index 000000000..646bc2edf
--- /dev/null
+++ b/packages/taler-wallet-core/src/operations/refresh.ts
@@ -0,0 +1,573 @@
+/*
+ This file is part of GNU Taler
+ (C) 2019 GNUnet e.V.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+import { Amounts, AmountJson } from "../util/amounts";
+import {
+ DenominationRecord,
+ Stores,
+ CoinStatus,
+ RefreshPlanchetRecord,
+ CoinRecord,
+ RefreshSessionRecord,
+ initRetryInfo,
+ updateRetryInfoTimeout,
+ RefreshGroupRecord,
+ CoinSourceType,
+} from "../types/dbTypes";
+import { amountToPretty } from "../util/helpers";
+import { TransactionHandle } from "../util/query";
+import { InternalWalletState } from "./state";
+import { Logger } from "../util/logging";
+import { getWithdrawDenomList } from "./withdraw";
+import { updateExchangeFromUrl } from "./exchanges";
+import {
+ OperationErrorDetails,
+ CoinPublicKey,
+ RefreshReason,
+ RefreshGroupId,
+} from "../types/walletTypes";
+import { guardOperationException } from "./errors";
+import { NotificationType } from "../types/notifications";
+import { getRandomBytes, encodeCrock } from "../crypto/talerCrypto";
+import { getTimestampNow } from "../util/time";
+import { readSuccessResponseJsonOrThrow } from "../util/http";
+import {
+ codecForExchangeMeltResponse,
+ codecForExchangeRevealResponse,
+} from "../types/talerTypes";
+import { URL } from "../util/url";
+
+const logger = new Logger("refresh.ts");
+
+/**
+ * Get the amount that we lose when refreshing a coin of the given denomination
+ * with a certain amount left.
+ *
+ * If the amount left is zero, then the refresh cost
+ * is also considered to be zero. If a refresh isn't possible (e.g. due to lack of
+ * the right denominations), then the cost is the full amount left.
+ *
+ * Considers refresh fees, withdrawal fees after refresh and amounts too small
+ * to refresh.
+ */
+export function getTotalRefreshCost(
+ denoms: DenominationRecord[],
+ refreshedDenom: DenominationRecord,
+ amountLeft: AmountJson,
+): AmountJson {
+ const withdrawAmount = Amounts.sub(amountLeft, refreshedDenom.feeRefresh)
+ .amount;
+ const withdrawDenoms = getWithdrawDenomList(withdrawAmount, denoms);
+ const resultingAmount = Amounts.add(
+ Amounts.getZero(withdrawAmount.currency),
+ ...withdrawDenoms.selectedDenoms.map(
+ (d) => Amounts.mult(d.denom.value, d.count).amount,
+ ),
+ ).amount;
+ const totalCost = Amounts.sub(amountLeft, resultingAmount).amount;
+ logger.trace(
+ `total refresh cost for ${amountToPretty(amountLeft)} is ${amountToPretty(
+ totalCost,
+ )}`,
+ );
+ return totalCost;
+}
+
+/**
+ * Create a refresh session inside a refresh group.
+ */
+async function refreshCreateSession(
+ ws: InternalWalletState,
+ refreshGroupId: string,
+ coinIndex: number,
+): Promise<void> {
+ logger.trace(
+ `creating refresh session for coin ${coinIndex} in refresh group ${refreshGroupId}`,
+ );
+ const refreshGroup = await ws.db.get(Stores.refreshGroups, refreshGroupId);
+ if (!refreshGroup) {
+ return;
+ }
+ if (refreshGroup.finishedPerCoin[coinIndex]) {
+ return;
+ }
+ const existingRefreshSession = refreshGroup.refreshSessionPerCoin[coinIndex];
+ if (existingRefreshSession) {
+ return;
+ }
+ const oldCoinPub = refreshGroup.oldCoinPubs[coinIndex];
+ const coin = await ws.db.get(Stores.coins, oldCoinPub);
+ if (!coin) {
+ throw Error("Can't refresh, coin not found");
+ }
+
+ const exchange = await updateExchangeFromUrl(ws, coin.exchangeBaseUrl);
+ if (!exchange) {
+ throw Error("db inconsistent: exchange of coin not found");
+ }
+
+ const oldDenom = await ws.db.get(Stores.denominations, [
+ exchange.baseUrl,
+ coin.denomPub,
+ ]);
+
+ if (!oldDenom) {
+ throw Error("db inconsistent: denomination for coin not found");
+ }
+
+ const availableDenoms: DenominationRecord[] = await ws.db
+ .iterIndex(Stores.denominations.exchangeBaseUrlIndex, exchange.baseUrl)
+ .toArray();
+
+ const availableAmount = Amounts.sub(coin.currentAmount, oldDenom.feeRefresh)
+ .amount;
+
+ const newCoinDenoms = getWithdrawDenomList(availableAmount, availableDenoms);
+
+ if (newCoinDenoms.selectedDenoms.length === 0) {
+ logger.trace(
+ `not refreshing, available amount ${amountToPretty(
+ availableAmount,
+ )} too small`,
+ );
+ await ws.db.runWithWriteTransaction(
+ [Stores.coins, Stores.refreshGroups],
+ async (tx) => {
+ const rg = await tx.get(Stores.refreshGroups, refreshGroupId);
+ if (!rg) {
+ return;
+ }
+ rg.finishedPerCoin[coinIndex] = true;
+ let allDone = true;
+ for (const f of rg.finishedPerCoin) {
+ if (!f) {
+ allDone = false;
+ break;
+ }
+ }
+ if (allDone) {
+ rg.timestampFinished = getTimestampNow();
+ rg.retryInfo = initRetryInfo(false);
+ }
+ await tx.put(Stores.refreshGroups, rg);
+ },
+ );
+ ws.notify({ type: NotificationType.RefreshUnwarranted });
+ return;
+ }
+
+ const refreshSession: RefreshSessionRecord = await ws.cryptoApi.createRefreshSession(
+ exchange.baseUrl,
+ 3,
+ coin,
+ newCoinDenoms,
+ oldDenom.feeRefresh,
+ );
+
+ // Store refresh session and subtract refreshed amount from
+ // coin in the same transaction.
+ await ws.db.runWithWriteTransaction(
+ [Stores.refreshGroups, Stores.coins],
+ async (tx) => {
+ const c = await tx.get(Stores.coins, coin.coinPub);
+ if (!c) {
+ throw Error("coin not found, but marked for refresh");
+ }
+ const r = Amounts.sub(c.currentAmount, refreshSession.amountRefreshInput);
+ if (r.saturated) {
+ console.log("can't refresh coin, no amount left");
+ return;
+ }
+ c.currentAmount = r.amount;
+ c.status = CoinStatus.Dormant;
+ const rg = await tx.get(Stores.refreshGroups, refreshGroupId);
+ if (!rg) {
+ return;
+ }
+ if (rg.refreshSessionPerCoin[coinIndex]) {
+ return;
+ }
+ rg.refreshSessionPerCoin[coinIndex] = refreshSession;
+ await tx.put(Stores.refreshGroups, rg);
+ await tx.put(Stores.coins, c);
+ },
+ );
+ logger.info(
+ `created refresh session for coin #${coinIndex} in ${refreshGroupId}`,
+ );
+ ws.notify({ type: NotificationType.RefreshStarted });
+}
+
+async function refreshMelt(
+ ws: InternalWalletState,
+ refreshGroupId: string,
+ coinIndex: number,
+): Promise<void> {
+ const refreshGroup = await ws.db.get(Stores.refreshGroups, refreshGroupId);
+ if (!refreshGroup) {
+ return;
+ }
+ const refreshSession = refreshGroup.refreshSessionPerCoin[coinIndex];
+ if (!refreshSession) {
+ return;
+ }
+ if (refreshSession.norevealIndex !== undefined) {
+ return;
+ }
+
+ const coin = await ws.db.get(Stores.coins, refreshSession.meltCoinPub);
+
+ if (!coin) {
+ console.error("can't melt coin, it does not exist");
+ return;
+ }
+
+ const reqUrl = new URL(
+ `coins/${coin.coinPub}/melt`,
+ refreshSession.exchangeBaseUrl,
+ );
+ const meltReq = {
+ coin_pub: coin.coinPub,
+ confirm_sig: refreshSession.confirmSig,
+ denom_pub_hash: coin.denomPubHash,
+ denom_sig: coin.denomSig,
+ rc: refreshSession.hash,
+ value_with_fee: Amounts.stringify(refreshSession.amountRefreshInput),
+ };
+ logger.trace(`melt request for coin:`, meltReq);
+ const resp = await ws.http.postJson(reqUrl.href, meltReq);
+ const meltResponse = await readSuccessResponseJsonOrThrow(
+ resp,
+ codecForExchangeMeltResponse(),
+ );
+
+ const norevealIndex = meltResponse.noreveal_index;
+
+ refreshSession.norevealIndex = norevealIndex;
+
+ await ws.db.mutate(Stores.refreshGroups, refreshGroupId, (rg) => {
+ const rs = rg.refreshSessionPerCoin[coinIndex];
+ if (!rs) {
+ return;
+ }
+ if (rs.norevealIndex !== undefined) {
+ return;
+ }
+ if (rs.finishedTimestamp) {
+ return;
+ }
+ rs.norevealIndex = norevealIndex;
+ return rg;
+ });
+
+ ws.notify({
+ type: NotificationType.RefreshMelted,
+ });
+}
+
+async function refreshReveal(
+ ws: InternalWalletState,
+ refreshGroupId: string,
+ coinIndex: number,
+): Promise<void> {
+ const refreshGroup = await ws.db.get(Stores.refreshGroups, refreshGroupId);
+ if (!refreshGroup) {
+ return;
+ }
+ const refreshSession = refreshGroup.refreshSessionPerCoin[coinIndex];
+ if (!refreshSession) {
+ return;
+ }
+ const norevealIndex = refreshSession.norevealIndex;
+ if (norevealIndex === undefined) {
+ throw Error("can't reveal without melting first");
+ }
+ const privs = Array.from(refreshSession.transferPrivs);
+ privs.splice(norevealIndex, 1);
+
+ const planchets = refreshSession.planchetsForGammas[norevealIndex];
+ if (!planchets) {
+ throw Error("refresh index error");
+ }
+
+ const meltCoinRecord = await ws.db.get(
+ Stores.coins,
+ refreshSession.meltCoinPub,
+ );
+ if (!meltCoinRecord) {
+ throw Error("inconsistent database");
+ }
+
+ const evs = planchets.map((x: RefreshPlanchetRecord) => x.coinEv);
+
+ const linkSigs: string[] = [];
+ for (let i = 0; i < refreshSession.newDenoms.length; i++) {
+ const linkSig = await ws.cryptoApi.signCoinLink(
+ meltCoinRecord.coinPriv,
+ refreshSession.newDenomHashes[i],
+ refreshSession.meltCoinPub,
+ refreshSession.transferPubs[norevealIndex],
+ planchets[i].coinEv,
+ );
+ linkSigs.push(linkSig);
+ }
+
+ const req = {
+ coin_evs: evs,
+ new_denoms_h: refreshSession.newDenomHashes,
+ rc: refreshSession.hash,
+ transfer_privs: privs,
+ transfer_pub: refreshSession.transferPubs[norevealIndex],
+ link_sigs: linkSigs,
+ };
+
+ const reqUrl = new URL(
+ `refreshes/${refreshSession.hash}/reveal`,
+ refreshSession.exchangeBaseUrl,
+ );
+
+ const resp = await ws.http.postJson(reqUrl.href, req);
+ const reveal = await readSuccessResponseJsonOrThrow(
+ resp,
+ codecForExchangeRevealResponse(),
+ );
+
+ const coins: CoinRecord[] = [];
+
+ for (let i = 0; i < reveal.ev_sigs.length; i++) {
+ const denom = await ws.db.get(Stores.denominations, [
+ refreshSession.exchangeBaseUrl,
+ refreshSession.newDenoms[i],
+ ]);
+ if (!denom) {
+ console.error("denom not found");
+ continue;
+ }
+ const pc = refreshSession.planchetsForGammas[norevealIndex][i];
+ const denomSig = await ws.cryptoApi.rsaUnblind(
+ reveal.ev_sigs[i].ev_sig,
+ pc.blindingKey,
+ denom.denomPub,
+ );
+ const coin: CoinRecord = {
+ blindingKey: pc.blindingKey,
+ coinPriv: pc.privateKey,
+ coinPub: pc.publicKey,
+ currentAmount: denom.value,
+ denomPub: denom.denomPub,
+ denomPubHash: denom.denomPubHash,
+ denomSig,
+ exchangeBaseUrl: refreshSession.exchangeBaseUrl,
+ status: CoinStatus.Fresh,
+ coinSource: {
+ type: CoinSourceType.Refresh,
+ oldCoinPub: refreshSession.meltCoinPub,
+ },
+ suspended: false,
+ };
+
+ coins.push(coin);
+ }
+
+ await ws.db.runWithWriteTransaction(
+ [Stores.coins, Stores.refreshGroups],
+ async (tx) => {
+ const rg = await tx.get(Stores.refreshGroups, refreshGroupId);
+ if (!rg) {
+ console.log("no refresh session found");
+ return;
+ }
+ const rs = rg.refreshSessionPerCoin[coinIndex];
+ if (!rs) {
+ return;
+ }
+ if (rs.finishedTimestamp) {
+ console.log("refresh session already finished");
+ return;
+ }
+ rs.finishedTimestamp = getTimestampNow();
+ rg.finishedPerCoin[coinIndex] = true;
+ let allDone = true;
+ for (const f of rg.finishedPerCoin) {
+ if (!f) {
+ allDone = false;
+ break;
+ }
+ }
+ if (allDone) {
+ rg.timestampFinished = getTimestampNow();
+ rg.retryInfo = initRetryInfo(false);
+ }
+ for (const coin of coins) {
+ await tx.put(Stores.coins, coin);
+ }
+ await tx.put(Stores.refreshGroups, rg);
+ },
+ );
+ console.log("refresh finished (end of reveal)");
+ ws.notify({
+ type: NotificationType.RefreshRevealed,
+ });
+}
+
+async function incrementRefreshRetry(
+ ws: InternalWalletState,
+ refreshGroupId: string,
+ err: OperationErrorDetails | undefined,
+): Promise<void> {
+ await ws.db.runWithWriteTransaction([Stores.refreshGroups], async (tx) => {
+ const r = await tx.get(Stores.refreshGroups, refreshGroupId);
+ if (!r) {
+ return;
+ }
+ if (!r.retryInfo) {
+ return;
+ }
+ r.retryInfo.retryCounter++;
+ updateRetryInfoTimeout(r.retryInfo);
+ r.lastError = err;
+ await tx.put(Stores.refreshGroups, r);
+ });
+ if (err) {
+ ws.notify({ type: NotificationType.RefreshOperationError, error: err });
+ }
+}
+
+export async function processRefreshGroup(
+ ws: InternalWalletState,
+ refreshGroupId: string,
+ forceNow = false,
+): Promise<void> {
+ await ws.memoProcessRefresh.memo(refreshGroupId, async () => {
+ const onOpErr = (e: OperationErrorDetails): Promise<void> =>
+ incrementRefreshRetry(ws, refreshGroupId, e);
+ return await guardOperationException(
+ async () => await processRefreshGroupImpl(ws, refreshGroupId, forceNow),
+ onOpErr,
+ );
+ });
+}
+
+async function resetRefreshGroupRetry(
+ ws: InternalWalletState,
+ refreshSessionId: string,
+): Promise<void> {
+ await ws.db.mutate(Stores.refreshGroups, refreshSessionId, (x) => {
+ if (x.retryInfo.active) {
+ x.retryInfo = initRetryInfo();
+ }
+ return x;
+ });
+}
+
+async function processRefreshGroupImpl(
+ ws: InternalWalletState,
+ refreshGroupId: string,
+ forceNow: boolean,
+): Promise<void> {
+ if (forceNow) {
+ await resetRefreshGroupRetry(ws, refreshGroupId);
+ }
+ const refreshGroup = await ws.db.get(Stores.refreshGroups, refreshGroupId);
+ if (!refreshGroup) {
+ return;
+ }
+ if (refreshGroup.timestampFinished) {
+ return;
+ }
+ const ps = refreshGroup.oldCoinPubs.map((x, i) =>
+ processRefreshSession(ws, refreshGroupId, i),
+ );
+ await Promise.all(ps);
+ logger.trace("refresh finished");
+}
+
+async function processRefreshSession(
+ ws: InternalWalletState,
+ refreshGroupId: string,
+ coinIndex: number,
+): Promise<void> {
+ logger.trace(
+ `processing refresh session for coin ${coinIndex} of group ${refreshGroupId}`,
+ );
+ let refreshGroup = await ws.db.get(Stores.refreshGroups, refreshGroupId);
+ if (!refreshGroup) {
+ return;
+ }
+ if (refreshGroup.finishedPerCoin[coinIndex]) {
+ return;
+ }
+ if (!refreshGroup.refreshSessionPerCoin[coinIndex]) {
+ await refreshCreateSession(ws, refreshGroupId, coinIndex);
+ refreshGroup = await ws.db.get(Stores.refreshGroups, refreshGroupId);
+ if (!refreshGroup) {
+ return;
+ }
+ }
+ const refreshSession = refreshGroup.refreshSessionPerCoin[coinIndex];
+ if (!refreshSession) {
+ if (!refreshGroup.finishedPerCoin[coinIndex]) {
+ throw Error(
+ "BUG: refresh session was not created and coin not marked as finished",
+ );
+ }
+ return;
+ }
+ if (refreshSession.norevealIndex === undefined) {
+ await refreshMelt(ws, refreshGroupId, coinIndex);
+ }
+ await refreshReveal(ws, refreshGroupId, coinIndex);
+}
+
+/**
+ * Create a refresh group for a list of coins.
+ */
+export async function createRefreshGroup(
+ ws: InternalWalletState,
+ tx: TransactionHandle,
+ oldCoinPubs: CoinPublicKey[],
+ reason: RefreshReason,
+): Promise<RefreshGroupId> {
+ const refreshGroupId = encodeCrock(getRandomBytes(32));
+
+ const refreshGroup: RefreshGroupRecord = {
+ timestampFinished: undefined,
+ finishedPerCoin: oldCoinPubs.map((x) => false),
+ lastError: undefined,
+ lastErrorPerCoin: {},
+ oldCoinPubs: oldCoinPubs.map((x) => x.coinPub),
+ reason,
+ refreshGroupId,
+ refreshSessionPerCoin: oldCoinPubs.map((x) => undefined),
+ retryInfo: initRetryInfo(),
+ };
+
+ await tx.put(Stores.refreshGroups, refreshGroup);
+
+ const processAsync = async (): Promise<void> => {
+ try {
+ await processRefreshGroup(ws, refreshGroupId);
+ } catch (e) {
+ logger.trace(`Error during refresh: ${e}`);
+ }
+ };
+
+ processAsync();
+
+ return {
+ refreshGroupId,
+ };
+}
diff --git a/packages/taler-wallet-core/src/operations/refund.d.ts.map b/packages/taler-wallet-core/src/operations/refund.d.ts.map
new file mode 100644
index 000000000..77efa7cae
--- /dev/null
+++ b/packages/taler-wallet-core/src/operations/refund.d.ts.map
@@ -0,0 +1 @@
+{"version":3,"file":"refund.d.ts","sourceRoot":"","sources":["refund.ts"],"names":[],"mappings":"AAgBA;;;;GAIG;AAEH;;GAEG;AACH,OAAO,EAAE,mBAAmB,EAAE,MAAM,SAAS,CAAC;AA0S9C;;;GAGG;AACH,wBAAsB,WAAW,CAC/B,EAAE,EAAE,mBAAmB,EACvB,cAAc,EAAE,MAAM,GACrB,OAAO,CAAC;IAAE,iBAAiB,EAAE,MAAM,CAAC;IAAC,UAAU,EAAE,MAAM,CAAA;CAAE,CAAC,CA2B5D;AAED,wBAAsB,0BAA0B,CAC9C,EAAE,EAAE,mBAAmB,EACvB,UAAU,EAAE,MAAM,EAClB,QAAQ,UAAQ,GACf,OAAO,CAAC,IAAI,CAAC,CAOf"} \ No newline at end of file
diff --git a/packages/taler-wallet-core/src/operations/refund.ts b/packages/taler-wallet-core/src/operations/refund.ts
new file mode 100644
index 000000000..9792d2268
--- /dev/null
+++ b/packages/taler-wallet-core/src/operations/refund.ts
@@ -0,0 +1,438 @@
+/*
+ This file is part of GNU Taler
+ (C) 2019-2019 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ * Implementation of the refund operation.
+ *
+ * @author Florian Dold
+ */
+
+/**
+ * Imports.
+ */
+import { InternalWalletState } from "./state";
+import {
+ OperationErrorDetails,
+ RefreshReason,
+ CoinPublicKey,
+} from "../types/walletTypes";
+import {
+ Stores,
+ updateRetryInfoTimeout,
+ initRetryInfo,
+ CoinStatus,
+ RefundReason,
+ RefundState,
+ PurchaseRecord,
+} from "../types/dbTypes";
+import { NotificationType } from "../types/notifications";
+import { parseRefundUri } from "../util/taleruri";
+import { createRefreshGroup, getTotalRefreshCost } from "./refresh";
+import { Amounts } from "../util/amounts";
+import {
+ MerchantCoinRefundStatus,
+ MerchantCoinRefundSuccessStatus,
+ MerchantCoinRefundFailureStatus,
+ codecForMerchantOrderStatusPaid,
+} from "../types/talerTypes";
+import { guardOperationException } from "./errors";
+import { getTimestampNow } from "../util/time";
+import { Logger } from "../util/logging";
+import { readSuccessResponseJsonOrThrow } from "../util/http";
+import { TransactionHandle } from "../util/query";
+import { URL } from "../util/url";
+
+const logger = new Logger("refund.ts");
+
+/**
+ * Retry querying and applying refunds for an order later.
+ */
+async function incrementPurchaseQueryRefundRetry(
+ ws: InternalWalletState,
+ proposalId: string,
+ err: OperationErrorDetails | undefined,
+): Promise<void> {
+ await ws.db.runWithWriteTransaction([Stores.purchases], async (tx) => {
+ const pr = await tx.get(Stores.purchases, proposalId);
+ if (!pr) {
+ return;
+ }
+ if (!pr.refundStatusRetryInfo) {
+ return;
+ }
+ pr.refundStatusRetryInfo.retryCounter++;
+ updateRetryInfoTimeout(pr.refundStatusRetryInfo);
+ pr.lastRefundStatusError = err;
+ await tx.put(Stores.purchases, pr);
+ });
+ if (err) {
+ ws.notify({
+ type: NotificationType.RefundStatusOperationError,
+ error: err,
+ });
+ }
+}
+
+function getRefundKey(d: MerchantCoinRefundStatus): string {
+ return `${d.coin_pub}-${d.rtransaction_id}`;
+}
+
+async function applySuccessfulRefund(
+ tx: TransactionHandle,
+ p: PurchaseRecord,
+ refreshCoinsMap: Record<string, { coinPub: string }>,
+ r: MerchantCoinRefundSuccessStatus,
+): Promise<void> {
+ // FIXME: check signature before storing it as valid!
+
+ const refundKey = getRefundKey(r);
+ const coin = await tx.get(Stores.coins, r.coin_pub);
+ if (!coin) {
+ console.warn("coin not found, can't apply refund");
+ return;
+ }
+ const denom = await tx.getIndexed(
+ Stores.denominations.denomPubHashIndex,
+ coin.denomPubHash,
+ );
+ if (!denom) {
+ throw Error("inconsistent database");
+ }
+ refreshCoinsMap[coin.coinPub] = { coinPub: coin.coinPub };
+ const refundAmount = Amounts.parseOrThrow(r.refund_amount);
+ const refundFee = denom.feeRefund;
+ coin.status = CoinStatus.Dormant;
+ coin.currentAmount = Amounts.add(coin.currentAmount, refundAmount).amount;
+ coin.currentAmount = Amounts.sub(coin.currentAmount, refundFee).amount;
+ logger.trace(`coin amount after is ${Amounts.stringify(coin.currentAmount)}`);
+ await tx.put(Stores.coins, coin);
+
+ const allDenoms = await tx
+ .iterIndexed(
+ Stores.denominations.exchangeBaseUrlIndex,
+ coin.exchangeBaseUrl,
+ )
+ .toArray();
+
+ const amountLeft = Amounts.sub(
+ Amounts.add(coin.currentAmount, Amounts.parseOrThrow(r.refund_amount))
+ .amount,
+ denom.feeRefund,
+ ).amount;
+
+ const totalRefreshCostBound = getTotalRefreshCost(
+ allDenoms,
+ denom,
+ amountLeft,
+ );
+
+ p.refunds[refundKey] = {
+ type: RefundState.Applied,
+ executionTime: r.execution_time,
+ refundAmount: Amounts.parseOrThrow(r.refund_amount),
+ refundFee: denom.feeRefund,
+ totalRefreshCostBound,
+ };
+}
+
+async function storePendingRefund(
+ tx: TransactionHandle,
+ p: PurchaseRecord,
+ r: MerchantCoinRefundFailureStatus,
+): Promise<void> {
+ const refundKey = getRefundKey(r);
+
+ const coin = await tx.get(Stores.coins, r.coin_pub);
+ if (!coin) {
+ console.warn("coin not found, can't apply refund");
+ return;
+ }
+ const denom = await tx.getIndexed(
+ Stores.denominations.denomPubHashIndex,
+ coin.denomPubHash,
+ );
+
+ if (!denom) {
+ throw Error("inconsistent database");
+ }
+
+ const allDenoms = await tx
+ .iterIndexed(
+ Stores.denominations.exchangeBaseUrlIndex,
+ coin.exchangeBaseUrl,
+ )
+ .toArray();
+
+ const amountLeft = Amounts.sub(
+ Amounts.add(coin.currentAmount, Amounts.parseOrThrow(r.refund_amount))
+ .amount,
+ denom.feeRefund,
+ ).amount;
+
+ const totalRefreshCostBound = getTotalRefreshCost(
+ allDenoms,
+ denom,
+ amountLeft,
+ );
+
+ p.refunds[refundKey] = {
+ type: RefundState.Pending,
+ executionTime: r.execution_time,
+ refundAmount: Amounts.parseOrThrow(r.refund_amount),
+ refundFee: denom.feeRefund,
+ totalRefreshCostBound,
+ };
+}
+
+async function acceptRefunds(
+ ws: InternalWalletState,
+ proposalId: string,
+ refunds: MerchantCoinRefundStatus[],
+ reason: RefundReason,
+): Promise<void> {
+ console.log("handling refunds", refunds);
+ const now = getTimestampNow();
+
+ await ws.db.runWithWriteTransaction(
+ [
+ Stores.purchases,
+ Stores.coins,
+ Stores.denominations,
+ Stores.refreshGroups,
+ Stores.refundEvents,
+ ],
+ async (tx) => {
+ const p = await tx.get(Stores.purchases, proposalId);
+ if (!p) {
+ console.error("purchase not found, not adding refunds");
+ return;
+ }
+
+ const refreshCoinsMap: Record<string, CoinPublicKey> = {};
+
+ for (const refundStatus of refunds) {
+ const refundKey = getRefundKey(refundStatus);
+ const existingRefundInfo = p.refunds[refundKey];
+
+ // Already failed.
+ if (existingRefundInfo?.type === RefundState.Failed) {
+ continue;
+ }
+
+ // Already applied.
+ if (existingRefundInfo?.type === RefundState.Applied) {
+ continue;
+ }
+
+ // Still pending.
+ if (
+ refundStatus.type === "failure" &&
+ existingRefundInfo?.type === RefundState.Pending
+ ) {
+ continue;
+ }
+
+ // Invariant: (!existingRefundInfo) || (existingRefundInfo === Pending)
+
+ if (refundStatus.type === "success") {
+ await applySuccessfulRefund(tx, p, refreshCoinsMap, refundStatus);
+ } else {
+ await storePendingRefund(tx, p, refundStatus);
+ }
+ }
+
+ const refreshCoinsPubs = Object.values(refreshCoinsMap);
+ await createRefreshGroup(ws, tx, refreshCoinsPubs, RefreshReason.Refund);
+
+ // Are we done with querying yet, or do we need to do another round
+ // after a retry delay?
+ let queryDone = true;
+
+ if (p.autoRefundDeadline && p.autoRefundDeadline.t_ms > now.t_ms) {
+ queryDone = false;
+ }
+
+ let numPendingRefunds = 0;
+ for (const ri of Object.values(p.refunds)) {
+ switch (ri.type) {
+ case RefundState.Pending:
+ numPendingRefunds++;
+ break;
+ }
+ }
+
+ if (numPendingRefunds > 0) {
+ queryDone = false;
+ }
+
+ if (queryDone) {
+ p.timestampLastRefundStatus = now;
+ p.lastRefundStatusError = undefined;
+ p.refundStatusRetryInfo = initRetryInfo(false);
+ p.refundStatusRequested = false;
+ logger.trace("refund query done");
+ } else {
+ // No error, but we need to try again!
+ p.timestampLastRefundStatus = now;
+ p.refundStatusRetryInfo.retryCounter++;
+ updateRetryInfoTimeout(p.refundStatusRetryInfo);
+ p.lastRefundStatusError = undefined;
+ logger.trace("refund query not done");
+ }
+
+ await tx.put(Stores.purchases, p);
+ },
+ );
+
+ ws.notify({
+ type: NotificationType.RefundQueried,
+ });
+}
+
+async function startRefundQuery(
+ ws: InternalWalletState,
+ proposalId: string,
+): Promise<void> {
+ const success = await ws.db.runWithWriteTransaction(
+ [Stores.purchases],
+ async (tx) => {
+ const p = await tx.get(Stores.purchases, proposalId);
+ if (!p) {
+ logger.error("no purchase found for refund URL");
+ return false;
+ }
+ p.refundStatusRequested = true;
+ p.lastRefundStatusError = undefined;
+ p.refundStatusRetryInfo = initRetryInfo();
+ await tx.put(Stores.purchases, p);
+ return true;
+ },
+ );
+
+ if (!success) {
+ return;
+ }
+
+ ws.notify({
+ type: NotificationType.RefundStarted,
+ });
+
+ await processPurchaseQueryRefund(ws, proposalId);
+}
+
+/**
+ * Accept a refund, return the contract hash for the contract
+ * that was involved in the refund.
+ */
+export async function applyRefund(
+ ws: InternalWalletState,
+ talerRefundUri: string,
+): Promise<{ contractTermsHash: string; proposalId: string }> {
+ const parseResult = parseRefundUri(talerRefundUri);
+
+ logger.trace("applying refund", parseResult);
+
+ if (!parseResult) {
+ throw Error("invalid refund URI");
+ }
+
+ const purchase = await ws.db.getIndexed(Stores.purchases.orderIdIndex, [
+ parseResult.merchantBaseUrl,
+ parseResult.orderId,
+ ]);
+
+ if (!purchase) {
+ throw Error(
+ `no purchase for the taler://refund/ URI (${talerRefundUri}) was found`,
+ );
+ }
+
+ logger.info("processing purchase for refund");
+ await startRefundQuery(ws, purchase.proposalId);
+
+ return {
+ contractTermsHash: purchase.contractData.contractTermsHash,
+ proposalId: purchase.proposalId,
+ };
+}
+
+export async function processPurchaseQueryRefund(
+ ws: InternalWalletState,
+ proposalId: string,
+ forceNow = false,
+): Promise<void> {
+ const onOpErr = (e: OperationErrorDetails): Promise<void> =>
+ incrementPurchaseQueryRefundRetry(ws, proposalId, e);
+ await guardOperationException(
+ () => processPurchaseQueryRefundImpl(ws, proposalId, forceNow),
+ onOpErr,
+ );
+}
+
+async function resetPurchaseQueryRefundRetry(
+ ws: InternalWalletState,
+ proposalId: string,
+): Promise<void> {
+ await ws.db.mutate(Stores.purchases, proposalId, (x) => {
+ if (x.refundStatusRetryInfo.active) {
+ x.refundStatusRetryInfo = initRetryInfo();
+ }
+ return x;
+ });
+}
+
+async function processPurchaseQueryRefundImpl(
+ ws: InternalWalletState,
+ proposalId: string,
+ forceNow: boolean,
+): Promise<void> {
+ if (forceNow) {
+ await resetPurchaseQueryRefundRetry(ws, proposalId);
+ }
+ const purchase = await ws.db.get(Stores.purchases, proposalId);
+ if (!purchase) {
+ return;
+ }
+
+ if (!purchase.refundStatusRequested) {
+ return;
+ }
+
+ const requestUrl = new URL(
+ `orders/${purchase.contractData.orderId}`,
+ purchase.contractData.merchantBaseUrl,
+ );
+ requestUrl.searchParams.set(
+ "h_contract",
+ purchase.contractData.contractTermsHash,
+ );
+
+ const request = await ws.http.get(requestUrl.href);
+
+ console.log("got json", JSON.stringify(await request.json(), undefined, 2));
+
+ const refundResponse = await readSuccessResponseJsonOrThrow(
+ request,
+ codecForMerchantOrderStatusPaid(),
+ );
+
+ await acceptRefunds(
+ ws,
+ proposalId,
+ refundResponse.refunds,
+ RefundReason.NormalRefund,
+ );
+}
diff --git a/packages/taler-wallet-core/src/operations/reserves.d.ts.map b/packages/taler-wallet-core/src/operations/reserves.d.ts.map
new file mode 100644
index 000000000..33d646ba5
--- /dev/null
+++ b/packages/taler-wallet-core/src/operations/reserves.d.ts.map
@@ -0,0 +1 @@
+{"version":3,"file":"reserves.d.ts","sourceRoot":"","sources":["reserves.ts"],"names":[],"mappings":"AAgBA,OAAO,EACL,oBAAoB,EACpB,qBAAqB,EAErB,wBAAwB,EACzB,MAAM,sBAAsB,CAAC;AAE9B,OAAO,EAAE,mBAAmB,EAAE,MAAM,SAAS,CAAC;AA+C9C,OAAO,EAAE,iBAAiB,EAAE,MAAM,eAAe,CAAC;AAyBlD;;;;;GAKG;AACH,wBAAsB,aAAa,CACjC,EAAE,EAAE,mBAAmB,EACvB,GAAG,EAAE,oBAAoB,GACxB,OAAO,CAAC,qBAAqB,CAAC,CA+JhC;AAED;;GAEG;AACH,wBAAsB,iBAAiB,CACrC,EAAE,EAAE,mBAAmB,EACvB,UAAU,EAAE,MAAM,GACjB,OAAO,CAAC,IAAI,CAAC,CAoBf;AAED;;;;;;GAMG;AACH,wBAAsB,cAAc,CAClC,EAAE,EAAE,mBAAmB,EACvB,UAAU,EAAE,MAAM,EAClB,QAAQ,UAAQ,GACf,OAAO,CAAC,IAAI,CAAC,CASf;AA+CD,wBAAsB,wBAAwB,CAC5C,EAAE,EAAE,mBAAmB,EACvB,UAAU,EAAE,MAAM,GACjB,OAAO,CAAC,IAAI,CAAC,CAOf;AA8ZD,wBAAsB,0BAA0B,CAC9C,EAAE,EAAE,mBAAmB,EACvB,gBAAgB,EAAE,MAAM,EACxB,gBAAgB,EAAE,MAAM,GACvB,OAAO,CAAC,wBAAwB,CAAC,CAqBnC;AAED;;GAEG;AACH,wBAAsB,mBAAmB,CACvC,EAAE,EAAE,iBAAiB,EACrB,UAAU,EAAE,MAAM,GACjB,OAAO,CAAC,MAAM,EAAE,CAAC,CAuBnB"} \ No newline at end of file
diff --git a/packages/taler-wallet-core/src/operations/reserves.ts b/packages/taler-wallet-core/src/operations/reserves.ts
new file mode 100644
index 000000000..58095affd
--- /dev/null
+++ b/packages/taler-wallet-core/src/operations/reserves.ts
@@ -0,0 +1,841 @@
+/*
+ This file is part of GNU Taler
+ (C) 2019 GNUnet e.V.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+import {
+ CreateReserveRequest,
+ CreateReserveResponse,
+ OperationErrorDetails,
+ AcceptWithdrawalResponse,
+} from "../types/walletTypes";
+import { canonicalizeBaseUrl } from "../util/helpers";
+import { InternalWalletState } from "./state";
+import {
+ ReserveRecordStatus,
+ ReserveRecord,
+ CurrencyRecord,
+ Stores,
+ WithdrawalGroupRecord,
+ initRetryInfo,
+ updateRetryInfoTimeout,
+ ReserveUpdatedEventRecord,
+ WalletReserveHistoryItemType,
+ WithdrawalSourceType,
+ ReserveHistoryRecord,
+ ReserveBankInfo,
+} from "../types/dbTypes";
+import { Logger } from "../util/logging";
+import { Amounts } from "../util/amounts";
+import {
+ updateExchangeFromUrl,
+ getExchangeTrust,
+ getExchangePaytoUri,
+} from "./exchanges";
+import {
+ codecForWithdrawOperationStatusResponse,
+ codecForBankWithdrawalOperationPostResponse,
+} from "../types/talerTypes";
+import { assertUnreachable } from "../util/assertUnreachable";
+import { encodeCrock, getRandomBytes } from "../crypto/talerCrypto";
+import { randomBytes } from "../crypto/primitives/nacl-fast";
+import {
+ selectWithdrawalDenoms,
+ processWithdrawGroup,
+ getBankWithdrawalInfo,
+ denomSelectionInfoToState,
+} from "./withdraw";
+import {
+ guardOperationException,
+ OperationFailedAndReportedError,
+ makeErrorDetails,
+} from "./errors";
+import { NotificationType } from "../types/notifications";
+import { codecForReserveStatus } from "../types/ReserveStatus";
+import { getTimestampNow } from "../util/time";
+import {
+ reconcileReserveHistory,
+ summarizeReserveHistory,
+} from "../util/reserveHistoryUtil";
+import { TransactionHandle } from "../util/query";
+import { addPaytoQueryParams } from "../util/payto";
+import { TalerErrorCode } from "../TalerErrorCode";
+import {
+ readSuccessResponseJsonOrErrorCode,
+ throwUnexpectedRequestError,
+ readSuccessResponseJsonOrThrow,
+} from "../util/http";
+import { codecForAny } from "../util/codec";
+import { URL } from "../util/url";
+
+const logger = new Logger("reserves.ts");
+
+async function resetReserveRetry(
+ ws: InternalWalletState,
+ reservePub: string,
+): Promise<void> {
+ await ws.db.mutate(Stores.reserves, reservePub, (x) => {
+ if (x.retryInfo.active) {
+ x.retryInfo = initRetryInfo();
+ }
+ return x;
+ });
+}
+
+/**
+ * Create a reserve, but do not flag it as confirmed yet.
+ *
+ * Adds the corresponding exchange as a trusted exchange if it is neither
+ * audited nor trusted already.
+ */
+export async function createReserve(
+ ws: InternalWalletState,
+ req: CreateReserveRequest,
+): Promise<CreateReserveResponse> {
+ const keypair = await ws.cryptoApi.createEddsaKeypair();
+ const now = getTimestampNow();
+ const canonExchange = canonicalizeBaseUrl(req.exchange);
+
+ let reserveStatus;
+ if (req.bankWithdrawStatusUrl) {
+ reserveStatus = ReserveRecordStatus.REGISTERING_BANK;
+ } else {
+ reserveStatus = ReserveRecordStatus.QUERYING_STATUS;
+ }
+
+ let bankInfo: ReserveBankInfo | undefined;
+
+ if (req.bankWithdrawStatusUrl) {
+ if (!req.exchangePaytoUri) {
+ throw Error(
+ "Exchange payto URI must be specified for a bank-integrated withdrawal",
+ );
+ }
+ bankInfo = {
+ statusUrl: req.bankWithdrawStatusUrl,
+ exchangePaytoUri: req.exchangePaytoUri,
+ };
+ }
+
+ const initialWithdrawalGroupId = encodeCrock(getRandomBytes(32));
+
+ const denomSelInfo = await selectWithdrawalDenoms(
+ ws,
+ canonExchange,
+ req.amount,
+ );
+ const initialDenomSel = denomSelectionInfoToState(denomSelInfo);
+
+ const reserveRecord: ReserveRecord = {
+ instructedAmount: req.amount,
+ initialWithdrawalGroupId,
+ initialDenomSel,
+ initialWithdrawalStarted: false,
+ timestampCreated: now,
+ exchangeBaseUrl: canonExchange,
+ reservePriv: keypair.priv,
+ reservePub: keypair.pub,
+ senderWire: req.senderWire,
+ timestampBankConfirmed: undefined,
+ timestampReserveInfoPosted: undefined,
+ bankInfo,
+ reserveStatus,
+ lastSuccessfulStatusQuery: undefined,
+ retryInfo: initRetryInfo(),
+ lastError: undefined,
+ currency: req.amount.currency,
+ };
+
+ const reserveHistoryRecord: ReserveHistoryRecord = {
+ reservePub: keypair.pub,
+ reserveTransactions: [],
+ };
+
+ reserveHistoryRecord.reserveTransactions.push({
+ type: WalletReserveHistoryItemType.Credit,
+ expectedAmount: req.amount,
+ });
+
+ const senderWire = req.senderWire;
+ if (senderWire) {
+ const rec = {
+ paytoUri: senderWire,
+ };
+ await ws.db.put(Stores.senderWires, rec);
+ }
+
+ const exchangeInfo = await updateExchangeFromUrl(ws, req.exchange);
+ const exchangeDetails = exchangeInfo.details;
+ if (!exchangeDetails) {
+ console.log(exchangeDetails);
+ throw Error("exchange not updated");
+ }
+ const { isAudited, isTrusted } = await getExchangeTrust(ws, exchangeInfo);
+ let currencyRecord = await ws.db.get(
+ Stores.currencies,
+ exchangeDetails.currency,
+ );
+ if (!currencyRecord) {
+ currencyRecord = {
+ auditors: [],
+ exchanges: [],
+ fractionalDigits: 2,
+ name: exchangeDetails.currency,
+ };
+ }
+
+ if (!isAudited && !isTrusted) {
+ currencyRecord.exchanges.push({
+ baseUrl: req.exchange,
+ exchangePub: exchangeDetails.masterPublicKey,
+ });
+ }
+
+ const cr: CurrencyRecord = currencyRecord;
+
+ const resp = await ws.db.runWithWriteTransaction(
+ [
+ Stores.currencies,
+ Stores.reserves,
+ Stores.reserveHistory,
+ Stores.bankWithdrawUris,
+ ],
+ async (tx) => {
+ // Check if we have already created a reserve for that bankWithdrawStatusUrl
+ if (reserveRecord.bankInfo?.statusUrl) {
+ const bwi = await tx.get(
+ Stores.bankWithdrawUris,
+ reserveRecord.bankInfo.statusUrl,
+ );
+ if (bwi) {
+ const otherReserve = await tx.get(Stores.reserves, bwi.reservePub);
+ if (otherReserve) {
+ logger.trace(
+ "returning existing reserve for bankWithdrawStatusUri",
+ );
+ return {
+ exchange: otherReserve.exchangeBaseUrl,
+ reservePub: otherReserve.reservePub,
+ };
+ }
+ }
+ await tx.put(Stores.bankWithdrawUris, {
+ reservePub: reserveRecord.reservePub,
+ talerWithdrawUri: reserveRecord.bankInfo.statusUrl,
+ });
+ }
+ await tx.put(Stores.currencies, cr);
+ await tx.put(Stores.reserves, reserveRecord);
+ await tx.put(Stores.reserveHistory, reserveHistoryRecord);
+ const r: CreateReserveResponse = {
+ exchange: canonExchange,
+ reservePub: keypair.pub,
+ };
+ return r;
+ },
+ );
+
+ if (reserveRecord.reservePub === resp.reservePub) {
+ // Only emit notification when a new reserve was created.
+ ws.notify({
+ type: NotificationType.ReserveCreated,
+ reservePub: reserveRecord.reservePub,
+ });
+ }
+
+ // Asynchronously process the reserve, but return
+ // to the caller already.
+ processReserve(ws, resp.reservePub, true).catch((e) => {
+ logger.error("Processing reserve (after createReserve) failed:", e);
+ });
+
+ return resp;
+}
+
+/**
+ * Re-query the status of a reserve.
+ */
+export async function forceQueryReserve(
+ ws: InternalWalletState,
+ reservePub: string,
+): Promise<void> {
+ await ws.db.runWithWriteTransaction([Stores.reserves], async (tx) => {
+ const reserve = await tx.get(Stores.reserves, reservePub);
+ if (!reserve) {
+ return;
+ }
+ // Only force status query where it makes sense
+ switch (reserve.reserveStatus) {
+ case ReserveRecordStatus.DORMANT:
+ case ReserveRecordStatus.WITHDRAWING:
+ case ReserveRecordStatus.QUERYING_STATUS:
+ break;
+ default:
+ return;
+ }
+ reserve.reserveStatus = ReserveRecordStatus.QUERYING_STATUS;
+ reserve.retryInfo = initRetryInfo();
+ await tx.put(Stores.reserves, reserve);
+ });
+ await processReserve(ws, reservePub, true);
+}
+
+/**
+ * First fetch information requred to withdraw from the reserve,
+ * then deplete the reserve, withdrawing coins until it is empty.
+ *
+ * The returned promise resolves once the reserve is set to the
+ * state DORMANT.
+ */
+export async function processReserve(
+ ws: InternalWalletState,
+ reservePub: string,
+ forceNow = false,
+): Promise<void> {
+ return ws.memoProcessReserve.memo(reservePub, async () => {
+ const onOpError = (err: OperationErrorDetails): Promise<void> =>
+ incrementReserveRetry(ws, reservePub, err);
+ await guardOperationException(
+ () => processReserveImpl(ws, reservePub, forceNow),
+ onOpError,
+ );
+ });
+}
+
+async function registerReserveWithBank(
+ ws: InternalWalletState,
+ reservePub: string,
+): Promise<void> {
+ const reserve = await ws.db.get(Stores.reserves, reservePub);
+ switch (reserve?.reserveStatus) {
+ case ReserveRecordStatus.WAIT_CONFIRM_BANK:
+ case ReserveRecordStatus.REGISTERING_BANK:
+ break;
+ default:
+ return;
+ }
+ const bankInfo = reserve.bankInfo;
+ if (!bankInfo) {
+ return;
+ }
+ const bankStatusUrl = bankInfo.statusUrl;
+ const httpResp = await ws.http.postJson(bankStatusUrl, {
+ reserve_pub: reservePub,
+ selected_exchange: bankInfo.exchangePaytoUri,
+ });
+ await readSuccessResponseJsonOrThrow(
+ httpResp,
+ codecForBankWithdrawalOperationPostResponse(),
+ );
+ await ws.db.mutate(Stores.reserves, reservePub, (r) => {
+ switch (r.reserveStatus) {
+ case ReserveRecordStatus.REGISTERING_BANK:
+ case ReserveRecordStatus.WAIT_CONFIRM_BANK:
+ break;
+ default:
+ return;
+ }
+ r.timestampReserveInfoPosted = getTimestampNow();
+ r.reserveStatus = ReserveRecordStatus.WAIT_CONFIRM_BANK;
+ if (!r.bankInfo) {
+ throw Error("invariant failed");
+ }
+ r.retryInfo = initRetryInfo();
+ return r;
+ });
+ ws.notify({ type: NotificationType.ReserveRegisteredWithBank });
+ return processReserveBankStatus(ws, reservePub);
+}
+
+export async function processReserveBankStatus(
+ ws: InternalWalletState,
+ reservePub: string,
+): Promise<void> {
+ const onOpError = (err: OperationErrorDetails): Promise<void> =>
+ incrementReserveRetry(ws, reservePub, err);
+ await guardOperationException(
+ () => processReserveBankStatusImpl(ws, reservePub),
+ onOpError,
+ );
+}
+
+async function processReserveBankStatusImpl(
+ ws: InternalWalletState,
+ reservePub: string,
+): Promise<void> {
+ const reserve = await ws.db.get(Stores.reserves, reservePub);
+ switch (reserve?.reserveStatus) {
+ case ReserveRecordStatus.WAIT_CONFIRM_BANK:
+ case ReserveRecordStatus.REGISTERING_BANK:
+ break;
+ default:
+ return;
+ }
+ const bankStatusUrl = reserve.bankInfo?.statusUrl;
+ if (!bankStatusUrl) {
+ return;
+ }
+
+ const statusResp = await ws.http.get(bankStatusUrl);
+ const status = await readSuccessResponseJsonOrThrow(
+ statusResp,
+ codecForWithdrawOperationStatusResponse(),
+ );
+
+ if (status.selection_done) {
+ if (reserve.reserveStatus === ReserveRecordStatus.REGISTERING_BANK) {
+ await registerReserveWithBank(ws, reservePub);
+ return await processReserveBankStatus(ws, reservePub);
+ }
+ } else {
+ await registerReserveWithBank(ws, reservePub);
+ return await processReserveBankStatus(ws, reservePub);
+ }
+
+ if (status.transfer_done) {
+ await ws.db.mutate(Stores.reserves, reservePub, (r) => {
+ switch (r.reserveStatus) {
+ case ReserveRecordStatus.REGISTERING_BANK:
+ case ReserveRecordStatus.WAIT_CONFIRM_BANK:
+ break;
+ default:
+ return;
+ }
+ const now = getTimestampNow();
+ r.timestampBankConfirmed = now;
+ r.reserveStatus = ReserveRecordStatus.QUERYING_STATUS;
+ r.retryInfo = initRetryInfo();
+ return r;
+ });
+ await processReserveImpl(ws, reservePub, true);
+ } else {
+ await ws.db.mutate(Stores.reserves, reservePub, (r) => {
+ switch (r.reserveStatus) {
+ case ReserveRecordStatus.WAIT_CONFIRM_BANK:
+ break;
+ default:
+ return;
+ }
+ if (r.bankInfo) {
+ r.bankInfo.confirmUrl = status.confirm_transfer_url;
+ }
+ return r;
+ });
+ await incrementReserveRetry(ws, reservePub, undefined);
+ }
+}
+
+async function incrementReserveRetry(
+ ws: InternalWalletState,
+ reservePub: string,
+ err: OperationErrorDetails | undefined,
+): Promise<void> {
+ await ws.db.runWithWriteTransaction([Stores.reserves], async (tx) => {
+ const r = await tx.get(Stores.reserves, reservePub);
+ if (!r) {
+ return;
+ }
+ if (!r.retryInfo) {
+ return;
+ }
+ r.retryInfo.retryCounter++;
+ updateRetryInfoTimeout(r.retryInfo);
+ r.lastError = err;
+ await tx.put(Stores.reserves, r);
+ });
+ if (err) {
+ ws.notify({
+ type: NotificationType.ReserveOperationError,
+ error: err,
+ });
+ }
+}
+
+/**
+ * Update the information about a reserve that is stored in the wallet
+ * by quering the reserve's exchange.
+ */
+async function updateReserve(
+ ws: InternalWalletState,
+ reservePub: string,
+): Promise<{ ready: boolean }> {
+ const reserve = await ws.db.get(Stores.reserves, reservePub);
+ if (!reserve) {
+ throw Error("reserve not in db");
+ }
+
+ if (reserve.reserveStatus !== ReserveRecordStatus.QUERYING_STATUS) {
+ return { ready: true };
+ }
+
+ const resp = await ws.http.get(
+ new URL(`reserves/${reservePub}`, reserve.exchangeBaseUrl).href,
+ );
+
+ const result = await readSuccessResponseJsonOrErrorCode(
+ resp,
+ codecForReserveStatus(),
+ );
+ if (result.isError) {
+ if (
+ resp.status === 404 &&
+ result.talerErrorResponse.code === TalerErrorCode.RESERVE_STATUS_UNKNOWN
+ ) {
+ ws.notify({
+ type: NotificationType.ReserveNotYetFound,
+ reservePub,
+ });
+ await incrementReserveRetry(ws, reservePub, undefined);
+ return { ready: false };
+ } else {
+ throwUnexpectedRequestError(resp, result.talerErrorResponse);
+ }
+ }
+
+ const reserveInfo = result.response;
+
+ const balance = Amounts.parseOrThrow(reserveInfo.balance);
+ const currency = balance.currency;
+ await ws.db.runWithWriteTransaction(
+ [Stores.reserves, Stores.reserveUpdatedEvents, Stores.reserveHistory],
+ async (tx) => {
+ const r = await tx.get(Stores.reserves, reservePub);
+ if (!r) {
+ return;
+ }
+ if (r.reserveStatus !== ReserveRecordStatus.QUERYING_STATUS) {
+ return;
+ }
+
+ const hist = await tx.get(Stores.reserveHistory, reservePub);
+ if (!hist) {
+ throw Error("inconsistent database");
+ }
+
+ const newHistoryTransactions = reserveInfo.history.slice(
+ hist.reserveTransactions.length,
+ );
+
+ const reserveUpdateId = encodeCrock(getRandomBytes(32));
+
+ const reconciled = reconcileReserveHistory(
+ hist.reserveTransactions,
+ reserveInfo.history,
+ );
+
+ const summary = summarizeReserveHistory(
+ reconciled.updatedLocalHistory,
+ currency,
+ );
+
+ if (
+ reconciled.newAddedItems.length + reconciled.newMatchedItems.length !=
+ 0
+ ) {
+ const reserveUpdate: ReserveUpdatedEventRecord = {
+ reservePub: r.reservePub,
+ timestamp: getTimestampNow(),
+ amountReserveBalance: Amounts.stringify(balance),
+ amountExpected: Amounts.stringify(summary.awaitedReserveAmount),
+ newHistoryTransactions,
+ reserveUpdateId,
+ };
+ await tx.put(Stores.reserveUpdatedEvents, reserveUpdate);
+ r.reserveStatus = ReserveRecordStatus.WITHDRAWING;
+ r.retryInfo = initRetryInfo();
+ } else {
+ r.reserveStatus = ReserveRecordStatus.DORMANT;
+ r.retryInfo = initRetryInfo(false);
+ }
+ r.lastSuccessfulStatusQuery = getTimestampNow();
+ hist.reserveTransactions = reconciled.updatedLocalHistory;
+ r.lastError = undefined;
+ await tx.put(Stores.reserves, r);
+ await tx.put(Stores.reserveHistory, hist);
+ },
+ );
+ ws.notify({ type: NotificationType.ReserveUpdated });
+ return { ready: true };
+}
+
+async function processReserveImpl(
+ ws: InternalWalletState,
+ reservePub: string,
+ forceNow = false,
+): Promise<void> {
+ const reserve = await ws.db.get(Stores.reserves, reservePub);
+ if (!reserve) {
+ console.log("not processing reserve: reserve does not exist");
+ return;
+ }
+ if (!forceNow) {
+ const now = getTimestampNow();
+ if (reserve.retryInfo.nextRetry.t_ms > now.t_ms) {
+ logger.trace("processReserve retry not due yet");
+ return;
+ }
+ } else {
+ await resetReserveRetry(ws, reservePub);
+ }
+ logger.trace(
+ `Processing reserve ${reservePub} with status ${reserve.reserveStatus}`,
+ );
+ switch (reserve.reserveStatus) {
+ case ReserveRecordStatus.REGISTERING_BANK:
+ await processReserveBankStatus(ws, reservePub);
+ return await processReserveImpl(ws, reservePub, true);
+ case ReserveRecordStatus.QUERYING_STATUS: {
+ const res = await updateReserve(ws, reservePub);
+ if (res.ready) {
+ return await processReserveImpl(ws, reservePub, true);
+ } else {
+ break;
+ }
+ }
+ case ReserveRecordStatus.WITHDRAWING:
+ await depleteReserve(ws, reservePub);
+ break;
+ case ReserveRecordStatus.DORMANT:
+ // nothing to do
+ break;
+ case ReserveRecordStatus.WAIT_CONFIRM_BANK:
+ await processReserveBankStatus(ws, reservePub);
+ break;
+ default:
+ console.warn("unknown reserve record status:", reserve.reserveStatus);
+ assertUnreachable(reserve.reserveStatus);
+ break;
+ }
+}
+
+/**
+ * Withdraw coins from a reserve until it is empty.
+ *
+ * When finished, marks the reserve as depleted by setting
+ * the depleted timestamp.
+ */
+async function depleteReserve(
+ ws: InternalWalletState,
+ reservePub: string,
+): Promise<void> {
+ let reserve: ReserveRecord | undefined;
+ let hist: ReserveHistoryRecord | undefined;
+ await ws.db.runWithReadTransaction(
+ [Stores.reserves, Stores.reserveHistory],
+ async (tx) => {
+ reserve = await tx.get(Stores.reserves, reservePub);
+ hist = await tx.get(Stores.reserveHistory, reservePub);
+ },
+ );
+
+ if (!reserve) {
+ return;
+ }
+ if (!hist) {
+ throw Error("inconsistent database");
+ }
+ if (reserve.reserveStatus !== ReserveRecordStatus.WITHDRAWING) {
+ return;
+ }
+ logger.trace(`depleting reserve ${reservePub}`);
+
+ const summary = summarizeReserveHistory(
+ hist.reserveTransactions,
+ reserve.currency,
+ );
+
+ const withdrawAmount = summary.unclaimedReserveAmount;
+
+ const denomsForWithdraw = await selectWithdrawalDenoms(
+ ws,
+ reserve.exchangeBaseUrl,
+ withdrawAmount,
+ );
+ if (!denomsForWithdraw) {
+ // Only complain about inability to withdraw if we
+ // didn't withdraw before.
+ if (Amounts.isZero(summary.withdrawnAmount)) {
+ const opErr = makeErrorDetails(
+ TalerErrorCode.WALLET_EXCHANGE_DENOMINATIONS_INSUFFICIENT,
+ `Unable to withdraw from reserve, no denominations are available to withdraw.`,
+ {},
+ );
+ await incrementReserveRetry(ws, reserve.reservePub, opErr);
+ throw new OperationFailedAndReportedError(opErr);
+ }
+ return;
+ }
+
+ logger.trace(
+ `Selected coins total cost ${Amounts.stringify(
+ denomsForWithdraw.totalWithdrawCost,
+ )} for withdrawal of ${Amounts.stringify(withdrawAmount)}`,
+ );
+
+ logger.trace("selected denominations");
+
+ const newWithdrawalGroup = await ws.db.runWithWriteTransaction(
+ [
+ Stores.withdrawalGroups,
+ Stores.reserves,
+ Stores.reserveHistory,
+ Stores.planchets,
+ ],
+ async (tx) => {
+ const newReserve = await tx.get(Stores.reserves, reservePub);
+ if (!newReserve) {
+ return false;
+ }
+ if (newReserve.reserveStatus !== ReserveRecordStatus.WITHDRAWING) {
+ return false;
+ }
+ const newHist = await tx.get(Stores.reserveHistory, reservePub);
+ if (!newHist) {
+ throw Error("inconsistent database");
+ }
+ const newSummary = summarizeReserveHistory(
+ newHist.reserveTransactions,
+ newReserve.currency,
+ );
+ if (
+ Amounts.cmp(
+ newSummary.unclaimedReserveAmount,
+ denomsForWithdraw.totalWithdrawCost,
+ ) < 0
+ ) {
+ // Something must have happened concurrently!
+ logger.error(
+ "aborting withdrawal session, likely concurrent withdrawal happened",
+ );
+ logger.error(
+ `unclaimed reserve amount is ${newSummary.unclaimedReserveAmount}`,
+ );
+ logger.error(
+ `withdrawal cost is ${denomsForWithdraw.totalWithdrawCost}`,
+ );
+ return false;
+ }
+ for (let i = 0; i < denomsForWithdraw.selectedDenoms.length; i++) {
+ const sd = denomsForWithdraw.selectedDenoms[i];
+ for (let j = 0; j < sd.count; j++) {
+ const amt = Amounts.add(sd.denom.value, sd.denom.feeWithdraw).amount;
+ newHist.reserveTransactions.push({
+ type: WalletReserveHistoryItemType.Withdraw,
+ expectedAmount: amt,
+ });
+ }
+ }
+ newReserve.reserveStatus = ReserveRecordStatus.DORMANT;
+ newReserve.retryInfo = initRetryInfo(false);
+
+ let withdrawalGroupId: string;
+
+ if (!newReserve.initialWithdrawalStarted) {
+ withdrawalGroupId = newReserve.initialWithdrawalGroupId;
+ newReserve.initialWithdrawalStarted = true;
+ } else {
+ withdrawalGroupId = encodeCrock(randomBytes(32));
+ }
+
+ const withdrawalRecord: WithdrawalGroupRecord = {
+ withdrawalGroupId: withdrawalGroupId,
+ exchangeBaseUrl: newReserve.exchangeBaseUrl,
+ source: {
+ type: WithdrawalSourceType.Reserve,
+ reservePub: newReserve.reservePub,
+ },
+ rawWithdrawalAmount: withdrawAmount,
+ timestampStart: getTimestampNow(),
+ retryInfo: initRetryInfo(),
+ lastErrorPerCoin: {},
+ lastError: undefined,
+ denomsSel: denomSelectionInfoToState(denomsForWithdraw),
+ };
+
+ await tx.put(Stores.reserves, newReserve);
+ await tx.put(Stores.reserveHistory, newHist);
+ await tx.put(Stores.withdrawalGroups, withdrawalRecord);
+ return withdrawalRecord;
+ },
+ );
+
+ if (newWithdrawalGroup) {
+ logger.trace("processing new withdraw group");
+ ws.notify({
+ type: NotificationType.WithdrawGroupCreated,
+ withdrawalGroupId: newWithdrawalGroup.withdrawalGroupId,
+ });
+ await processWithdrawGroup(ws, newWithdrawalGroup.withdrawalGroupId);
+ } else {
+ console.trace("withdraw session already existed");
+ }
+}
+
+export async function createTalerWithdrawReserve(
+ ws: InternalWalletState,
+ talerWithdrawUri: string,
+ selectedExchange: string,
+): Promise<AcceptWithdrawalResponse> {
+ const withdrawInfo = await getBankWithdrawalInfo(ws, talerWithdrawUri);
+ const exchangeWire = await getExchangePaytoUri(
+ ws,
+ selectedExchange,
+ withdrawInfo.wireTypes,
+ );
+ const reserve = await createReserve(ws, {
+ amount: withdrawInfo.amount,
+ bankWithdrawStatusUrl: withdrawInfo.extractedStatusUrl,
+ exchange: selectedExchange,
+ senderWire: withdrawInfo.senderWire,
+ exchangePaytoUri: exchangeWire,
+ });
+ // We do this here, as the reserve should be registered before we return,
+ // so that we can redirect the user to the bank's status page.
+ await processReserveBankStatus(ws, reserve.reservePub);
+ return {
+ reservePub: reserve.reservePub,
+ confirmTransferUrl: withdrawInfo.confirmTransferUrl,
+ };
+}
+
+/**
+ * Get payto URIs needed to fund a reserve.
+ */
+export async function getFundingPaytoUris(
+ tx: TransactionHandle,
+ reservePub: string,
+): Promise<string[]> {
+ const r = await tx.get(Stores.reserves, reservePub);
+ if (!r) {
+ logger.error(`reserve ${reservePub} not found (DB corrupted?)`);
+ return [];
+ }
+ const exchange = await tx.get(Stores.exchanges, r.exchangeBaseUrl);
+ if (!exchange) {
+ logger.error(`exchange ${r.exchangeBaseUrl} not found (DB corrupted?)`);
+ return [];
+ }
+ const plainPaytoUris =
+ exchange.wireInfo?.accounts.map((x) => x.payto_uri) ?? [];
+ if (!plainPaytoUris) {
+ logger.error(`exchange ${r.exchangeBaseUrl} has no wire info`);
+ return [];
+ }
+ return plainPaytoUris.map((x) =>
+ addPaytoQueryParams(x, {
+ amount: Amounts.stringify(r.instructedAmount),
+ message: `Taler Withdrawal ${r.reservePub}`,
+ }),
+ );
+}
diff --git a/packages/taler-wallet-core/src/operations/state.d.ts.map b/packages/taler-wallet-core/src/operations/state.d.ts.map
new file mode 100644
index 000000000..275197839
--- /dev/null
+++ b/packages/taler-wallet-core/src/operations/state.d.ts.map
@@ -0,0 +1 @@
+{"version":3,"file":"state.d.ts","sourceRoot":"","sources":["state.ts"],"names":[],"mappings":"AAgBA,OAAO,EAAE,kBAAkB,EAAE,MAAM,cAAc,CAAC;AAClD,OAAO,EAAE,aAAa,EAAE,gBAAgB,EAAE,MAAM,sBAAsB,CAAC;AACvE,OAAO,EAAE,SAAS,EAAE,mBAAmB,EAAE,MAAM,6BAA6B,CAAC;AAC7E,OAAO,EAAE,cAAc,EAAE,iBAAiB,EAAE,MAAM,mBAAmB,CAAC;AAEtE,OAAO,EAAE,yBAAyB,EAAE,MAAM,kBAAkB,CAAC;AAC7D,OAAO,EAAE,kBAAkB,EAAE,MAAM,wBAAwB,CAAC;AAC5D,OAAO,EAAE,QAAQ,EAAE,MAAM,eAAe,CAAC;AAEzC,aAAK,oBAAoB,GAAG,CAAC,CAAC,EAAE,kBAAkB,KAAK,IAAI,CAAC;AAI5D,qBAAa,mBAAmB;IAerB,EAAE,EAAE,QAAQ;IACZ,IAAI,EAAE,kBAAkB;IAfjC,aAAa,EAAE;QAAE,CAAC,cAAc,EAAE,MAAM,GAAG,aAAa,CAAA;KAAE,CAAM;IAChE,kBAAkB,EAAE,cAAc,CAAC,IAAI,CAAC,CAAwB;IAChE,gBAAgB,EAAE,cAAc,CAAC,IAAI,CAAC,CAAwB;IAC9D,cAAc,EAAE,iBAAiB,CAC/B,yBAAyB,CAC1B,CAA2B;IAC5B,cAAc,EAAE,iBAAiB,CAAC,gBAAgB,CAAC,CAA2B;IAC9E,kBAAkB,EAAE,cAAc,CAAC,IAAI,CAAC,CAAwB;IAChE,iBAAiB,EAAE,cAAc,CAAC,IAAI,CAAC,CAAwB;IAC/D,SAAS,EAAE,SAAS,CAAC;IAErB,SAAS,EAAE,oBAAoB,EAAE,CAAM;gBAG9B,EAAE,EAAE,QAAQ,EACZ,IAAI,EAAE,kBAAkB,EAC/B,mBAAmB,EAAE,mBAAmB;IAKnC,MAAM,CAAC,CAAC,EAAE,kBAAkB,GAAG,IAAI;IAU1C,uBAAuB,CAAC,CAAC,EAAE,CAAC,CAAC,EAAE,kBAAkB,KAAK,IAAI,GAAG,IAAI;CAGlE"} \ No newline at end of file
diff --git a/packages/taler-wallet-core/src/operations/state.ts b/packages/taler-wallet-core/src/operations/state.ts
new file mode 100644
index 000000000..cfec85d0f
--- /dev/null
+++ b/packages/taler-wallet-core/src/operations/state.ts
@@ -0,0 +1,65 @@
+/*
+ This file is part of GNU Taler
+ (C) 2019 GNUnet e.V.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+import { HttpRequestLibrary } from "../util/http";
+import { NextUrlResult, BalancesResponse } from "../types/walletTypes";
+import { CryptoApi, CryptoWorkerFactory } from "../crypto/workers/cryptoApi";
+import { AsyncOpMemoMap, AsyncOpMemoSingle } from "../util/asyncMemo";
+import { Logger } from "../util/logging";
+import { PendingOperationsResponse } from "../types/pending";
+import { WalletNotification } from "../types/notifications";
+import { Database } from "../util/query";
+
+type NotificationListener = (n: WalletNotification) => void;
+
+const logger = new Logger("state.ts");
+
+export class InternalWalletState {
+ cachedNextUrl: { [fulfillmentUrl: string]: NextUrlResult } = {};
+ memoProcessReserve: AsyncOpMemoMap<void> = new AsyncOpMemoMap();
+ memoMakePlanchet: AsyncOpMemoMap<void> = new AsyncOpMemoMap();
+ memoGetPending: AsyncOpMemoSingle<
+ PendingOperationsResponse
+ > = new AsyncOpMemoSingle();
+ memoGetBalance: AsyncOpMemoSingle<BalancesResponse> = new AsyncOpMemoSingle();
+ memoProcessRefresh: AsyncOpMemoMap<void> = new AsyncOpMemoMap();
+ memoProcessRecoup: AsyncOpMemoMap<void> = new AsyncOpMemoMap();
+ cryptoApi: CryptoApi;
+
+ listeners: NotificationListener[] = [];
+
+ constructor(
+ public db: Database,
+ public http: HttpRequestLibrary,
+ cryptoWorkerFactory: CryptoWorkerFactory,
+ ) {
+ this.cryptoApi = new CryptoApi(cryptoWorkerFactory);
+ }
+
+ public notify(n: WalletNotification): void {
+ logger.trace("Notification", n);
+ for (const l of this.listeners) {
+ const nc = JSON.parse(JSON.stringify(n));
+ setTimeout(() => {
+ l(nc);
+ }, 0);
+ }
+ }
+
+ addNotificationListener(f: (n: WalletNotification) => void): void {
+ this.listeners.push(f);
+ }
+}
diff --git a/packages/taler-wallet-core/src/operations/testing.d.ts.map b/packages/taler-wallet-core/src/operations/testing.d.ts.map
new file mode 100644
index 000000000..d7b3ceaec
--- /dev/null
+++ b/packages/taler-wallet-core/src/operations/testing.d.ts.map
@@ -0,0 +1 @@
+{"version":3,"file":"testing.d.ts","sourceRoot":"","sources":["testing.ts"],"names":[],"mappings":"AAwBA,OAAO,EAAE,mBAAmB,EAAE,MAAM,SAAS,CAAC;AAuC9C,wBAAsB,mBAAmB,CACvC,EAAE,EAAE,mBAAmB,EACvB,MAAM,SAAiB,EACvB,WAAW,SAAiC,EAC5C,eAAe,SAAqC,GACnD,OAAO,CAAC,IAAI,CAAC,CAuBf"} \ No newline at end of file
diff --git a/packages/taler-wallet-core/src/operations/testing.ts b/packages/taler-wallet-core/src/operations/testing.ts
new file mode 100644
index 000000000..71cee1f3a
--- /dev/null
+++ b/packages/taler-wallet-core/src/operations/testing.ts
@@ -0,0 +1,156 @@
+/*
+ This file is part of GNU Taler
+ (C) 2020 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+import { Logger } from "../util/logging";
+import {
+ HttpRequestLibrary,
+ readSuccessResponseJsonOrThrow,
+ checkSuccessResponseOrThrow,
+} from "../util/http";
+import { codecForAny } from "../util/codec";
+import { AmountString } from "../types/talerTypes";
+import { InternalWalletState } from "./state";
+import { createTalerWithdrawReserve } from "./reserves";
+import { URL } from "../util/url";
+
+const logger = new Logger("operations/testing.ts");
+
+interface BankUser {
+ username: string;
+ password: string;
+}
+
+interface BankWithdrawalResponse {
+ taler_withdraw_uri: string;
+ withdrawal_id: string;
+}
+
+/**
+ * Generate a random alphanumeric ID. Does *not* use cryptographically
+ * secure randomness.
+ */
+function makeId(length: number): string {
+ let result = "";
+ const characters =
+ "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";
+ for (let i = 0; i < length; i++) {
+ result += characters.charAt(Math.floor(Math.random() * characters.length));
+ }
+ return result;
+}
+
+/**
+ * Helper function to generate the "Authorization" HTTP header.
+ */
+function makeAuth(username: string, password: string): string {
+ const auth = `${username}:${password}`;
+ const authEncoded: string = Buffer.from(auth).toString("base64");
+ return `Basic ${authEncoded}`;
+}
+
+export async function withdrawTestBalance(
+ ws: InternalWalletState,
+ amount = "TESTKUDOS:10",
+ bankBaseUrl = "https://bank.test.taler.net/",
+ exchangeBaseUrl = "https://exchange.test.taler.net/",
+): Promise<void> {
+ const bankUser = await registerRandomBankUser(ws.http, bankBaseUrl);
+ logger.trace(`Registered bank user ${JSON.stringify(bankUser)}`);
+
+ const wresp = await createBankWithdrawalUri(
+ ws.http,
+ bankBaseUrl,
+ bankUser,
+ amount,
+ );
+
+ await createTalerWithdrawReserve(
+ ws,
+ wresp.taler_withdraw_uri,
+ exchangeBaseUrl,
+ );
+
+ await confirmBankWithdrawalUri(
+ ws.http,
+ bankBaseUrl,
+ bankUser,
+ wresp.withdrawal_id,
+ );
+}
+
+async function createBankWithdrawalUri(
+ http: HttpRequestLibrary,
+ bankBaseUrl: string,
+ bankUser: BankUser,
+ amount: AmountString,
+): Promise<BankWithdrawalResponse> {
+ const reqUrl = new URL(
+ `accounts/${bankUser.username}/withdrawals`,
+ bankBaseUrl,
+ ).href;
+ const resp = await http.postJson(
+ reqUrl,
+ {
+ amount,
+ },
+ {
+ headers: {
+ Authorization: makeAuth(bankUser.username, bankUser.password),
+ },
+ },
+ );
+ const respJson = await readSuccessResponseJsonOrThrow(resp, codecForAny);
+ return respJson;
+}
+
+async function confirmBankWithdrawalUri(
+ http: HttpRequestLibrary,
+ bankBaseUrl: string,
+ bankUser: BankUser,
+ withdrawalId: string,
+): Promise<void> {
+ const reqUrl = new URL(
+ `accounts/${bankUser.username}/withdrawals/${withdrawalId}/confirm`,
+ bankBaseUrl,
+ ).href;
+ const resp = await http.postJson(
+ reqUrl,
+ {},
+ {
+ headers: {
+ Authorization: makeAuth(bankUser.username, bankUser.password),
+ },
+ },
+ );
+ await readSuccessResponseJsonOrThrow(resp, codecForAny);
+ return;
+}
+
+async function registerRandomBankUser(
+ http: HttpRequestLibrary,
+ bankBaseUrl: string,
+): Promise<BankUser> {
+ const reqUrl = new URL("testing/register", bankBaseUrl).href;
+ const randId = makeId(8);
+ const bankUser: BankUser = {
+ username: `testuser-${randId}`,
+ password: `testpw-${randId}`,
+ };
+
+ const resp = await http.postJson(reqUrl, bankUser);
+ await checkSuccessResponseOrThrow(resp);
+ return bankUser;
+}
diff --git a/packages/taler-wallet-core/src/operations/tip.d.ts.map b/packages/taler-wallet-core/src/operations/tip.d.ts.map
new file mode 100644
index 000000000..8d8a72fb8
--- /dev/null
+++ b/packages/taler-wallet-core/src/operations/tip.d.ts.map
@@ -0,0 +1 @@
+{"version":3,"file":"tip.d.ts","sourceRoot":"","sources":["tip.ts"],"names":[],"mappings":"AAgBA,OAAO,EAAE,mBAAmB,EAAE,MAAM,SAAS,CAAC;AAE9C,OAAO,EAAE,SAAS,EAAyB,MAAM,sBAAsB,CAAC;AA8BxE,wBAAsB,YAAY,CAChC,EAAE,EAAE,mBAAmB,EACvB,WAAW,EAAE,MAAM,GAClB,OAAO,CAAC,SAAS,CAAC,CAgFpB;AAuBD,wBAAsB,UAAU,CAC9B,EAAE,EAAE,mBAAmB,EACvB,KAAK,EAAE,MAAM,EACb,QAAQ,UAAQ,GACf,OAAO,CAAC,IAAI,CAAC,CAOf;AAkKD,wBAAsB,SAAS,CAC7B,EAAE,EAAE,mBAAmB,EACvB,KAAK,EAAE,MAAM,GACZ,OAAO,CAAC,IAAI,CAAC,CAYf"} \ No newline at end of file
diff --git a/packages/taler-wallet-core/src/operations/tip.ts b/packages/taler-wallet-core/src/operations/tip.ts
new file mode 100644
index 000000000..d6768bdb6
--- /dev/null
+++ b/packages/taler-wallet-core/src/operations/tip.ts
@@ -0,0 +1,343 @@
+/*
+ This file is part of GNU Taler
+ (C) 2019 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+import { InternalWalletState } from "./state";
+import { parseTipUri } from "../util/taleruri";
+import { TipStatus, OperationErrorDetails } from "../types/walletTypes";
+import {
+ TipPlanchetDetail,
+ codecForTipPickupGetResponse,
+ codecForTipResponse,
+} from "../types/talerTypes";
+import * as Amounts from "../util/amounts";
+import {
+ Stores,
+ PlanchetRecord,
+ WithdrawalGroupRecord,
+ initRetryInfo,
+ updateRetryInfoTimeout,
+ WithdrawalSourceType,
+ TipPlanchet,
+} from "../types/dbTypes";
+import {
+ getExchangeWithdrawalInfo,
+ selectWithdrawalDenoms,
+ processWithdrawGroup,
+ denomSelectionInfoToState,
+} from "./withdraw";
+import { updateExchangeFromUrl } from "./exchanges";
+import { getRandomBytes, encodeCrock } from "../crypto/talerCrypto";
+import { guardOperationException } from "./errors";
+import { NotificationType } from "../types/notifications";
+import { getTimestampNow } from "../util/time";
+import { readSuccessResponseJsonOrThrow } from "../util/http";
+import { URL } from "../util/url";
+
+export async function getTipStatus(
+ ws: InternalWalletState,
+ talerTipUri: string,
+): Promise<TipStatus> {
+ const res = parseTipUri(talerTipUri);
+ if (!res) {
+ throw Error("invalid taler://tip URI");
+ }
+
+ const tipStatusUrl = new URL("tip-pickup", res.merchantBaseUrl);
+ tipStatusUrl.searchParams.set("tip_id", res.merchantTipId);
+ console.log("checking tip status from", tipStatusUrl.href);
+ const merchantResp = await ws.http.get(tipStatusUrl.href);
+ const tipPickupStatus = await readSuccessResponseJsonOrThrow(
+ merchantResp,
+ codecForTipPickupGetResponse(),
+ );
+ console.log("status", tipPickupStatus);
+
+ const amount = Amounts.parseOrThrow(tipPickupStatus.amount);
+
+ const merchantOrigin = new URL(res.merchantBaseUrl).origin;
+
+ let tipRecord = await ws.db.get(Stores.tips, [
+ res.merchantTipId,
+ merchantOrigin,
+ ]);
+
+ if (!tipRecord) {
+ await updateExchangeFromUrl(ws, tipPickupStatus.exchange_url);
+ const withdrawDetails = await getExchangeWithdrawalInfo(
+ ws,
+ tipPickupStatus.exchange_url,
+ amount,
+ );
+
+ const tipId = encodeCrock(getRandomBytes(32));
+ const selectedDenoms = await selectWithdrawalDenoms(
+ ws,
+ tipPickupStatus.exchange_url,
+ amount,
+ );
+
+ tipRecord = {
+ tipId,
+ acceptedTimestamp: undefined,
+ rejectedTimestamp: undefined,
+ amount,
+ deadline: tipPickupStatus.stamp_expire,
+ exchangeUrl: tipPickupStatus.exchange_url,
+ merchantBaseUrl: res.merchantBaseUrl,
+ nextUrl: undefined,
+ pickedUp: false,
+ planchets: undefined,
+ response: undefined,
+ createdTimestamp: getTimestampNow(),
+ merchantTipId: res.merchantTipId,
+ totalFees: Amounts.add(
+ withdrawDetails.overhead,
+ withdrawDetails.withdrawFee,
+ ).amount,
+ retryInfo: initRetryInfo(),
+ lastError: undefined,
+ denomsSel: denomSelectionInfoToState(selectedDenoms),
+ };
+ await ws.db.put(Stores.tips, tipRecord);
+ }
+
+ const tipStatus: TipStatus = {
+ accepted: !!tipRecord && !!tipRecord.acceptedTimestamp,
+ amount: Amounts.parseOrThrow(tipPickupStatus.amount),
+ amountLeft: Amounts.parseOrThrow(tipPickupStatus.amount_left),
+ exchangeUrl: tipPickupStatus.exchange_url,
+ nextUrl: tipPickupStatus.extra.next_url,
+ merchantOrigin: merchantOrigin,
+ merchantTipId: res.merchantTipId,
+ expirationTimestamp: tipPickupStatus.stamp_expire,
+ timestamp: tipPickupStatus.stamp_created,
+ totalFees: tipRecord.totalFees,
+ tipId: tipRecord.tipId,
+ };
+
+ return tipStatus;
+}
+
+async function incrementTipRetry(
+ ws: InternalWalletState,
+ refreshSessionId: string,
+ err: OperationErrorDetails | undefined,
+): Promise<void> {
+ await ws.db.runWithWriteTransaction([Stores.tips], async (tx) => {
+ const t = await tx.get(Stores.tips, refreshSessionId);
+ if (!t) {
+ return;
+ }
+ if (!t.retryInfo) {
+ return;
+ }
+ t.retryInfo.retryCounter++;
+ updateRetryInfoTimeout(t.retryInfo);
+ t.lastError = err;
+ await tx.put(Stores.tips, t);
+ });
+ ws.notify({ type: NotificationType.TipOperationError });
+}
+
+export async function processTip(
+ ws: InternalWalletState,
+ tipId: string,
+ forceNow = false,
+): Promise<void> {
+ const onOpErr = (e: OperationErrorDetails): Promise<void> =>
+ incrementTipRetry(ws, tipId, e);
+ await guardOperationException(
+ () => processTipImpl(ws, tipId, forceNow),
+ onOpErr,
+ );
+}
+
+async function resetTipRetry(
+ ws: InternalWalletState,
+ tipId: string,
+): Promise<void> {
+ await ws.db.mutate(Stores.tips, tipId, (x) => {
+ if (x.retryInfo.active) {
+ x.retryInfo = initRetryInfo();
+ }
+ return x;
+ });
+}
+
+async function processTipImpl(
+ ws: InternalWalletState,
+ tipId: string,
+ forceNow: boolean,
+): Promise<void> {
+ if (forceNow) {
+ await resetTipRetry(ws, tipId);
+ }
+ let tipRecord = await ws.db.get(Stores.tips, tipId);
+ if (!tipRecord) {
+ return;
+ }
+
+ if (tipRecord.pickedUp) {
+ console.log("tip already picked up");
+ return;
+ }
+
+ const denomsForWithdraw = tipRecord.denomsSel;
+
+ if (!tipRecord.planchets) {
+ const planchets: TipPlanchet[] = [];
+
+ for (const sd of denomsForWithdraw.selectedDenoms) {
+ const denom = await ws.db.getIndexed(
+ Stores.denominations.denomPubHashIndex,
+ sd.denomPubHash,
+ );
+ if (!denom) {
+ throw Error("denom does not exist anymore");
+ }
+ for (let i = 0; i < sd.count; i++) {
+ const r = await ws.cryptoApi.createTipPlanchet(denom);
+ planchets.push(r);
+ }
+ }
+ await ws.db.mutate(Stores.tips, tipId, (r) => {
+ if (!r.planchets) {
+ r.planchets = planchets;
+ }
+ return r;
+ });
+ }
+
+ tipRecord = await ws.db.get(Stores.tips, tipId);
+ if (!tipRecord) {
+ throw Error("tip not in database");
+ }
+
+ if (!tipRecord.planchets) {
+ throw Error("invariant violated");
+ }
+
+ console.log("got planchets for tip!");
+
+ // Planchets in the form that the merchant expects
+ const planchetsDetail: TipPlanchetDetail[] = tipRecord.planchets.map((p) => ({
+ coin_ev: p.coinEv,
+ denom_pub_hash: p.denomPubHash,
+ }));
+
+ let merchantResp;
+
+ const tipStatusUrl = new URL("tip-pickup", tipRecord.merchantBaseUrl);
+
+ try {
+ const req = { planchets: planchetsDetail, tip_id: tipRecord.merchantTipId };
+ merchantResp = await ws.http.postJson(tipStatusUrl.href, req);
+ if (merchantResp.status !== 200) {
+ throw Error(`unexpected status ${merchantResp.status} for tip-pickup`);
+ }
+ console.log("got merchant resp:", merchantResp);
+ } catch (e) {
+ console.log("tipping failed", e);
+ throw e;
+ }
+
+ const response = codecForTipResponse().decode(await merchantResp.json());
+
+ if (response.reserve_sigs.length !== tipRecord.planchets.length) {
+ throw Error("number of tip responses does not match requested planchets");
+ }
+
+ const withdrawalGroupId = encodeCrock(getRandomBytes(32));
+ const planchets: PlanchetRecord[] = [];
+
+ for (let i = 0; i < tipRecord.planchets.length; i++) {
+ const tipPlanchet = tipRecord.planchets[i];
+ const coinEvHash = await ws.cryptoApi.hashEncoded(tipPlanchet.coinEv);
+ const planchet: PlanchetRecord = {
+ blindingKey: tipPlanchet.blindingKey,
+ coinEv: tipPlanchet.coinEv,
+ coinPriv: tipPlanchet.coinPriv,
+ coinPub: tipPlanchet.coinPub,
+ coinValue: tipPlanchet.coinValue,
+ denomPub: tipPlanchet.denomPub,
+ denomPubHash: tipPlanchet.denomPubHash,
+ reservePub: response.reserve_pub,
+ withdrawSig: response.reserve_sigs[i].reserve_sig,
+ isFromTip: true,
+ coinEvHash,
+ coinIdx: i,
+ withdrawalDone: false,
+ withdrawalGroupId: withdrawalGroupId,
+ };
+ planchets.push(planchet);
+ }
+
+ const withdrawalGroup: WithdrawalGroupRecord = {
+ exchangeBaseUrl: tipRecord.exchangeUrl,
+ source: {
+ type: WithdrawalSourceType.Tip,
+ tipId: tipRecord.tipId,
+ },
+ timestampStart: getTimestampNow(),
+ withdrawalGroupId: withdrawalGroupId,
+ rawWithdrawalAmount: tipRecord.amount,
+ lastErrorPerCoin: {},
+ retryInfo: initRetryInfo(),
+ timestampFinish: undefined,
+ lastError: undefined,
+ denomsSel: tipRecord.denomsSel,
+ };
+
+ await ws.db.runWithWriteTransaction(
+ [Stores.tips, Stores.withdrawalGroups],
+ async (tx) => {
+ const tr = await tx.get(Stores.tips, tipId);
+ if (!tr) {
+ return;
+ }
+ if (tr.pickedUp) {
+ return;
+ }
+ tr.pickedUp = true;
+ tr.retryInfo = initRetryInfo(false);
+
+ await tx.put(Stores.tips, tr);
+ await tx.put(Stores.withdrawalGroups, withdrawalGroup);
+ for (const p of planchets) {
+ await tx.put(Stores.planchets, p);
+ }
+ },
+ );
+
+ await processWithdrawGroup(ws, withdrawalGroupId);
+}
+
+export async function acceptTip(
+ ws: InternalWalletState,
+ tipId: string,
+): Promise<void> {
+ const tipRecord = await ws.db.get(Stores.tips, tipId);
+ if (!tipRecord) {
+ console.log("tip not found");
+ return;
+ }
+
+ tipRecord.acceptedTimestamp = getTimestampNow();
+ await ws.db.put(Stores.tips, tipRecord);
+
+ await processTip(ws, tipId);
+ return;
+}
diff --git a/packages/taler-wallet-core/src/operations/transactions.d.ts.map b/packages/taler-wallet-core/src/operations/transactions.d.ts.map
new file mode 100644
index 000000000..5a462e4d6
--- /dev/null
+++ b/packages/taler-wallet-core/src/operations/transactions.d.ts.map
@@ -0,0 +1 @@
+{"version":3,"file":"transactions.d.ts","sourceRoot":"","sources":["transactions.ts"],"names":[],"mappings":"AAgBA;;GAEG;AACH,OAAO,EAAE,mBAAmB,EAAE,MAAM,SAAS,CAAC;AAO9C,OAAO,EACL,mBAAmB,EACnB,oBAAoB,EAMrB,MAAM,uBAAuB,CAAC;AAoC/B;;GAEG;AACH,wBAAsB,eAAe,CACnC,EAAE,EAAE,mBAAmB,EACvB,mBAAmB,CAAC,EAAE,mBAAmB,GACxC,OAAO,CAAC,oBAAoB,CAAC,CAuN/B"} \ No newline at end of file
diff --git a/packages/taler-wallet-core/src/operations/transactions.ts b/packages/taler-wallet-core/src/operations/transactions.ts
new file mode 100644
index 000000000..2d66b5e9d
--- /dev/null
+++ b/packages/taler-wallet-core/src/operations/transactions.ts
@@ -0,0 +1,288 @@
+/*
+ This file is part of GNU Taler
+ (C) 2019 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ * Imports.
+ */
+import { InternalWalletState } from "./state";
+import { Stores, WithdrawalSourceType } from "../types/dbTypes";
+import { Amounts, AmountJson } from "../util/amounts";
+import { timestampCmp } from "../util/time";
+import {
+ TransactionsRequest,
+ TransactionsResponse,
+ Transaction,
+ TransactionType,
+ PaymentStatus,
+ WithdrawalType,
+ WithdrawalDetails,
+} from "../types/transactions";
+import { getFundingPaytoUris } from "./reserves";
+
+/**
+ * Create an event ID from the type and the primary key for the event.
+ */
+function makeEventId(type: TransactionType, ...args: string[]): string {
+ return type + ";" + args.map((x) => encodeURIComponent(x)).join(";");
+}
+
+function shouldSkipCurrency(
+ transactionsRequest: TransactionsRequest | undefined,
+ currency: string,
+): boolean {
+ if (!transactionsRequest?.currency) {
+ return false;
+ }
+ return transactionsRequest.currency.toLowerCase() !== currency.toLowerCase();
+}
+
+function shouldSkipSearch(
+ transactionsRequest: TransactionsRequest | undefined,
+ fields: string[],
+): boolean {
+ if (!transactionsRequest?.search) {
+ return false;
+ }
+ const needle = transactionsRequest.search.trim();
+ for (const f of fields) {
+ if (f.indexOf(needle) >= 0) {
+ return false;
+ }
+ }
+ return true;
+}
+
+/**
+ * Retrive the full event history for this wallet.
+ */
+export async function getTransactions(
+ ws: InternalWalletState,
+ transactionsRequest?: TransactionsRequest,
+): Promise<TransactionsResponse> {
+ const transactions: Transaction[] = [];
+
+ await ws.db.runWithReadTransaction(
+ [
+ Stores.currencies,
+ Stores.coins,
+ Stores.denominations,
+ Stores.exchanges,
+ Stores.proposals,
+ Stores.purchases,
+ Stores.refreshGroups,
+ Stores.reserves,
+ Stores.reserveHistory,
+ Stores.tips,
+ Stores.withdrawalGroups,
+ Stores.payEvents,
+ Stores.planchets,
+ Stores.refundEvents,
+ Stores.reserveUpdatedEvents,
+ Stores.recoupGroups,
+ ],
+ // Report withdrawals that are currently in progress.
+ async (tx) => {
+ tx.iter(Stores.withdrawalGroups).forEachAsync(async (wsr) => {
+ if (
+ shouldSkipCurrency(
+ transactionsRequest,
+ wsr.rawWithdrawalAmount.currency,
+ )
+ ) {
+ return;
+ }
+
+ if (shouldSkipSearch(transactionsRequest, [])) {
+ return;
+ }
+
+ switch (wsr.source.type) {
+ case WithdrawalSourceType.Reserve:
+ {
+ const r = await tx.get(Stores.reserves, wsr.source.reservePub);
+ if (!r) {
+ break;
+ }
+ let amountRaw: AmountJson | undefined = undefined;
+ if (wsr.withdrawalGroupId === r.initialWithdrawalGroupId) {
+ amountRaw = r.instructedAmount;
+ } else {
+ amountRaw = wsr.denomsSel.totalWithdrawCost;
+ }
+ let withdrawalDetails: WithdrawalDetails;
+ if (r.bankInfo) {
+ withdrawalDetails = {
+ type: WithdrawalType.TalerBankIntegrationApi,
+ confirmed: true,
+ bankConfirmationUrl: r.bankInfo.confirmUrl,
+ };
+ } else {
+ const exchange = await tx.get(
+ Stores.exchanges,
+ r.exchangeBaseUrl,
+ );
+ if (!exchange) {
+ // FIXME: report somehow
+ break;
+ }
+ withdrawalDetails = {
+ type: WithdrawalType.ManualTransfer,
+ exchangePaytoUris:
+ exchange.wireInfo?.accounts.map((x) => x.payto_uri) ?? [],
+ };
+ }
+ transactions.push({
+ type: TransactionType.Withdrawal,
+ amountEffective: Amounts.stringify(
+ wsr.denomsSel.totalCoinValue,
+ ),
+ amountRaw: Amounts.stringify(amountRaw),
+ withdrawalDetails,
+ exchangeBaseUrl: wsr.exchangeBaseUrl,
+ pending: !wsr.timestampFinish,
+ timestamp: wsr.timestampStart,
+ transactionId: makeEventId(
+ TransactionType.Withdrawal,
+ wsr.withdrawalGroupId,
+ ),
+ });
+ }
+ break;
+ default:
+ // Tips are reported via their own event
+ break;
+ }
+ });
+
+ // Report pending withdrawals based on reserves that
+ // were created, but where the actual withdrawal group has
+ // not started yet.
+ tx.iter(Stores.reserves).forEachAsync(async (r) => {
+ if (shouldSkipCurrency(transactionsRequest, r.currency)) {
+ return;
+ }
+ if (shouldSkipSearch(transactionsRequest, [])) {
+ return;
+ }
+ if (r.initialWithdrawalStarted) {
+ return;
+ }
+ let withdrawalDetails: WithdrawalDetails;
+ if (r.bankInfo) {
+ withdrawalDetails = {
+ type: WithdrawalType.TalerBankIntegrationApi,
+ confirmed: false,
+ bankConfirmationUrl: r.bankInfo.confirmUrl,
+ };
+ } else {
+ withdrawalDetails = {
+ type: WithdrawalType.ManualTransfer,
+ exchangePaytoUris: await getFundingPaytoUris(tx, r.reservePub),
+ };
+ }
+ transactions.push({
+ type: TransactionType.Withdrawal,
+ amountRaw: Amounts.stringify(r.instructedAmount),
+ amountEffective: Amounts.stringify(r.initialDenomSel.totalCoinValue),
+ exchangeBaseUrl: r.exchangeBaseUrl,
+ pending: true,
+ timestamp: r.timestampCreated,
+ withdrawalDetails: withdrawalDetails,
+ transactionId: makeEventId(
+ TransactionType.Withdrawal,
+ r.initialWithdrawalGroupId,
+ ),
+ });
+ });
+
+ tx.iter(Stores.purchases).forEachAsync(async (pr) => {
+ if (
+ shouldSkipCurrency(
+ transactionsRequest,
+ pr.contractData.amount.currency,
+ )
+ ) {
+ return;
+ }
+ if (shouldSkipSearch(transactionsRequest, [pr.contractData.summary])) {
+ return;
+ }
+ const proposal = await tx.get(Stores.proposals, pr.proposalId);
+ if (!proposal) {
+ return;
+ }
+ transactions.push({
+ type: TransactionType.Payment,
+ amountRaw: Amounts.stringify(pr.contractData.amount),
+ amountEffective: Amounts.stringify(pr.payCostInfo.totalCost),
+ status: pr.timestampFirstSuccessfulPay
+ ? PaymentStatus.Paid
+ : PaymentStatus.Accepted,
+ pending: !pr.timestampFirstSuccessfulPay,
+ timestamp: pr.timestampAccept,
+ transactionId: makeEventId(TransactionType.Payment, pr.proposalId),
+ info: {
+ fulfillmentUrl: pr.contractData.fulfillmentUrl,
+ merchant: pr.contractData.merchant,
+ orderId: pr.contractData.orderId,
+ products: pr.contractData.products,
+ summary: pr.contractData.summary,
+ summary_i18n: pr.contractData.summaryI18n,
+ },
+ });
+
+ // for (const rg of pr.refundGroups) {
+ // const pending = Object.keys(pr.refundsPending).length > 0;
+ // const stats = getRefundStats(pr, rg.refundGroupId);
+
+ // transactions.push({
+ // type: TransactionType.Refund,
+ // pending,
+ // info: {
+ // fulfillmentUrl: pr.contractData.fulfillmentUrl,
+ // merchant: pr.contractData.merchant,
+ // orderId: pr.contractData.orderId,
+ // products: pr.contractData.products,
+ // summary: pr.contractData.summary,
+ // summary_i18n: pr.contractData.summaryI18n,
+ // },
+ // timestamp: rg.timestampQueried,
+ // transactionId: makeEventId(
+ // TransactionType.Refund,
+ // pr.proposalId,
+ // `${rg.timestampQueried.t_ms}`,
+ // ),
+ // refundedTransactionId: makeEventId(
+ // TransactionType.Payment,
+ // pr.proposalId,
+ // ),
+ // amountEffective: Amounts.stringify(stats.amountEffective),
+ // amountInvalid: Amounts.stringify(stats.amountInvalid),
+ // amountRaw: Amounts.stringify(stats.amountRaw),
+ // });
+ // }
+ });
+ },
+ );
+
+ const txPending = transactions.filter((x) => x.pending);
+ const txNotPending = transactions.filter((x) => !x.pending);
+
+ txPending.sort((h1, h2) => timestampCmp(h1.timestamp, h2.timestamp));
+ txNotPending.sort((h1, h2) => timestampCmp(h1.timestamp, h2.timestamp));
+
+ return { transactions: [...txPending, ...txNotPending] };
+}
diff --git a/packages/taler-wallet-core/src/operations/versions.d.ts.map b/packages/taler-wallet-core/src/operations/versions.d.ts.map
new file mode 100644
index 000000000..15ba8d27e
--- /dev/null
+++ b/packages/taler-wallet-core/src/operations/versions.d.ts.map
@@ -0,0 +1 @@
+{"version":3,"file":"versions.d.ts","sourceRoot":"","sources":["versions.ts"],"names":[],"mappings":"AAgBA;;;;GAIG;AACH,eAAO,MAAM,gCAAgC,UAAU,CAAC;AAExD;;;;GAIG;AACH,eAAO,MAAM,gCAAgC,UAAU,CAAC;AAExD;;;;;;GAMG;AACH,eAAO,MAAM,mCAAmC,MAAM,CAAC"} \ No newline at end of file
diff --git a/packages/taler-wallet-core/src/operations/versions.ts b/packages/taler-wallet-core/src/operations/versions.ts
new file mode 100644
index 000000000..31c4921c6
--- /dev/null
+++ b/packages/taler-wallet-core/src/operations/versions.ts
@@ -0,0 +1,38 @@
+/*
+ This file is part of GNU Taler
+ (C) 2019 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ * Protocol version spoken with the exchange.
+ *
+ * Uses libtool's current:revision:age versioning.
+ */
+export const WALLET_EXCHANGE_PROTOCOL_VERSION = "8:0:0";
+
+/**
+ * Protocol version spoken with the merchant.
+ *
+ * Uses libtool's current:revision:age versioning.
+ */
+export const WALLET_MERCHANT_PROTOCOL_VERSION = "1:0:0";
+
+/**
+ * Cache breaker that is appended to queries such as /keys and /wire
+ * to break through caching, if it has been accidentally/badly configured
+ * by the exchange.
+ *
+ * This is only a temporary measure.
+ */
+export const WALLET_CACHE_BREAKER_CLIENT_VERSION = "3";
diff --git a/packages/taler-wallet-core/src/operations/withdraw-test.ts b/packages/taler-wallet-core/src/operations/withdraw-test.ts
new file mode 100644
index 000000000..24cb6f4b1
--- /dev/null
+++ b/packages/taler-wallet-core/src/operations/withdraw-test.ts
@@ -0,0 +1,332 @@
+/*
+ This file is part of GNU Taler
+ (C) 2020 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+import test from "ava";
+import { getWithdrawDenomList } from "./withdraw";
+import { Amounts } from "../util/amounts";
+
+test("withdrawal selection bug repro", (t) => {
+ const amount = {
+ currency: "KUDOS",
+ fraction: 43000000,
+ value: 23,
+ };
+
+ const denoms = [
+ {
+ denomPub:
+ "040000XT67C8KBD6B75TTQ3SK8FWXMNQW4372T3BDDGPAMB9RFCA03638W8T3F71WFEFK9NP32VKYVNFXPYRWQ1N1HDKV5J0DFEKHBPJCYSWCBJDRNWD7G8BN8PT97FA9AMV75MYEK4X54D1HGJ207JSVJBGFCATSPNTEYNHEQF1F220W00TBZR1HNPDQFD56FG0DJQ9KGHM8EC33H6AY9YN9CNX5R3Z4TZ4Q23W47SBHB13H6W74FQJG1F50X38VRSC4SR8RWBAFB7S4K8D2H4NMRFSQT892A3T0BTBW7HM5C0H2CK6FRKG31F7W9WP1S29013K5CXYE55CT8TH6N8J9B780R42Y5S3ZB6J6E9H76XBPSGH4TGYSR2VZRB98J417KCQMZKX1BB67E7W5KVE37TC9SJ904002",
+ denomPubHash:
+ "Q21FQSSG4FXNT96Z14CHXM8N1RZAG9GPHAV8PRWS0PZAAVWH7PBW6R97M2CH19KKP65NNSWXY7B6S53PT3CBM342E357ZXDDJ8RDVW8",
+ exchangeBaseUrl: "https://exchange.demo.taler.net/",
+ feeDeposit: {
+ currency: "KUDOS",
+ fraction: 1000000,
+ value: 0,
+ },
+ feeRefresh: {
+ currency: "KUDOS",
+ fraction: 1000000,
+ value: 0,
+ },
+ feeRefund: {
+ currency: "KUDOS",
+ fraction: 1000000,
+ value: 0,
+ },
+ feeWithdraw: {
+ currency: "KUDOS",
+ fraction: 1000000,
+ value: 0,
+ },
+ isOffered: true,
+ isRevoked: false,
+ masterSig:
+ "4F0P456CNNTTWK8BFJHGM3JTD6FVVNZY8EP077GYAHDJ5Y81S5RQ3SMS925NXMDVG9A88JAAP0E2GDZBC21PP5NHFFVWHAW3AVT8J3R",
+ stampExpireDeposit: {
+ t_ms: 1742909388000,
+ },
+ stampExpireLegal: {
+ t_ms: 1900589388000,
+ },
+ stampExpireWithdraw: {
+ t_ms: 1679837388000,
+ },
+ stampStart: {
+ t_ms: 1585229388000,
+ },
+ status: 0,
+ value: {
+ currency: "KUDOS",
+ fraction: 0,
+ value: 1000,
+ },
+ },
+ {
+ denomPub:
+ "040000Y63CF78QFPKRY77BRK9P557Q1GQWX3NCZ3HSYSK0Z7TT0KGRA7N4SKBKEHSTVHX1Z9DNXMJR4EXSY1TXCKV0GJ3T3YYC6Z0JNMJFVYQAV4FX5J90NZH1N33MZTV8HS9SMNAA9S6K73G4P99GYBB01B0P6M1KXZ5JRDR7VWBR3MEJHHGJ6QBMCJR3NWJRE3WJW9PRY8QPQ2S7KFWTWRESH2DBXCXWBD2SRN6P9YX8GRAEMFEGXC9V5GVJTEMH6ZDGNXFPWZE3JVJ2Q4N9GDYKBCHZCJ7M7M2RJ9ZV4Y64NAN9BT6XDC68215GKKRHTW1BBF1MYY6AR3JCTT9HYAM923RMVQR3TAEB7SDX8J76XRZWYH3AGJCZAQGMN5C8SSH9AHQ9RNQJQ15CN45R37X4YNFJV904002",
+ denomPubHash:
+ "447WA23SCBATMABHA0793F92MYTBYVPYMMQHCPKMKVY5P7RZRFMQ6VRW0Y8HRA7177GTBT0TBT08R21DZD129AJ995H9G09XBFE55G8",
+ exchangeBaseUrl: "https://exchange.demo.taler.net/",
+ feeDeposit: {
+ currency: "KUDOS",
+ fraction: 1000000,
+ value: 0,
+ },
+ feeRefresh: {
+ currency: "KUDOS",
+ fraction: 1000000,
+ value: 0,
+ },
+ feeRefund: {
+ currency: "KUDOS",
+ fraction: 1000000,
+ value: 0,
+ },
+ feeWithdraw: {
+ currency: "KUDOS",
+ fraction: 1000000,
+ value: 0,
+ },
+ isOffered: true,
+ isRevoked: false,
+ masterSig:
+ "P99AW82W46MZ0AKW7Z58VQPXFNTJQM9DVTYPBDF6KVYF38PPVDAZTV7JQ8TY7HGEC7JJJAY4E7AY7J3W1WV10DAZZQHHKTAVTSRAC20",
+ stampExpireDeposit: {
+ t_ms: 1742909388000,
+ },
+ stampExpireLegal: {
+ t_ms: 1900589388000,
+ },
+ stampExpireWithdraw: {
+ t_ms: 1679837388000,
+ },
+ stampStart: {
+ t_ms: 1585229388000,
+ },
+ status: 0,
+ value: {
+ currency: "KUDOS",
+ fraction: 0,
+ value: 10,
+ },
+ },
+ {
+ denomPub:
+ "040000YDESWC2B962DA4WK356SC50MA3N9KV0ZSGY3RC48JCTY258W909C7EEMT5BTC5KZ5T4CERCZ141P9QF87EK2BD1XEEM5GB07MB3H19WE4CQGAS8X84JBWN83PQGQXVMWE5HFA992KMGHC566GT9ZS2QPHZB6X89C4A80Z663PYAAPXP728VHAKATGNNBQ01ZZ2XD1CH9Y38YZBSPJ4K7GB2J76GBCYAVD9ENHDVWXJAXYRPBX4KSS5TXRR3K5NEN9ZV3AJD2V65K7ABRZDF5D5V1FJZZMNJ5XZ4FEREEKEBV9TDFPGJTKDEHEC60K3DN24DAATRESDJ1ZYYSYSRCAT4BT2B62ARGVMJTT5N2R126DRW9TGRWCW0ZAF2N2WET1H4NJEW77X0QT46Z5R3MZ0XPHD04002",
+ denomPubHash:
+ "JS61DTKAFM0BX8Q4XV3ZSKB921SM8QK745Z2AFXTKFMBHHFNBD8TQ5ETJHFNDGBGX22FFN2A2ERNYG1SGSDQWNQHQQ2B14DBVJYJG8R",
+ exchangeBaseUrl: "https://exchange.demo.taler.net/",
+ feeDeposit: {
+ currency: "KUDOS",
+ fraction: 1000000,
+ value: 0,
+ },
+ feeRefresh: {
+ currency: "KUDOS",
+ fraction: 1000000,
+ value: 0,
+ },
+ feeRefund: {
+ currency: "KUDOS",
+ fraction: 1000000,
+ value: 0,
+ },
+ feeWithdraw: {
+ currency: "KUDOS",
+ fraction: 1000000,
+ value: 0,
+ },
+ isOffered: true,
+ isRevoked: false,
+ masterSig:
+ "8S4VZGHE5WE0N5ZVCHYW9KZZR4YAKK15S46MV1HR1QB9AAMH3NWPW4DCR4NYGJK33Q8YNFY80SWNS6XKAP5DEVK933TM894FJ2VGE3G",
+ stampExpireDeposit: {
+ t_ms: 1742909388000,
+ },
+ stampExpireLegal: {
+ t_ms: 1900589388000,
+ },
+ stampExpireWithdraw: {
+ t_ms: 1679837388000,
+ },
+ stampStart: {
+ t_ms: 1585229388000,
+ },
+ status: 0,
+ value: {
+ currency: "KUDOS",
+ fraction: 0,
+ value: 5,
+ },
+ },
+ {
+ denomPub:
+ "040000YG3T1ADB8DVA6BD3EPV6ZHSHTDW35DEN4VH1AE6CSB7P1PSDTNTJG866PHF6QB1CCWYCVRGA0FVBJ9Q0G7KV7AD9010GDYBQH0NNPHW744MTNXVXWBGGGRGQGYK4DTYN1DSWQ1FZNDSZZPB5BEKG2PDJ93NX2JTN06Y8QMS2G734Z9XHC10EENBG2KVB7EJ3CM8PV1T32RC7AY62F3496E8D8KRHJQQTT67DSGMNKK86QXVDTYW677FG27DP20E8XY3M6FQD53NDJ1WWES91401MV1A3VXVPGC76GZVDD62W3WTJ1YMKHTTA3MRXX3VEAAH3XTKDN1ER7X6CZPMYTF8VK735VP2B2TZGTF28TTW4FZS32SBS64APCDF6SZQ427N5538TJC7SRE71YSP5ET8GS904002",
+ denomPubHash:
+ "8T51NEY81VMPQ180EQ5WR0YH7GMNNT90W55Q0514KZM18AZT71FHJGJHQXGK0WTA7ACN1X2SD0S53XPBQ1A9KH960R48VCVVM6E3TH8",
+ exchangeBaseUrl: "https://exchange.demo.taler.net/",
+ feeDeposit: {
+ currency: "KUDOS",
+ fraction: 1000000,
+ value: 0,
+ },
+ feeRefresh: {
+ currency: "KUDOS",
+ fraction: 1000000,
+ value: 0,
+ },
+ feeRefund: {
+ currency: "KUDOS",
+ fraction: 1000000,
+ value: 0,
+ },
+ feeWithdraw: {
+ currency: "KUDOS",
+ fraction: 1000000,
+ value: 0,
+ },
+ isOffered: true,
+ isRevoked: false,
+ masterSig:
+ "E3AWGAG8VB42P3KXM8B04Z6M483SX59R3Y4T53C3NXCA2NPB6C7HVCMVX05DC6S58E9X40NGEBQNYXKYMYCF3ASY2C4WP1WCZ4ME610",
+ stampExpireDeposit: {
+ t_ms: 1742909388000,
+ },
+ stampExpireLegal: {
+ t_ms: 1900589388000,
+ },
+ stampExpireWithdraw: {
+ t_ms: 1679837388000,
+ },
+ stampStart: {
+ t_ms: 1585229388000,
+ },
+ status: 0,
+ value: {
+ currency: "KUDOS",
+ fraction: 0,
+ value: 1,
+ },
+ },
+ {
+ denomPub:
+ "040000ZC0G60E9QQ5PD81TSDWD9GV5Y6P8Z05NSPA696DP07NGQQVSRQXBA76Q6PRB0YFX295RG4MTQJXAZZ860ET307HSC2X37XAVGQXRVB8Q4F1V7NP5ZEVKTX75DZK1QRAVHEZGQYKSSH6DBCJNQF6V9WNQF3GEYVA4KCBHA7JF772KHXM9642C28Z0AS4XXXV2PABAN5C8CHYD5H7JDFNK3920W5Q69X0BS84XZ4RE2PW6HM1WZ6KGZ3MKWWWCPKQ1FSFABRBWKAB09PF563BEBXKY6M38QETPH5EDWGANHD0SC3QV0WXYVB7BNHNNQ0J5BNV56K563SYHM4E5ND260YRJSYA1GN5YSW2B1J5T1A1EBNYF2DN6JNJKWXWEQ42G5YS17ZSZ5EWDRA9QKV8EGTCNAD04002",
+ denomPubHash:
+ "A41HW0Q2H9PCNMEWW0C0N45QAYVXZ8SBVRRAHE4W6X24SV1TH38ANTWDT80JXEBW9Z8PVPGT9GFV2EYZWJ5JW5W1N34NFNKHQSZ1PFR",
+ exchangeBaseUrl: "https://exchange.demo.taler.net/",
+ feeDeposit: {
+ currency: "KUDOS",
+ fraction: 1000000,
+ value: 0,
+ },
+ feeRefresh: {
+ currency: "KUDOS",
+ fraction: 1000000,
+ value: 0,
+ },
+ feeRefund: {
+ currency: "KUDOS",
+ fraction: 1000000,
+ value: 0,
+ },
+ feeWithdraw: {
+ currency: "KUDOS",
+ fraction: 1000000,
+ value: 0,
+ },
+ isOffered: true,
+ isRevoked: false,
+ masterSig:
+ "0ES1RKV002XB4YP21SN0QB7RSDHGYT0XAE65JYN8AVJAA6H7JZFN7JADXT521DJS89XMGPZGR8GCXF1516Y0Q9QDV00E6NMFA6CF838",
+ stampExpireDeposit: {
+ t_ms: 1742909388000,
+ },
+ stampExpireLegal: {
+ t_ms: 1900589388000,
+ },
+ stampExpireWithdraw: {
+ t_ms: 1679837388000,
+ },
+ stampStart: {
+ t_ms: 1585229388000,
+ },
+ status: 0,
+ value: {
+ currency: "KUDOS",
+ fraction: 10000000,
+ value: 0,
+ },
+ },
+ {
+ denomPub:
+ "040000ZSK2PMVY6E3NBQ52KXMW029M60F4BWYTDS0FZSD0PE53CNZ9H6TM3GQK1WRTEKQ5GRWJ1J9DY6Y42SP47QVT1XD1G0W05SQ5F3F7P5KSWR0FJBJ9NZBXQEVN8Q4JRC94X3JJ3XV3KBYTZ2HTDFV28C3H2SRR0XGNZB4FY85NDZF1G4AEYJJ9QB3C0V8H70YB8RV3FKTNH7XS4K4HFNZHJ5H9VMX5SM9Z2DX37HA5WFH0E2MJBVVF2BWWA5M0HPPSB365RAE2AMD42Q65A96WD80X27SB2ZNQZ8WX0K13FWF85GZ6YNYAJGE1KGN06JDEKE9QD68Z651D7XE8V6664TVVC8M68S7WD0DSXMJQKQ0BNJXNDE29Q7MRX6DA3RW0PZ44B3TKRK0294FPVZTNSTA6XF04002",
+ denomPubHash:
+ "F5NGBX33DTV4595XZZVK0S2MA1VMXFEJQERE5EBP5DS4QQ9EFRANN7YHWC1TKSHT2K6CQWDBRES8D3DWR0KZF5RET40B4AZXZ0RW1ZG",
+ exchangeBaseUrl: "https://exchange.demo.taler.net/",
+ feeDeposit: {
+ currency: "KUDOS",
+ fraction: 1000000,
+ value: 0,
+ },
+ feeRefresh: {
+ currency: "KUDOS",
+ fraction: 1000000,
+ value: 0,
+ },
+ feeRefund: {
+ currency: "KUDOS",
+ fraction: 1000000,
+ value: 0,
+ },
+ feeWithdraw: {
+ currency: "KUDOS",
+ fraction: 1000000,
+ value: 0,
+ },
+ isOffered: true,
+ isRevoked: false,
+ masterSig:
+ "58QEB6C6N7602E572E3JYANVVJ9BRW0V9E2ZFDW940N47YVQDK9SAFPWBN5YGT3G1742AFKQ0CYR4DM2VWV0Z0T1XMEKWN6X2EZ9M0R",
+ stampExpireDeposit: {
+ t_ms: 1742909388000,
+ },
+ stampExpireLegal: {
+ t_ms: 1900589388000,
+ },
+ stampExpireWithdraw: {
+ t_ms: 1679837388000,
+ },
+ stampStart: {
+ t_ms: 1585229388000,
+ },
+ status: 0,
+ value: {
+ currency: "KUDOS",
+ fraction: 0,
+ value: 2,
+ },
+ },
+ ];
+
+ const res = getWithdrawDenomList(amount, denoms);
+
+ console.error("cost", Amounts.stringify(res.totalWithdrawCost));
+ console.error("withdraw amount", Amounts.stringify(amount));
+
+ t.assert(Amounts.cmp(res.totalWithdrawCost, amount) <= 0);
+ t.pass();
+});
diff --git a/packages/taler-wallet-core/src/operations/withdraw.d.ts.map b/packages/taler-wallet-core/src/operations/withdraw.d.ts.map
new file mode 100644
index 000000000..51eeb1888
--- /dev/null
+++ b/packages/taler-wallet-core/src/operations/withdraw.d.ts.map
@@ -0,0 +1 @@
+{"version":3,"file":"withdraw.d.ts","sourceRoot":"","sources":["withdraw.ts"],"names":[],"mappings":"AAgBA,OAAO,EAAE,UAAU,EAAW,MAAM,iBAAiB,CAAC;AACtD,OAAO,EACL,kBAAkB,EAQlB,yBAAyB,EAGzB,mBAAmB,EACpB,MAAM,kBAAkB,CAAC;AAC1B,OAAO,EACL,mBAAmB,EACnB,uBAAuB,EAGxB,MAAM,sBAAsB,CAAC;AAC9B,OAAO,EAGL,uBAAuB,EACxB,MAAM,qBAAqB,CAAC;AAC7B,OAAO,EAAE,mBAAmB,EAAE,MAAM,SAAS,CAAC;AAgC9C;;;;GAIG;AACH,wBAAgB,oBAAoB,CAClC,eAAe,EAAE,UAAU,EAC3B,MAAM,EAAE,kBAAkB,EAAE,GAC3B,yBAAyB,CAiD3B;AAED;;;GAGG;AACH,wBAAsB,qBAAqB,CACzC,EAAE,EAAE,mBAAmB,EACvB,gBAAgB,EAAE,MAAM,GACvB,OAAO,CAAC,mBAAmB,CAAC,CAyB9B;AA2QD,wBAAgB,yBAAyB,CACvC,GAAG,EAAE,yBAAyB,GAC7B,mBAAmB,CAWrB;AAED;;;;;;GAMG;AACH,wBAAsB,sBAAsB,CAC1C,EAAE,EAAE,mBAAmB,EACvB,eAAe,EAAE,MAAM,EACvB,MAAM,EAAE,UAAU,GACjB,OAAO,CAAC,yBAAyB,CAAC,CA8CpC;AAyBD,wBAAsB,oBAAoB,CACxC,EAAE,EAAE,mBAAmB,EACvB,iBAAiB,EAAE,MAAM,EACzB,QAAQ,UAAQ,GACf,OAAO,CAAC,IAAI,CAAC,CAOf;AAsED,wBAAsB,yBAAyB,CAC7C,EAAE,EAAE,mBAAmB,EACvB,OAAO,EAAE,MAAM,EACf,MAAM,EAAE,UAAU,GACjB,OAAO,CAAC,uBAAuB,CAAC,CA8FlC;AAED,wBAAsB,0BAA0B,CAC9C,EAAE,EAAE,mBAAmB,EACvB,gBAAgB,EAAE,MAAM,GACvB,OAAO,CAAC,uBAAuB,CAAC,CA2ClC"} \ No newline at end of file
diff --git a/packages/taler-wallet-core/src/operations/withdraw.ts b/packages/taler-wallet-core/src/operations/withdraw.ts
new file mode 100644
index 000000000..3b0aa0095
--- /dev/null
+++ b/packages/taler-wallet-core/src/operations/withdraw.ts
@@ -0,0 +1,759 @@
+/*
+ This file is part of GNU Taler
+ (C) 2019-2020 Taler Systems SA
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+import { AmountJson, Amounts } from "../util/amounts";
+import {
+ DenominationRecord,
+ Stores,
+ DenominationStatus,
+ CoinStatus,
+ CoinRecord,
+ initRetryInfo,
+ updateRetryInfoTimeout,
+ CoinSourceType,
+ DenominationSelectionInfo,
+ PlanchetRecord,
+ WithdrawalSourceType,
+ DenomSelectionState,
+} from "../types/dbTypes";
+import {
+ BankWithdrawDetails,
+ ExchangeWithdrawDetails,
+ OperationErrorDetails,
+ ExchangeListItem,
+} from "../types/walletTypes";
+import {
+ codecForWithdrawOperationStatusResponse,
+ codecForWithdrawResponse,
+ WithdrawUriInfoResponse,
+} from "../types/talerTypes";
+import { InternalWalletState } from "./state";
+import { parseWithdrawUri } from "../util/taleruri";
+import { Logger } from "../util/logging";
+import { updateExchangeFromUrl, getExchangeTrust } from "./exchanges";
+import { WALLET_EXCHANGE_PROTOCOL_VERSION } from "./versions";
+
+import * as LibtoolVersion from "../util/libtoolVersion";
+import { guardOperationException } from "./errors";
+import { NotificationType } from "../types/notifications";
+import {
+ getTimestampNow,
+ getDurationRemaining,
+ timestampCmp,
+ timestampSubtractDuraction,
+} from "../util/time";
+import { readSuccessResponseJsonOrThrow } from "../util/http";
+import { URL } from "../util/url";
+
+const logger = new Logger("withdraw.ts");
+
+function isWithdrawableDenom(d: DenominationRecord): boolean {
+ const now = getTimestampNow();
+ const started = timestampCmp(now, d.stampStart) >= 0;
+ const lastPossibleWithdraw = timestampSubtractDuraction(
+ d.stampExpireWithdraw,
+ { d_ms: 50 * 1000 },
+ );
+ const remaining = getDurationRemaining(lastPossibleWithdraw, now);
+ const stillOkay = remaining.d_ms !== 0;
+ return started && stillOkay && !d.isRevoked;
+}
+
+/**
+ * Get a list of denominations (with repetitions possible)
+ * whose total value is as close as possible to the available
+ * amount, but never larger.
+ */
+export function getWithdrawDenomList(
+ amountAvailable: AmountJson,
+ denoms: DenominationRecord[],
+): DenominationSelectionInfo {
+ let remaining = Amounts.copy(amountAvailable);
+
+ const selectedDenoms: {
+ count: number;
+ denom: DenominationRecord;
+ }[] = [];
+
+ let totalCoinValue = Amounts.getZero(amountAvailable.currency);
+ let totalWithdrawCost = Amounts.getZero(amountAvailable.currency);
+
+ denoms = denoms.filter(isWithdrawableDenom);
+ denoms.sort((d1, d2) => Amounts.cmp(d2.value, d1.value));
+
+ for (const d of denoms) {
+ let count = 0;
+ const cost = Amounts.add(d.value, d.feeWithdraw).amount;
+ for (;;) {
+ if (Amounts.cmp(remaining, cost) < 0) {
+ break;
+ }
+ remaining = Amounts.sub(remaining, cost).amount;
+ count++;
+ }
+ if (count > 0) {
+ totalCoinValue = Amounts.add(
+ totalCoinValue,
+ Amounts.mult(d.value, count).amount,
+ ).amount;
+ totalWithdrawCost = Amounts.add(
+ totalWithdrawCost,
+ Amounts.mult(cost, count).amount,
+ ).amount;
+ selectedDenoms.push({
+ count,
+ denom: d,
+ });
+ }
+
+ if (Amounts.isZero(remaining)) {
+ break;
+ }
+ }
+
+ return {
+ selectedDenoms,
+ totalCoinValue,
+ totalWithdrawCost,
+ };
+}
+
+/**
+ * Get information about a withdrawal from
+ * a taler://withdraw URI by asking the bank.
+ */
+export async function getBankWithdrawalInfo(
+ ws: InternalWalletState,
+ talerWithdrawUri: string,
+): Promise<BankWithdrawDetails> {
+ const uriResult = parseWithdrawUri(talerWithdrawUri);
+ if (!uriResult) {
+ throw Error(`can't parse URL ${talerWithdrawUri}`);
+ }
+ const reqUrl = new URL(
+ `api/withdraw-operation/${uriResult.withdrawalOperationId}`,
+ uriResult.bankIntegrationApiBaseUrl,
+ );
+ const resp = await ws.http.get(reqUrl.href);
+ const status = await readSuccessResponseJsonOrThrow(
+ resp,
+ codecForWithdrawOperationStatusResponse(),
+ );
+
+ return {
+ amount: Amounts.parseOrThrow(status.amount),
+ confirmTransferUrl: status.confirm_transfer_url,
+ extractedStatusUrl: reqUrl.href,
+ selectionDone: status.selection_done,
+ senderWire: status.sender_wire,
+ suggestedExchange: status.suggested_exchange,
+ transferDone: status.transfer_done,
+ wireTypes: status.wire_types,
+ };
+}
+
+/**
+ * Return denominations that can potentially used for a withdrawal.
+ */
+async function getPossibleDenoms(
+ ws: InternalWalletState,
+ exchangeBaseUrl: string,
+): Promise<DenominationRecord[]> {
+ return await ws.db
+ .iterIndex(Stores.denominations.exchangeBaseUrlIndex, exchangeBaseUrl)
+ .filter((d) => {
+ return (
+ (d.status === DenominationStatus.Unverified ||
+ d.status === DenominationStatus.VerifiedGood) &&
+ !d.isRevoked
+ );
+ });
+}
+
+/**
+ * Given a planchet, withdraw a coin from the exchange.
+ */
+async function processPlanchet(
+ ws: InternalWalletState,
+ withdrawalGroupId: string,
+ coinIdx: number,
+): Promise<void> {
+ const withdrawalGroup = await ws.db.get(
+ Stores.withdrawalGroups,
+ withdrawalGroupId,
+ );
+ if (!withdrawalGroup) {
+ return;
+ }
+ let planchet = await ws.db.getIndexed(Stores.planchets.byGroupAndIndex, [
+ withdrawalGroupId,
+ coinIdx,
+ ]);
+ if (!planchet) {
+ let ci = 0;
+ let denomPubHash: string | undefined;
+ for (
+ let di = 0;
+ di < withdrawalGroup.denomsSel.selectedDenoms.length;
+ di++
+ ) {
+ const d = withdrawalGroup.denomsSel.selectedDenoms[di];
+ if (coinIdx >= ci && coinIdx < ci + d.count) {
+ denomPubHash = d.denomPubHash;
+ break;
+ }
+ ci += d.count;
+ }
+ if (!denomPubHash) {
+ throw Error("invariant violated");
+ }
+ const denom = await ws.db.getIndexed(
+ Stores.denominations.denomPubHashIndex,
+ denomPubHash,
+ );
+ if (!denom) {
+ throw Error("invariant violated");
+ }
+ if (withdrawalGroup.source.type != WithdrawalSourceType.Reserve) {
+ throw Error("invariant violated");
+ }
+ const reserve = await ws.db.get(
+ Stores.reserves,
+ withdrawalGroup.source.reservePub,
+ );
+ if (!reserve) {
+ throw Error("invariant violated");
+ }
+ const r = await ws.cryptoApi.createPlanchet({
+ denomPub: denom.denomPub,
+ feeWithdraw: denom.feeWithdraw,
+ reservePriv: reserve.reservePriv,
+ reservePub: reserve.reservePub,
+ value: denom.value,
+ });
+ const newPlanchet: PlanchetRecord = {
+ blindingKey: r.blindingKey,
+ coinEv: r.coinEv,
+ coinEvHash: r.coinEvHash,
+ coinIdx,
+ coinPriv: r.coinPriv,
+ coinPub: r.coinPub,
+ coinValue: r.coinValue,
+ denomPub: r.denomPub,
+ denomPubHash: r.denomPubHash,
+ isFromTip: false,
+ reservePub: r.reservePub,
+ withdrawalDone: false,
+ withdrawSig: r.withdrawSig,
+ withdrawalGroupId: withdrawalGroupId,
+ };
+ await ws.db.runWithWriteTransaction([Stores.planchets], async (tx) => {
+ const p = await tx.getIndexed(Stores.planchets.byGroupAndIndex, [
+ withdrawalGroupId,
+ coinIdx,
+ ]);
+ if (p) {
+ planchet = p;
+ return;
+ }
+ await tx.put(Stores.planchets, newPlanchet);
+ planchet = newPlanchet;
+ });
+ }
+ if (!planchet) {
+ throw Error("invariant violated");
+ }
+ if (planchet.withdrawalDone) {
+ logger.warn("processPlanchet: planchet already withdrawn");
+ return;
+ }
+ const exchange = await ws.db.get(
+ Stores.exchanges,
+ withdrawalGroup.exchangeBaseUrl,
+ );
+ if (!exchange) {
+ logger.error("db inconsistent: exchange for planchet not found");
+ return;
+ }
+
+ const denom = await ws.db.get(Stores.denominations, [
+ withdrawalGroup.exchangeBaseUrl,
+ planchet.denomPub,
+ ]);
+
+ if (!denom) {
+ console.error("db inconsistent: denom for planchet not found");
+ return;
+ }
+
+ logger.trace(
+ `processing planchet #${coinIdx} in withdrawal ${withdrawalGroupId}`,
+ );
+
+ const wd: any = {};
+ wd.denom_pub_hash = planchet.denomPubHash;
+ wd.reserve_pub = planchet.reservePub;
+ wd.reserve_sig = planchet.withdrawSig;
+ wd.coin_ev = planchet.coinEv;
+ const reqUrl = new URL(
+ `reserves/${planchet.reservePub}/withdraw`,
+ exchange.baseUrl,
+ ).href;
+
+ const resp = await ws.http.postJson(reqUrl, wd);
+ const r = await readSuccessResponseJsonOrThrow(
+ resp,
+ codecForWithdrawResponse(),
+ );
+
+ logger.trace(`got response for /withdraw`);
+
+ const denomSig = await ws.cryptoApi.rsaUnblind(
+ r.ev_sig,
+ planchet.blindingKey,
+ planchet.denomPub,
+ );
+
+ const isValid = await ws.cryptoApi.rsaVerify(
+ planchet.coinPub,
+ denomSig,
+ planchet.denomPub,
+ );
+
+ if (!isValid) {
+ throw Error("invalid RSA signature by the exchange");
+ }
+
+ logger.trace(`unblinded and verified`);
+
+ const coin: CoinRecord = {
+ blindingKey: planchet.blindingKey,
+ coinPriv: planchet.coinPriv,
+ coinPub: planchet.coinPub,
+ currentAmount: planchet.coinValue,
+ denomPub: planchet.denomPub,
+ denomPubHash: planchet.denomPubHash,
+ denomSig,
+ exchangeBaseUrl: withdrawalGroup.exchangeBaseUrl,
+ status: CoinStatus.Fresh,
+ coinSource: {
+ type: CoinSourceType.Withdraw,
+ coinIndex: coinIdx,
+ reservePub: planchet.reservePub,
+ withdrawalGroupId: withdrawalGroupId,
+ },
+ suspended: false,
+ };
+
+ let withdrawalGroupFinished = false;
+
+ const planchetCoinPub = planchet.coinPub;
+
+ const success = await ws.db.runWithWriteTransaction(
+ [Stores.coins, Stores.withdrawalGroups, Stores.reserves, Stores.planchets],
+ async (tx) => {
+ const ws = await tx.get(Stores.withdrawalGroups, withdrawalGroupId);
+ if (!ws) {
+ return false;
+ }
+ const p = await tx.get(Stores.planchets, planchetCoinPub);
+ if (!p) {
+ return false;
+ }
+ if (p.withdrawalDone) {
+ // Already withdrawn
+ return false;
+ }
+ p.withdrawalDone = true;
+ await tx.put(Stores.planchets, p);
+
+ let numTotal = 0;
+
+ for (const ds of ws.denomsSel.selectedDenoms) {
+ numTotal += ds.count;
+ }
+
+ let numDone = 0;
+
+ await tx
+ .iterIndexed(Stores.planchets.byGroup, withdrawalGroupId)
+ .forEach((x) => {
+ if (x.withdrawalDone) {
+ numDone++;
+ }
+ });
+
+ if (numDone > numTotal) {
+ throw Error(
+ "invariant violated (created more planchets than expected)",
+ );
+ }
+
+ if (numDone == numTotal) {
+ ws.timestampFinish = getTimestampNow();
+ ws.lastError = undefined;
+ ws.retryInfo = initRetryInfo(false);
+ withdrawalGroupFinished = true;
+ }
+ await tx.put(Stores.withdrawalGroups, ws);
+ await tx.add(Stores.coins, coin);
+ return true;
+ },
+ );
+
+ logger.trace(`withdrawal result stored in DB`);
+
+ if (success) {
+ ws.notify({
+ type: NotificationType.CoinWithdrawn,
+ });
+ }
+
+ if (withdrawalGroupFinished) {
+ ws.notify({
+ type: NotificationType.WithdrawGroupFinished,
+ withdrawalSource: withdrawalGroup.source,
+ });
+ }
+}
+
+export function denomSelectionInfoToState(
+ dsi: DenominationSelectionInfo,
+): DenomSelectionState {
+ return {
+ selectedDenoms: dsi.selectedDenoms.map((x) => {
+ return {
+ count: x.count,
+ denomPubHash: x.denom.denomPubHash,
+ };
+ }),
+ totalCoinValue: dsi.totalCoinValue,
+ totalWithdrawCost: dsi.totalWithdrawCost,
+ };
+}
+
+/**
+ * Get a list of denominations to withdraw from the given exchange for the
+ * given amount, making sure that all denominations' signatures are verified.
+ *
+ * Writes to the DB in order to record the result from verifying
+ * denominations.
+ */
+export async function selectWithdrawalDenoms(
+ ws: InternalWalletState,
+ exchangeBaseUrl: string,
+ amount: AmountJson,
+): Promise<DenominationSelectionInfo> {
+ const exchange = await ws.db.get(Stores.exchanges, exchangeBaseUrl);
+ if (!exchange) {
+ logger.error("exchange not found");
+ throw Error(`exchange ${exchangeBaseUrl} not found`);
+ }
+ const exchangeDetails = exchange.details;
+ if (!exchangeDetails) {
+ logger.error("exchange details not available");
+ throw Error(`exchange ${exchangeBaseUrl} details not available`);
+ }
+
+ let allValid = false;
+ let selectedDenoms: DenominationSelectionInfo;
+
+ // Find a denomination selection for the requested amount.
+ // If a selected denomination has not been validated yet
+ // and turns our to be invalid, we try again with the
+ // reduced set of denominations.
+ do {
+ allValid = true;
+ const nextPossibleDenoms = await getPossibleDenoms(ws, exchange.baseUrl);
+ selectedDenoms = getWithdrawDenomList(amount, nextPossibleDenoms);
+ for (const denomSel of selectedDenoms.selectedDenoms) {
+ const denom = denomSel.denom;
+ if (denom.status === DenominationStatus.Unverified) {
+ const valid = await ws.cryptoApi.isValidDenom(
+ denom,
+ exchangeDetails.masterPublicKey,
+ );
+ if (!valid) {
+ denom.status = DenominationStatus.VerifiedBad;
+ allValid = false;
+ } else {
+ denom.status = DenominationStatus.VerifiedGood;
+ }
+ await ws.db.put(Stores.denominations, denom);
+ }
+ }
+ } while (selectedDenoms.selectedDenoms.length > 0 && !allValid);
+
+ if (Amounts.cmp(selectedDenoms.totalWithdrawCost, amount) > 0) {
+ throw Error("Bug: withdrawal coin selection is wrong");
+ }
+
+ return selectedDenoms;
+}
+
+async function incrementWithdrawalRetry(
+ ws: InternalWalletState,
+ withdrawalGroupId: string,
+ err: OperationErrorDetails | undefined,
+): Promise<void> {
+ await ws.db.runWithWriteTransaction([Stores.withdrawalGroups], async (tx) => {
+ const wsr = await tx.get(Stores.withdrawalGroups, withdrawalGroupId);
+ if (!wsr) {
+ return;
+ }
+ if (!wsr.retryInfo) {
+ return;
+ }
+ wsr.retryInfo.retryCounter++;
+ updateRetryInfoTimeout(wsr.retryInfo);
+ wsr.lastError = err;
+ await tx.put(Stores.withdrawalGroups, wsr);
+ });
+ if (err) {
+ ws.notify({ type: NotificationType.WithdrawOperationError, error: err });
+ }
+}
+
+export async function processWithdrawGroup(
+ ws: InternalWalletState,
+ withdrawalGroupId: string,
+ forceNow = false,
+): Promise<void> {
+ const onOpErr = (e: OperationErrorDetails): Promise<void> =>
+ incrementWithdrawalRetry(ws, withdrawalGroupId, e);
+ await guardOperationException(
+ () => processWithdrawGroupImpl(ws, withdrawalGroupId, forceNow),
+ onOpErr,
+ );
+}
+
+async function resetWithdrawalGroupRetry(
+ ws: InternalWalletState,
+ withdrawalGroupId: string,
+): Promise<void> {
+ await ws.db.mutate(Stores.withdrawalGroups, withdrawalGroupId, (x) => {
+ if (x.retryInfo.active) {
+ x.retryInfo = initRetryInfo();
+ }
+ return x;
+ });
+}
+
+async function processInBatches(
+ workGen: Iterator<Promise<void>>,
+ batchSize: number,
+): Promise<void> {
+ for (;;) {
+ const batch: Promise<void>[] = [];
+ for (let i = 0; i < batchSize; i++) {
+ const wn = workGen.next();
+ if (wn.done) {
+ break;
+ }
+ batch.push(wn.value);
+ }
+ if (batch.length == 0) {
+ break;
+ }
+ logger.trace(`processing withdrawal batch of ${batch.length} elements`);
+ await Promise.all(batch);
+ }
+}
+
+async function processWithdrawGroupImpl(
+ ws: InternalWalletState,
+ withdrawalGroupId: string,
+ forceNow: boolean,
+): Promise<void> {
+ logger.trace("processing withdraw group", withdrawalGroupId);
+ if (forceNow) {
+ await resetWithdrawalGroupRetry(ws, withdrawalGroupId);
+ }
+ const withdrawalGroup = await ws.db.get(
+ Stores.withdrawalGroups,
+ withdrawalGroupId,
+ );
+ if (!withdrawalGroup) {
+ logger.trace("withdraw session doesn't exist");
+ return;
+ }
+
+ const numDenoms = withdrawalGroup.denomsSel.selectedDenoms.length;
+ const genWork = function* (): Iterator<Promise<void>> {
+ let coinIdx = 0;
+ for (let i = 0; i < numDenoms; i++) {
+ const count = withdrawalGroup.denomsSel.selectedDenoms[i].count;
+ for (let j = 0; j < count; j++) {
+ yield processPlanchet(ws, withdrawalGroupId, coinIdx);
+ coinIdx++;
+ }
+ }
+ };
+
+ // Withdraw coins in batches.
+ // The batch size is relatively large
+ await processInBatches(genWork(), 10);
+}
+
+export async function getExchangeWithdrawalInfo(
+ ws: InternalWalletState,
+ baseUrl: string,
+ amount: AmountJson,
+): Promise<ExchangeWithdrawDetails> {
+ const exchangeInfo = await updateExchangeFromUrl(ws, baseUrl);
+ const exchangeDetails = exchangeInfo.details;
+ if (!exchangeDetails) {
+ throw Error(`exchange ${exchangeInfo.baseUrl} details not available`);
+ }
+ const exchangeWireInfo = exchangeInfo.wireInfo;
+ if (!exchangeWireInfo) {
+ throw Error(`exchange ${exchangeInfo.baseUrl} wire details not available`);
+ }
+
+ const selectedDenoms = await selectWithdrawalDenoms(ws, baseUrl, amount);
+ const exchangeWireAccounts: string[] = [];
+ for (const account of exchangeWireInfo.accounts) {
+ exchangeWireAccounts.push(account.payto_uri);
+ }
+
+ const { isTrusted, isAudited } = await getExchangeTrust(ws, exchangeInfo);
+
+ let earliestDepositExpiration =
+ selectedDenoms.selectedDenoms[0].denom.stampExpireDeposit;
+ for (let i = 1; i < selectedDenoms.selectedDenoms.length; i++) {
+ const expireDeposit =
+ selectedDenoms.selectedDenoms[i].denom.stampExpireDeposit;
+ if (expireDeposit.t_ms < earliestDepositExpiration.t_ms) {
+ earliestDepositExpiration = expireDeposit;
+ }
+ }
+
+ const possibleDenoms = await ws.db
+ .iterIndex(Stores.denominations.exchangeBaseUrlIndex, baseUrl)
+ .filter((d) => d.isOffered);
+
+ const trustedAuditorPubs = [];
+ const currencyRecord = await ws.db.get(Stores.currencies, amount.currency);
+ if (currencyRecord) {
+ trustedAuditorPubs.push(
+ ...currencyRecord.auditors.map((a) => a.auditorPub),
+ );
+ }
+
+ let versionMatch;
+ if (exchangeDetails.protocolVersion) {
+ versionMatch = LibtoolVersion.compare(
+ WALLET_EXCHANGE_PROTOCOL_VERSION,
+ exchangeDetails.protocolVersion,
+ );
+
+ if (
+ versionMatch &&
+ !versionMatch.compatible &&
+ versionMatch.currentCmp === -1
+ ) {
+ console.warn(
+ `wallet's support for exchange protocol version ${WALLET_EXCHANGE_PROTOCOL_VERSION} might be outdated ` +
+ `(exchange has ${exchangeDetails.protocolVersion}), checking for updates`,
+ );
+ }
+ }
+
+ let tosAccepted = false;
+
+ if (exchangeInfo.termsOfServiceAcceptedTimestamp) {
+ if (
+ exchangeInfo.termsOfServiceAcceptedEtag ==
+ exchangeInfo.termsOfServiceLastEtag
+ ) {
+ tosAccepted = true;
+ }
+ }
+
+ const withdrawFee = Amounts.sub(
+ selectedDenoms.totalWithdrawCost,
+ selectedDenoms.totalCoinValue,
+ ).amount;
+
+ const ret: ExchangeWithdrawDetails = {
+ earliestDepositExpiration,
+ exchangeInfo,
+ exchangeWireAccounts,
+ exchangeVersion: exchangeDetails.protocolVersion || "unknown",
+ isAudited,
+ isTrusted,
+ numOfferedDenoms: possibleDenoms.length,
+ overhead: Amounts.sub(amount, selectedDenoms.totalWithdrawCost).amount,
+ selectedDenoms,
+ trustedAuditorPubs,
+ versionMatch,
+ walletVersion: WALLET_EXCHANGE_PROTOCOL_VERSION,
+ wireFees: exchangeWireInfo,
+ withdrawFee,
+ termsOfServiceAccepted: tosAccepted,
+ };
+ return ret;
+}
+
+export async function getWithdrawalDetailsForUri(
+ ws: InternalWalletState,
+ talerWithdrawUri: string,
+): Promise<WithdrawUriInfoResponse> {
+ const info = await getBankWithdrawalInfo(ws, talerWithdrawUri);
+ if (info.suggestedExchange) {
+ // FIXME: right now the exchange gets permanently added,
+ // we might want to only temporarily add it.
+ try {
+ await updateExchangeFromUrl(ws, info.suggestedExchange);
+ } catch (e) {
+ // We still continued if it failed, as other exchanges might be available.
+ // We don't want to fail if the bank-suggested exchange is broken/offline.
+ logger.trace(
+ `querying bank-suggested exchange (${info.suggestedExchange}) failed`,
+ );
+ }
+ }
+
+ const exchangesRes: (ExchangeListItem | undefined)[] = await ws.db
+ .iter(Stores.exchanges)
+ .map((x) => {
+ const details = x.details;
+ if (!details) {
+ return undefined;
+ }
+ if (!x.addComplete) {
+ return undefined;
+ }
+ if (!x.wireInfo) {
+ return undefined;
+ }
+ if (details.currency !== info.amount.currency) {
+ return undefined;
+ }
+ return {
+ exchangeBaseUrl: x.baseUrl,
+ currency: details.currency,
+ paytoUris: x.wireInfo.accounts.map((x) => x.payto_uri),
+ };
+ });
+ const exchanges = exchangesRes.filter((x) => !!x) as ExchangeListItem[];
+
+ return {
+ amount: Amounts.stringify(info.amount),
+ defaultExchangeBaseUrl: info.suggestedExchange,
+ possibleExchanges: exchanges,
+ };
+}