From ffd2a62c3f7df94365980302fef3bc3376b48182 Mon Sep 17 00:00:00 2001 From: Florian Dold Date: Mon, 3 Aug 2020 13:00:48 +0530 Subject: modularize repo, use pnpm, improve typechecking --- .../src/operations/balance.d.ts.map | 1 + .../taler-wallet-core/src/operations/balance.ts | 153 +++ .../src/operations/errors.d.ts.map | 1 + .../taler-wallet-core/src/operations/errors.ts | 121 +++ .../src/operations/exchanges.d.ts.map | 1 + .../taler-wallet-core/src/operations/exchanges.ts | 555 ++++++++++ .../taler-wallet-core/src/operations/pay.d.ts.map | 1 + packages/taler-wallet-core/src/operations/pay.ts | 1148 ++++++++++++++++++++ .../src/operations/pending.d.ts.map | 1 + .../taler-wallet-core/src/operations/pending.ts | 458 ++++++++ .../src/operations/recoup.d.ts.map | 1 + .../taler-wallet-core/src/operations/recoup.ts | 412 +++++++ .../src/operations/refresh.d.ts.map | 1 + .../taler-wallet-core/src/operations/refresh.ts | 573 ++++++++++ .../src/operations/refund.d.ts.map | 1 + .../taler-wallet-core/src/operations/refund.ts | 438 ++++++++ .../src/operations/reserves.d.ts.map | 1 + .../taler-wallet-core/src/operations/reserves.ts | 841 ++++++++++++++ .../src/operations/state.d.ts.map | 1 + packages/taler-wallet-core/src/operations/state.ts | 65 ++ .../src/operations/testing.d.ts.map | 1 + .../taler-wallet-core/src/operations/testing.ts | 156 +++ .../taler-wallet-core/src/operations/tip.d.ts.map | 1 + packages/taler-wallet-core/src/operations/tip.ts | 343 ++++++ .../src/operations/transactions.d.ts.map | 1 + .../src/operations/transactions.ts | 288 +++++ .../src/operations/versions.d.ts.map | 1 + .../taler-wallet-core/src/operations/versions.ts | 38 + .../src/operations/withdraw-test.ts | 332 ++++++ .../src/operations/withdraw.d.ts.map | 1 + .../taler-wallet-core/src/operations/withdraw.ts | 759 +++++++++++++ 31 files changed, 6695 insertions(+) create mode 100644 packages/taler-wallet-core/src/operations/balance.d.ts.map create mode 100644 packages/taler-wallet-core/src/operations/balance.ts create mode 100644 packages/taler-wallet-core/src/operations/errors.d.ts.map create mode 100644 packages/taler-wallet-core/src/operations/errors.ts create mode 100644 packages/taler-wallet-core/src/operations/exchanges.d.ts.map create mode 100644 packages/taler-wallet-core/src/operations/exchanges.ts create mode 100644 packages/taler-wallet-core/src/operations/pay.d.ts.map create mode 100644 packages/taler-wallet-core/src/operations/pay.ts create mode 100644 packages/taler-wallet-core/src/operations/pending.d.ts.map create mode 100644 packages/taler-wallet-core/src/operations/pending.ts create mode 100644 packages/taler-wallet-core/src/operations/recoup.d.ts.map create mode 100644 packages/taler-wallet-core/src/operations/recoup.ts create mode 100644 packages/taler-wallet-core/src/operations/refresh.d.ts.map create mode 100644 packages/taler-wallet-core/src/operations/refresh.ts create mode 100644 packages/taler-wallet-core/src/operations/refund.d.ts.map create mode 100644 packages/taler-wallet-core/src/operations/refund.ts create mode 100644 packages/taler-wallet-core/src/operations/reserves.d.ts.map create mode 100644 packages/taler-wallet-core/src/operations/reserves.ts create mode 100644 packages/taler-wallet-core/src/operations/state.d.ts.map create mode 100644 packages/taler-wallet-core/src/operations/state.ts create mode 100644 packages/taler-wallet-core/src/operations/testing.d.ts.map create mode 100644 packages/taler-wallet-core/src/operations/testing.ts create mode 100644 packages/taler-wallet-core/src/operations/tip.d.ts.map create mode 100644 packages/taler-wallet-core/src/operations/tip.ts create mode 100644 packages/taler-wallet-core/src/operations/transactions.d.ts.map create mode 100644 packages/taler-wallet-core/src/operations/transactions.ts create mode 100644 packages/taler-wallet-core/src/operations/versions.d.ts.map create mode 100644 packages/taler-wallet-core/src/operations/versions.ts create mode 100644 packages/taler-wallet-core/src/operations/withdraw-test.ts create mode 100644 packages/taler-wallet-core/src/operations/withdraw.d.ts.map create mode 100644 packages/taler-wallet-core/src/operations/withdraw.ts (limited to 'packages/taler-wallet-core/src/operations') 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 + */ + +/** + * 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 { + const balanceStore: Record = {}; + + /** + * 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 { + 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 + */ + +/** + * Classes and helpers for error handling specific to wallet operations. + * + * @author Florian Dold + */ + +/** + * 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, + ): 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, +): 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( + op: () => Promise, + onOpError: (e: OperationErrorDetails) => Promise, +): Promise { + 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 + */ + +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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + const onOpErr = (e: OperationErrorDetails): Promise => + 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 { + 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 { + // 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 + */ + +/** + * 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + const onOpErr = (err: OperationErrorDetails): Promise => + incrementProposalRetry(ws, proposalId, err); + await guardOperationException( + () => processDownloadProposalImpl(ws, proposalId, forceNow), + onOpErr, + ); +} + +async function resetDownloadProposalRetry( + ws: InternalWalletState, + proposalId: string, +): Promise { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + const onOpErr = (e: OperationErrorDetails): Promise => + incrementPurchasePayRetry(ws, proposalId, e); + await guardOperationException( + () => processPurchasePayImpl(ws, proposalId, forceNow), + onOpErr, + ); +} + +async function resetPurchasePayRetry( + ws: InternalWalletState, + proposalId: string, +): Promise { + 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 { + 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 { + 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 + */ + +/** + * 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 { + 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 { + // 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 + */ + +/** + * Implementation of the recoup operation, which allows to recover the + * value of coins held in a revoked denomination. + * + * @author Florian Dold + */ + +/** + * 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 { + 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 { + 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 { + // 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 { + 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 { + 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 { + 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 { + await ws.memoProcessRecoup.memo(recoupGroupId, async () => { + const onOpErr = (e: OperationErrorDetails): Promise => + incrementRecoupRetry(ws, recoupGroupId, e); + return await guardOperationException( + async () => await processRecoupGroupImpl(ws, recoupGroupId, forceNow), + onOpErr, + ); + }); +} + +async function processRecoupGroupImpl( + ws: InternalWalletState, + recoupGroupId: string, + forceNow = false, +): Promise { + 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 { + 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 { + 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 + */ + +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 { + 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 { + 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 { + 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 { + 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 { + await ws.memoProcessRefresh.memo(refreshGroupId, async () => { + const onOpErr = (e: OperationErrorDetails): Promise => + incrementRefreshRetry(ws, refreshGroupId, e); + return await guardOperationException( + async () => await processRefreshGroupImpl(ws, refreshGroupId, forceNow), + onOpErr, + ); + }); +} + +async function resetRefreshGroupRetry( + ws: InternalWalletState, + refreshSessionId: string, +): Promise { + 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 { + 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 { + 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 { + 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 => { + 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 + */ + +/** + * 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 { + 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, + r: MerchantCoinRefundSuccessStatus, +): Promise { + // 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 { + 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 { + 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 = {}; + + 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 { + 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 { + const onOpErr = (e: OperationErrorDetails): Promise => + incrementPurchaseQueryRefundRetry(ws, proposalId, e); + await guardOperationException( + () => processPurchaseQueryRefundImpl(ws, proposalId, forceNow), + onOpErr, + ); +} + +async function resetPurchaseQueryRefundRetry( + ws: InternalWalletState, + proposalId: string, +): Promise { + 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 { + 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 + */ + +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 { + 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 { + 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 { + 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 { + return ws.memoProcessReserve.memo(reservePub, async () => { + const onOpError = (err: OperationErrorDetails): Promise => + incrementReserveRetry(ws, reservePub, err); + await guardOperationException( + () => processReserveImpl(ws, reservePub, forceNow), + onOpError, + ); + }); +} + +async function registerReserveWithBank( + ws: InternalWalletState, + reservePub: string, +): Promise { + 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 { + const onOpError = (err: OperationErrorDetails): Promise => + incrementReserveRetry(ws, reservePub, err); + await guardOperationException( + () => processReserveBankStatusImpl(ws, reservePub), + onOpError, + ); +} + +async function processReserveBankStatusImpl( + ws: InternalWalletState, + reservePub: string, +): Promise { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 + */ + +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 = new AsyncOpMemoMap(); + memoMakePlanchet: AsyncOpMemoMap = new AsyncOpMemoMap(); + memoGetPending: AsyncOpMemoSingle< + PendingOperationsResponse + > = new AsyncOpMemoSingle(); + memoGetBalance: AsyncOpMemoSingle = new AsyncOpMemoSingle(); + memoProcessRefresh: AsyncOpMemoMap = new AsyncOpMemoMap(); + memoProcessRecoup: AsyncOpMemoMap = 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 + */ + +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 { + 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 { + 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 { + 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 { + 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 + */ + +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 { + 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 { + 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 { + const onOpErr = (e: OperationErrorDetails): Promise => + incrementTipRetry(ws, tipId, e); + await guardOperationException( + () => processTipImpl(ws, tipId, forceNow), + onOpErr, + ); +} + +async function resetTipRetry( + ws: InternalWalletState, + tipId: string, +): Promise { + 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 { + 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 { + 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 + */ + +/** + * 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 { + 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 + */ + +/** + * 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 + */ + +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 + */ + +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 { + 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 { + 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 { + 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 { + 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 { + 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 { + const onOpErr = (e: OperationErrorDetails): Promise => + incrementWithdrawalRetry(ws, withdrawalGroupId, e); + await guardOperationException( + () => processWithdrawGroupImpl(ws, withdrawalGroupId, forceNow), + onOpErr, + ); +} + +async function resetWithdrawalGroupRetry( + ws: InternalWalletState, + withdrawalGroupId: string, +): Promise { + await ws.db.mutate(Stores.withdrawalGroups, withdrawalGroupId, (x) => { + if (x.retryInfo.active) { + x.retryInfo = initRetryInfo(); + } + return x; + }); +} + +async function processInBatches( + workGen: Iterator>, + batchSize: number, +): Promise { + for (;;) { + const batch: Promise[] = []; + 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 { + 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> { + 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 { + 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 { + 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, + }; +} -- cgit v1.2.3