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