/* 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 */ /** * Functions to compute the wallet's balance. * * There are multiple definition of the wallet's balance. * We use the following terminology: * * - "available": Balance that the wallet believes will certainly be available * for spending, modulo any failures of the exchange or double spending issues. * This includes available coins *not* allocated to any * spending/refresh/... operation. Pending withdrawals are *not* counted * towards this balance, because they are not certain to succeed. * Pending refreshes *are* counted towards this balance. * This balance type is nice to show to the user, because it does not * temporarily decrease after payment when we are waiting for refreshes * * - "material": Balance that the wallet believes it could spend *right now*, * without waiting for any operations to complete. * This balance type is important when showing "insufficient balance" error messages. * * - "age-acceptable": Subset of the material balance that can be spent * with age restrictions applied. * * - "merchant-acceptable": Subset of the material balance that can be spent with a particular * merchant (restricted via min age, exchange, auditor, wire_method). * * - "merchant-depositable": Subset of the merchant-acceptable balance that the merchant * can accept via their supported wire methods. */ /** * Imports. */ import { AmountJson, BalancesResponse, Amounts, Logger, AuditorHandle, ExchangeHandle, canonicalizeBaseUrl, parsePaytoUri, } from "@gnu-taler/taler-util"; import { AllowedAuditorInfo, AllowedExchangeInfo, RefreshGroupRecord, WalletStoresV1 } from "../db.js"; import { GetReadOnlyAccess } from "../util/query.js"; import { InternalWalletState } from "../internal-wallet-state.js"; import { getExchangeDetails } from "./exchanges.js"; import { checkLogicInvariant } from "../util/invariants.js"; /** * Logger. */ const logger = new Logger("operations/balance.ts"); interface WalletBalance { available: AmountJson; pendingIncoming: AmountJson; pendingOutgoing: AmountJson; } /** * Compute the available amount that the wallet expects to get * out of a refresh group. */ function computeRefreshGroupAvailableAmount(r: RefreshGroupRecord): AmountJson { // Don't count finished refreshes, since the refresh already resulted // in coins being added to the wallet. let available = Amounts.zeroOfCurrency(r.currency); if (r.timestampFinished) { return available; } for (let i = 0; i < r.oldCoinPubs.length; i++) { const session = r.refreshSessionPerCoin[i]; if (session) { // We are always assuming the refresh will succeed, thus we // report the output as available balance. available = Amounts.add(available, session.amountRefreshOutput).amount; } else { available = Amounts.add(available, r.estimatedOutputPerCoin[i]).amount; } } return available; } /** * Get balance information. */ export async function getBalancesInsideTransaction( ws: InternalWalletState, tx: GetReadOnlyAccess<{ coins: typeof WalletStoresV1.coins; coinAvailability: typeof WalletStoresV1.coinAvailability; refreshGroups: typeof WalletStoresV1.refreshGroups; withdrawalGroups: typeof WalletStoresV1.withdrawalGroups; }>, ): Promise { const balanceStore: Record = {}; /** * Add amount to a balance field, both for * the slicing by exchange and currency. */ const initBalance = (currency: string): WalletBalance => { const b = balanceStore[currency]; if (!b) { balanceStore[currency] = { available: Amounts.zeroOfCurrency(currency), pendingIncoming: Amounts.zeroOfCurrency(currency), pendingOutgoing: Amounts.zeroOfCurrency(currency), }; } return balanceStore[currency]; }; await tx.coinAvailability.iter().forEach((ca) => { const b = initBalance(ca.currency); for (let i = 0; i < ca.freshCoinCount; i++) { b.available = Amounts.add(b.available, { currency: ca.currency, fraction: ca.amountFrac, value: ca.amountVal, }).amount; } }); await tx.refreshGroups.iter().forEach((r) => { const b = initBalance(r.currency); b.available = Amounts.add( b.available, computeRefreshGroupAvailableAmount(r), ).amount; }); await tx.withdrawalGroups.iter().forEach((wds) => { if (wds.timestampFinish) { return; } const b = initBalance(Amounts.currencyOf(wds.denomsSel.totalWithdrawCost)); b.pendingIncoming = Amounts.add( b.pendingIncoming, wds.denomsSel.totalCoinValue, ).amount; }); const balancesResponse: BalancesResponse = { balances: [], }; Object.keys(balanceStore) .sort() .forEach((c) => { const v = balanceStore[c]; balancesResponse.balances.push({ available: Amounts.stringify(v.available), pendingIncoming: Amounts.stringify(v.pendingIncoming), pendingOutgoing: Amounts.stringify(v.pendingOutgoing), hasPendingTransactions: false, requiresUserInput: false, }); }); return balancesResponse; } /** * Get detailed balance information, sliced by exchange and by currency. */ export async function getBalances( ws: InternalWalletState, ): Promise { logger.trace("starting to compute balance"); const wbal = await ws.db .mktx((x) => [ x.coins, x.coinAvailability, x.refreshGroups, x.purchases, x.withdrawalGroups, ]) .runReadOnly(async (tx) => { return getBalancesInsideTransaction(ws, tx); }); logger.trace("finished computing wallet balance"); return wbal; } /** * Information about the balance for a particular payment to a particular * merchant. */ export interface MerchantPaymentBalanceDetails { balanceAvailable: AmountJson; } export interface MerchantPaymentRestrictionsForBalance { currency: string; minAge: number; acceptedExchanges: AllowedExchangeInfo[]; acceptedAuditors: AllowedAuditorInfo[]; acceptedWireMethods: string[]; } export interface AcceptableExchanges { /** * Exchanges accepted by the merchant, but wire method might not match. */ acceptableExchanges: string[]; /** * Exchanges accepted by the merchant, including a matching * wire method, i.e. the merchant can deposit coins there. */ depositableExchanges: string[]; } /** * Get all exchanges that are acceptable for a particular payment. */ export async function getAcceptableExchangeBaseUrls( ws: InternalWalletState, req: MerchantPaymentRestrictionsForBalance, ): Promise { const acceptableExchangeUrls = new Set(); const depositableExchangeUrls = new Set(); await ws.db .mktx((x) => [x.exchanges, x.exchangeDetails, x.auditorTrust]) .runReadOnly(async (tx) => { // FIXME: We should have a DB index to look up all exchanges // for a particular auditor ... const canonExchanges = new Set(); const canonAuditors = new Set(); for (const exchangeHandle of req.acceptedExchanges) { const normUrl = canonicalizeBaseUrl(exchangeHandle.exchangeBaseUrl); canonExchanges.add(normUrl); } for (const auditorHandle of req.acceptedAuditors) { const normUrl = canonicalizeBaseUrl(auditorHandle.auditorBaseUrl); canonAuditors.add(normUrl); } await tx.exchanges.iter().forEachAsync(async (exchange) => { const dp = exchange.detailsPointer; if (!dp) { return; } const { currency, masterPublicKey } = dp; const exchangeDetails = await tx.exchangeDetails.indexes.byPointer.get([ exchange.baseUrl, currency, masterPublicKey, ]); if (!exchangeDetails) { return; } let acceptable = false; if (canonExchanges.has(exchange.baseUrl)) { acceptableExchangeUrls.add(exchange.baseUrl); acceptable = true; } for (const exchangeAuditor of exchangeDetails.auditors) { if (canonAuditors.has(exchangeAuditor.auditor_url)) { acceptableExchangeUrls.add(exchange.baseUrl); acceptable = true; break; } } if (!acceptable) { return; } // FIXME: Also consider exchange and auditor public key // instead of just base URLs? let wireMethodSupported = false; for (const acc of exchangeDetails.wireInfo.accounts) { const pp = parsePaytoUri(acc.payto_uri); checkLogicInvariant(!!pp); for (const wm of req.acceptedWireMethods) { if (pp.targetType === wm) { wireMethodSupported = true; break; } if (wireMethodSupported) { break; } } } acceptableExchangeUrls.add(exchange.baseUrl); if (wireMethodSupported) { depositableExchangeUrls.add(exchange.baseUrl); } }); }); return { acceptableExchanges: [...acceptableExchangeUrls], depositableExchanges: [...depositableExchangeUrls], }; } export interface MerchantPaymentBalanceDetails { /** * Balance of type "available" (see balance.ts for definition). */ balanceAvailable: AmountJson; /** * Balance of type "material" (see balance.ts for definition). */ balanceMaterial: AmountJson; /** * Balance of type "age-acceptable" (see balance.ts for definition). */ balanceAgeAcceptable: AmountJson; /** * Balance of type "merchant-acceptable" (see balance.ts for definition). */ balanceMerchantAcceptable: AmountJson; /** * Balance of type "merchant-depositable" (see balance.ts for definition). */ balanceMerchantDepositable: AmountJson; } export async function getMerchantPaymentBalanceDetails( ws: InternalWalletState, req: MerchantPaymentRestrictionsForBalance, ): Promise { const acceptability = await getAcceptableExchangeBaseUrls(ws, req); const d: MerchantPaymentBalanceDetails = { balanceAvailable: Amounts.zeroOfCurrency(req.currency), balanceMaterial: Amounts.zeroOfCurrency(req.currency), balanceAgeAcceptable: Amounts.zeroOfCurrency(req.currency), balanceMerchantAcceptable: Amounts.zeroOfCurrency(req.currency), balanceMerchantDepositable: Amounts.zeroOfCurrency(req.currency), }; const wbal = await ws.db .mktx((x) => [ x.coins, x.coinAvailability, x.refreshGroups, x.purchases, x.withdrawalGroups, ]) .runReadOnly(async (tx) => { await tx.coinAvailability.iter().forEach((ca) => { const singleCoinAmount: AmountJson = { currency: ca.currency, fraction: ca.amountFrac, value: ca.amountVal, }; const coinAmount: AmountJson = Amounts.mult( singleCoinAmount, ca.freshCoinCount, ).amount; d.balanceAvailable = Amounts.add(d.balanceAvailable, coinAmount).amount; d.balanceMaterial = Amounts.add(d.balanceMaterial, coinAmount).amount; if (ca.maxAge === 0 || ca.maxAge > req.minAge) { d.balanceAgeAcceptable = Amounts.add( d.balanceAgeAcceptable, coinAmount, ).amount; if (acceptability.acceptableExchanges.includes(ca.exchangeBaseUrl)) { d.balanceMerchantAcceptable = Amounts.add( d.balanceMerchantAcceptable, coinAmount, ).amount; if ( acceptability.depositableExchanges.includes(ca.exchangeBaseUrl) ) { d.balanceMerchantDepositable = Amounts.add( d.balanceMerchantDepositable, coinAmount, ).amount; } } } }); await tx.refreshGroups.iter().forEach((r) => { d.balanceAvailable = Amounts.add( d.balanceAvailable, computeRefreshGroupAvailableAmount(r), ).amount; }); }); return d; }