/* This file is part of GNU Taler (C) 2015-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 */ /** * High-level wallet operations that should be indepentent from the underlying * browser extension interface. */ /** * Imports. */ import { CryptoWorkerFactory } from "./crypto/workers/cryptoApi"; import { HttpRequestLibrary } from "./util/http"; import { Database } from "./util/query"; import { Amounts, AmountJson } from "./util/amounts"; import { getExchangeWithdrawalInfo, getWithdrawalDetailsForUri, } from "./operations/withdraw"; import { preparePayForUri, refuseProposal, confirmPay, processDownloadProposal, processPurchasePay, } from "./operations/pay"; import { CoinRecord, CurrencyRecord, DenominationRecord, ExchangeRecord, PurchaseRecord, ReserveRecord, Stores, ReserveRecordStatus, CoinSourceType, RefundState, } from "./types/dbTypes"; import { CoinDumpJson, WithdrawUriInfoResponse } from "./types/talerTypes"; import { BenchmarkResult, ConfirmPayResult, ReturnCoinsRequest, SenderWireInfos, TipStatus, WalletBalance, PreparePayResult, AcceptWithdrawalResponse, PurchaseDetails, RefreshReason, ExchangeListItem, ExchangesListRespose, ManualWithdrawalDetails, GetExchangeTosResult, AcceptManualWithdrawalResult, } from "./types/walletTypes"; import { Logger } from "./util/logging"; import { assertUnreachable } from "./util/assertUnreachable"; import { updateExchangeFromUrl, getExchangeTrust, getExchangePaytoUri, acceptExchangeTermsOfService, } from "./operations/exchanges"; import { processReserve, createTalerWithdrawReserve, forceQueryReserve, getFundingPaytoUris, } from "./operations/reserves"; import { InternalWalletState } from "./operations/state"; import { createReserve } from "./operations/reserves"; import { processRefreshGroup, createRefreshGroup } from "./operations/refresh"; import { processWithdrawGroup } from "./operations/withdraw"; import { getPendingOperations } from "./operations/pending"; import { getBalances } from "./operations/balance"; import { acceptTip, getTipStatus, processTip } from "./operations/tip"; import { TimerGroup } from "./util/timer"; import { AsyncCondition } from "./util/promiseUtils"; import { AsyncOpMemoSingle } from "./util/asyncMemo"; import { PendingOperationInfo, PendingOperationsResponse, PendingOperationType, } from "./types/pending"; import { WalletNotification, NotificationType } from "./types/notifications"; import { processPurchaseQueryRefund, applyRefund } from "./operations/refund"; import { durationMin, Duration } from "./util/time"; import { processRecoupGroup } from "./operations/recoup"; import { OperationFailedAndReportedError } from "./operations/errors"; import { TransactionsRequest, TransactionsResponse, } from "./types/transactions"; import { getTransactions } from "./operations/transactions"; const builtinCurrencies: CurrencyRecord[] = [ { auditors: [ { auditorPub: "BW9DC48PHQY4NH011SHHX36DZZ3Q22Y6X7FZ1VD1CMZ2PTFZ6PN0", baseUrl: "https://auditor.demo.taler.net/", expirationStamp: new Date(2027, 1).getTime(), }, ], exchanges: [], fractionalDigits: 2, name: "KUDOS", }, ]; const logger = new Logger("wallet.ts"); /** * The platform-independent wallet implementation. */ export class Wallet { private ws: InternalWalletState; private timerGroup: TimerGroup = new TimerGroup(); private latch = new AsyncCondition(); private stopped = false; private memoRunRetryLoop = new AsyncOpMemoSingle(); get db(): Database { return this.ws.db; } constructor( db: Database, http: HttpRequestLibrary, cryptoWorkerFactory: CryptoWorkerFactory, ) { this.ws = new InternalWalletState(db, http, cryptoWorkerFactory); } getExchangePaytoUri( exchangeBaseUrl: string, supportedTargetTypes: string[], ): Promise { return getExchangePaytoUri(this.ws, exchangeBaseUrl, supportedTargetTypes); } async getWithdrawalDetailsForAmount( exchangeBaseUrl: string, amount: AmountJson, ): Promise { const wi = await getExchangeWithdrawalInfo( this.ws, exchangeBaseUrl, amount, ); const paytoUris = wi.exchangeInfo.wireInfo?.accounts.map( (x) => x.payto_uri, ); if (!paytoUris) { throw Error("exchange is in invalid state"); } return { rawAmount: Amounts.stringify(amount), effectiveAmount: Amounts.stringify(wi.selectedDenoms.totalCoinValue), paytoUris, tosAccepted: wi.termsOfServiceAccepted, }; } addNotificationListener(f: (n: WalletNotification) => void): void { this.ws.addNotificationListener(f); } /** * Execute one operation based on the pending operation info record. */ async processOnePendingOperation( pending: PendingOperationInfo, forceNow = false, ): Promise { logger.trace(`running pending ${JSON.stringify(pending, undefined, 2)}`); switch (pending.type) { case PendingOperationType.Bug: // Nothing to do, will just be displayed to the user return; case PendingOperationType.ExchangeUpdate: await updateExchangeFromUrl(this.ws, pending.exchangeBaseUrl, forceNow); break; case PendingOperationType.Refresh: await processRefreshGroup(this.ws, pending.refreshGroupId, forceNow); break; case PendingOperationType.Reserve: await processReserve(this.ws, pending.reservePub, forceNow); break; case PendingOperationType.Withdraw: await processWithdrawGroup( this.ws, pending.withdrawalGroupId, forceNow, ); break; case PendingOperationType.ProposalChoice: // Nothing to do, user needs to accept/reject break; case PendingOperationType.ProposalDownload: await processDownloadProposal(this.ws, pending.proposalId, forceNow); break; case PendingOperationType.TipChoice: // Nothing to do, user needs to accept/reject break; case PendingOperationType.TipPickup: await processTip(this.ws, pending.tipId, forceNow); break; case PendingOperationType.Pay: await processPurchasePay(this.ws, pending.proposalId, forceNow); break; case PendingOperationType.RefundQuery: await processPurchaseQueryRefund(this.ws, pending.proposalId, forceNow); break; case PendingOperationType.Recoup: await processRecoupGroup(this.ws, pending.recoupGroupId, forceNow); break; default: assertUnreachable(pending); } } /** * Process pending operations. */ public async runPending(forceNow = false): Promise { const onlyDue = !forceNow; const pendingOpsResponse = await this.getPendingOperations({ onlyDue }); for (const p of pendingOpsResponse.pendingOperations) { try { await this.processOnePendingOperation(p, forceNow); } catch (e) { if (e instanceof OperationFailedAndReportedError) { console.error( "Operation failed:", JSON.stringify(e.operationError, undefined, 2), ); } else { console.error(e); } } } } /** * Run the wallet until there are no more pending operations that give * liveness left. The wallet will be in a stopped state when this function * returns without resolving to an exception. */ public async runUntilDone(): Promise { let done = false; const p = new Promise((resolve, reject) => { // Run this asynchronously this.addNotificationListener((n) => { if (done) { return; } if ( n.type === NotificationType.WaitingForRetry && n.numGivingLiveness == 0 ) { done = true; logger.trace("no liveness-giving operations left"); resolve(); } }); this.runRetryLoop().catch((e) => { console.log("exception in wallet retry loop"); reject(e); }); }); await p; } /** * Run the wallet until there are no more pending operations that give * liveness left. The wallet will be in a stopped state when this function * returns without resolving to an exception. */ public async runUntilDoneAndStop(): Promise { await this.runUntilDone(); logger.trace("stopping after liveness-giving operations done"); this.stop(); } /** * Process pending operations and wait for scheduled operations in * a loop until the wallet is stopped explicitly. */ public async runRetryLoop(): Promise { // Make sure we only run one main loop at a time. return this.memoRunRetryLoop.memo(async () => { try { await this.runRetryLoopImpl(); } catch (e) { console.error("error during retry loop execution", e); throw e; } }); } private async runRetryLoopImpl(): Promise { while (!this.stopped) { const pending = await this.getPendingOperations({ onlyDue: true }); if (pending.pendingOperations.length === 0) { const allPending = await this.getPendingOperations({ onlyDue: false }); let numPending = 0; let numGivingLiveness = 0; for (const p of allPending.pendingOperations) { numPending++; if (p.givesLifeness) { numGivingLiveness++; } } let dt: Duration; if ( allPending.pendingOperations.length === 0 || allPending.nextRetryDelay.d_ms === Number.MAX_SAFE_INTEGER ) { // Wait for 5 seconds dt = { d_ms: 5000 }; } else { dt = durationMin({ d_ms: 5000 }, allPending.nextRetryDelay); } const timeout = this.timerGroup.resolveAfter(dt); this.ws.notify({ type: NotificationType.WaitingForRetry, numGivingLiveness, numPending, }); await Promise.race([timeout, this.latch.wait()]); console.log("timeout done"); } else { // FIXME: maybe be a bit smarter about executing these // operations in parallel? for (const p of pending.pendingOperations) { try { await this.processOnePendingOperation(p); } catch (e) { if (e instanceof OperationFailedAndReportedError) { logger.warn("operation processed resulted in reported error"); } else { console.error("Uncaught exception", e); this.ws.notify({ type: NotificationType.InternalError, message: "uncaught exception", exception: e, }); } } this.ws.notify({ type: NotificationType.PendingOperationProcessed, }); } } } logger.trace("exiting wallet retry loop"); } /** * Insert the hard-coded defaults for exchanges, coins and * auditors into the database, unless these defaults have * already been applied. */ async fillDefaults(): Promise { await this.db.runWithWriteTransaction( [Stores.config, Stores.currencies], async (tx) => { let applied = false; await tx.iter(Stores.config).forEach((x) => { if (x.key == "currencyDefaultsApplied" && x.value == true) { applied = true; } }); if (!applied) { for (const c of builtinCurrencies) { await tx.put(Stores.currencies, c); } } }, ); } /** * 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. */ async preparePayForUri(talerPayUri: string): Promise { return preparePayForUri(this.ws, talerPayUri); } /** * Add a contract to the wallet and sign coins, and send them. */ async confirmPay( proposalId: string, sessionIdOverride: string | undefined, ): Promise { try { return await confirmPay(this.ws, proposalId, sessionIdOverride); } finally { this.latch.trigger(); } } /** * 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. */ async processReserve(reservePub: string): Promise { try { return await processReserve(this.ws, reservePub); } finally { this.latch.trigger(); } } /** * 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. */ async acceptManualWithdrawal( exchangeBaseUrl: string, amount: AmountJson, ): Promise { try { const resp = await createReserve(this.ws, { amount, exchange: exchangeBaseUrl, }); const exchangePaytoUris = await this.db.runWithReadTransaction( [Stores.exchanges, Stores.reserves], (tx) => getFundingPaytoUris(tx, resp.reservePub), ); return { reservePub: resp.reservePub, exchangePaytoUris, }; } finally { this.latch.trigger(); } } /** * Check if and how an exchange is trusted and/or audited. */ async getExchangeTrust( exchangeInfo: ExchangeRecord, ): Promise<{ isTrusted: boolean; isAudited: boolean }> { return getExchangeTrust(this.ws, exchangeInfo); } async getWithdrawalDetailsForUri(talerWithdrawUri: string): Promise { return getWithdrawalDetailsForUri(this.ws, talerWithdrawUri); } /** * 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 updateExchangeFromUrl( baseUrl: string, force = false, ): Promise { try { return updateExchangeFromUrl(this.ws, baseUrl, force); } finally { this.latch.trigger(); } } async getExchangeTos(exchangeBaseUrl: string): Promise { const exchange = await this.updateExchangeFromUrl(exchangeBaseUrl); const tos = exchange.termsOfServiceText; const currentEtag = exchange.termsOfServiceLastEtag; if (!tos || !currentEtag) { throw Error("exchange is in invalid state"); } return { acceptedEtag: exchange.termsOfServiceAcceptedEtag, currentEtag, tos, }; } /** * Get detailed balance information, sliced by exchange and by currency. */ async getBalances(): Promise { return this.ws.memoGetBalance.memo(() => getBalances(this.ws)); } async refresh(oldCoinPub: string): Promise { try { const refreshGroupId = await this.db.runWithWriteTransaction( [Stores.refreshGroups], async (tx) => { return await createRefreshGroup( this.ws, tx, [{ coinPub: oldCoinPub }], RefreshReason.Manual, ); }, ); await processRefreshGroup(this.ws, refreshGroupId.refreshGroupId); } catch (e) { this.latch.trigger(); } } async findExchange( exchangeBaseUrl: string, ): Promise { return await this.db.get(Stores.exchanges, exchangeBaseUrl); } async getPendingOperations({ onlyDue = false } = {}): Promise< PendingOperationsResponse > { return this.ws.memoGetPending.memo(() => getPendingOperations(this.ws, { onlyDue }), ); } async acceptExchangeTermsOfService( exchangeBaseUrl: string, etag: string | undefined, ): Promise { return acceptExchangeTermsOfService(this.ws, exchangeBaseUrl, etag); } async getDenoms(exchangeUrl: string): Promise { const denoms = await this.db .iterIndex(Stores.denominations.exchangeBaseUrlIndex, exchangeUrl) .toArray(); return denoms; } /** * Get all exchanges known to the exchange. * * @deprecated Use getExchanges instead */ async getExchangeRecords(): Promise { return await this.db.iter(Stores.exchanges).toArray(); } async getExchanges(): Promise { const exchanges: (ExchangeListItem | undefined)[] = await this.db .iter(Stores.exchanges) .map((x) => { const details = x.details; if (!details) { return undefined; } if (!x.addComplete) { return undefined; } if (!x.wireInfo) { return undefined; } return { exchangeBaseUrl: x.baseUrl, currency: details.currency, paytoUris: x.wireInfo.accounts.map((x) => x.payto_uri), }; }); return { exchanges: exchanges.filter((x) => !!x) as ExchangeListItem[], }; } async getCurrencies(): Promise { return await this.db.iter(Stores.currencies).toArray(); } async updateCurrency(currencyRecord: CurrencyRecord): Promise { logger.trace("updating currency to", currencyRecord); await this.db.put(Stores.currencies, currencyRecord); } async getReserves(exchangeBaseUrl?: string): Promise { if (exchangeBaseUrl) { return await this.db .iter(Stores.reserves) .filter((r) => r.exchangeBaseUrl === exchangeBaseUrl); } else { return await this.db.iter(Stores.reserves).toArray(); } } async getCoinsForExchange(exchangeBaseUrl: string): Promise { return await this.db .iter(Stores.coins) .filter((c) => c.exchangeBaseUrl === exchangeBaseUrl); } async getCoins(): Promise { return await this.db.iter(Stores.coins).toArray(); } /** * Stop ongoing processing. */ stop(): void { this.stopped = true; this.timerGroup.stopCurrentAndFutureTimers(); this.ws.cryptoApi.stop(); } async getSenderWireInfos(): Promise { const m: { [url: string]: Set } = {}; await this.db.iter(Stores.exchanges).forEach((x) => { const wi = x.wireInfo; if (!wi) { return; } const s = (m[x.baseUrl] = m[x.baseUrl] || new Set()); Object.keys(wi.feesForType).map((k) => s.add(k)); }); const exchangeWireTypes: { [url: string]: string[] } = {}; Object.keys(m).map((e) => { exchangeWireTypes[e] = Array.from(m[e]); }); const senderWiresSet: Set = new Set(); await this.db.iter(Stores.senderWires).forEach((x) => { senderWiresSet.add(x.paytoUri); }); const senderWires: string[] = Array.from(senderWiresSet); return { exchangeWireTypes, senderWires, }; } /** * Trigger paying coins back into the user's account. */ async returnCoins(req: ReturnCoinsRequest): Promise { throw Error("not implemented"); } /** * Accept a refund, return the contract hash for the contract * that was involved in the refund. */ async applyRefund( talerRefundUri: string, ): Promise<{ contractTermsHash: string; proposalId: string }> { return applyRefund(this.ws, talerRefundUri); } async getPurchase( contractTermsHash: string, ): Promise { return this.db.get(Stores.purchases, contractTermsHash); } async acceptTip(talerTipUri: string): Promise { try { return acceptTip(this.ws, talerTipUri); } catch (e) { this.latch.trigger(); } } async getTipStatus(talerTipUri: string): Promise { return getTipStatus(this.ws, talerTipUri); } async abortFailedPayment(contractTermsHash: string): Promise { throw Error("not implemented"); } /** * Inform the wallet that the status of a reserve has changed (e.g. due to a * confirmation from the bank.). */ public async handleNotifyReserve(): Promise { const reserves = await this.db.iter(Stores.reserves).toArray(); for (const r of reserves) { if (r.reserveStatus === ReserveRecordStatus.WAIT_CONFIRM_BANK) { try { this.processReserve(r.reservePub); } catch (e) { console.error(e); } } } } /** * Remove unreferenced / expired data from the wallet's database * based on the current system time. */ async collectGarbage(): Promise { // FIXME(#5845) // We currently do not garbage-collect the wallet database. This might change // after the feature has been properly re-designed, and we have come up with a // strategy to test it. } async acceptWithdrawal( talerWithdrawUri: string, selectedExchange: string, ): Promise { try { return createTalerWithdrawReserve( this.ws, talerWithdrawUri, selectedExchange, ); } finally { this.latch.trigger(); } } async updateReserve(reservePub: string): Promise { await forceQueryReserve(this.ws, reservePub); return await this.ws.db.get(Stores.reserves, reservePub); } async getReserve(reservePub: string): Promise { return await this.ws.db.get(Stores.reserves, reservePub); } async refuseProposal(proposalId: string): Promise { return refuseProposal(this.ws, proposalId); } async getPurchaseDetails(proposalId: string): Promise { const purchase = await this.db.get(Stores.purchases, proposalId); if (!purchase) { throw Error("unknown purchase"); } const refundsDoneAmounts = Object.values(purchase.refunds) .filter((x) => x.type === RefundState.Applied) .map((x) => x.refundAmount); const refundsPendingAmounts = Object.values(purchase.refunds) .filter((x) => x.type === RefundState.Pending) .map((x) => x.refundAmount); const totalRefundAmount = Amounts.sum([ ...refundsDoneAmounts, ...refundsPendingAmounts, ]).amount; const refundsDoneFees = Object.values(purchase.refunds) .filter((x) => x.type === RefundState.Applied) .map((x) => x.refundFee); const refundsPendingFees = Object.values(purchase.refunds) .filter((x) => x.type === RefundState.Pending) .map((x) => x.refundFee); const totalRefundFees = Amounts.sum([ ...refundsDoneFees, ...refundsPendingFees, ]).amount; const totalFees = totalRefundFees; return { contractTerms: purchase.contractTermsRaw, hasRefund: purchase.timestampLastRefundStatus !== undefined, totalRefundAmount: totalRefundAmount, totalRefundAndRefreshFees: totalFees, }; } benchmarkCrypto(repetitions: number): Promise { return this.ws.cryptoApi.benchmark(repetitions); } async setCoinSuspended(coinPub: string, suspended: boolean): Promise { await this.db.runWithWriteTransaction([Stores.coins], async (tx) => { const c = await tx.get(Stores.coins, coinPub); if (!c) { logger.warn(`coin ${coinPub} not found, won't suspend`); return; } c.suspended = suspended; await tx.put(Stores.coins, c); }); } /** * Dump the public information of coins we have in an easy-to-process format. */ async dumpCoins(): Promise { const coins = await this.db.iter(Stores.coins).toArray(); const coinsJson: CoinDumpJson = { coins: [] }; for (const c of coins) { const denom = await this.db.get(Stores.denominations, [ c.exchangeBaseUrl, c.denomPub, ]); if (!denom) { console.error("no denom session found for coin"); continue; } const cs = c.coinSource; let refreshParentCoinPub: string | undefined; if (cs.type == CoinSourceType.Refresh) { refreshParentCoinPub = cs.oldCoinPub; } let withdrawalReservePub: string | undefined; if (cs.type == CoinSourceType.Withdraw) { const ws = await this.db.get( Stores.withdrawalGroups, cs.withdrawalGroupId, ); if (!ws) { console.error("no withdrawal session found for coin"); continue; } if (ws.source.type == "reserve") { withdrawalReservePub = ws.source.reservePub; } } coinsJson.coins.push({ coin_pub: c.coinPub, denom_pub: c.denomPub, denom_pub_hash: c.denomPubHash, denom_value: Amounts.stringify(denom.value), exchange_base_url: c.exchangeBaseUrl, refresh_parent_coin_pub: refreshParentCoinPub, remaining_value: Amounts.stringify(c.currentAmount), withdrawal_reserve_pub: withdrawalReservePub, coin_suspended: c.suspended, }); } return coinsJson; } async getTransactions( request: TransactionsRequest, ): Promise { return getTransactions(this.ws, request); } }