/* 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 is available * for spending from transactions in their final state and * expected to be available from pending refreshes. * * - "pending-incoming": Expected (positive!) delta * to the available balance that we expect to have * after pending operations reach the "done" state. * * - "pending-outgoing": Amount that is currently allocated * to be spent, but the spend operation could still be aborted * and part of the pending-outgoing amount could be recovered. * * - "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 { GlobalIDB } from "@gnu-taler/idb-bridge"; import { AllowedAuditorInfo, AllowedExchangeInfo, AmountJson, AmountLike, Amounts, assertUnreachable, BalanceFlag, BalancesResponse, canonicalizeBaseUrl, checkLogicInvariant, GetBalanceDetailRequest, Logger, parsePaytoUri, ScopeInfo, ScopeType, } from "@gnu-taler/taler-util"; import { DepositOperationStatus, OPERATION_STATUS_ACTIVE_FIRST, OPERATION_STATUS_ACTIVE_LAST, RefreshGroupRecord, RefreshOperationStatus, WalletDbReadOnlyTransaction, WithdrawalGroupStatus, } from "./db.js"; import { getExchangeScopeInfo, getExchangeWireDetailsInTx, } from "./exchanges.js"; import { InternalWalletState, WalletExecutionContext } from "./wallet.js"; /** * Logger. */ const logger = new Logger("operations/balance.ts"); interface WalletBalance { scopeInfo: ScopeInfo; available: AmountJson; pendingIncoming: AmountJson; pendingOutgoing: AmountJson; flagIncomingKyc: boolean; flagIncomingAml: boolean; flagIncomingConfirmation: boolean; flagOutgoingKyc: boolean; } /** * 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++) { available = Amounts.add(available, r.expectedOutputPerCoin[i]).amount; } return available; } function getBalanceKey(scopeInfo: ScopeInfo): string { switch (scopeInfo.type) { case ScopeType.Auditor: return `${scopeInfo.type};${scopeInfo.currency};${scopeInfo.url}`; case ScopeType.Exchange: return `${scopeInfo.type};${scopeInfo.currency};${scopeInfo.url}`; case ScopeType.Global: return `${scopeInfo.type};${scopeInfo.currency}`; } } class BalancesStore { private exchangeScopeCache: Record = {}; private balanceStore: Record = {}; constructor( private wex: WalletExecutionContext, private tx: WalletDbReadOnlyTransaction< [ "globalCurrencyAuditors", "globalCurrencyExchanges", "exchanges", "exchangeDetails", ] >, ) {} /** * Add amount to a balance field, both for * the slicing by exchange and currency. */ private async initBalance( currency: string, exchangeBaseUrl: string, ): Promise { let scopeInfo: ScopeInfo | undefined = this.exchangeScopeCache[exchangeBaseUrl]; if (!scopeInfo) { scopeInfo = await getExchangeScopeInfo( this.tx, exchangeBaseUrl, currency, ); this.exchangeScopeCache[exchangeBaseUrl] = scopeInfo; } const balanceKey = getBalanceKey(scopeInfo); const b = this.balanceStore[balanceKey]; if (!b) { const zero = Amounts.zeroOfCurrency(currency); this.balanceStore[balanceKey] = { scopeInfo, available: zero, pendingIncoming: zero, pendingOutgoing: zero, flagIncomingAml: false, flagIncomingConfirmation: false, flagIncomingKyc: false, flagOutgoingKyc: false, }; } return this.balanceStore[balanceKey]; } async addAvailable( currency: string, exchangeBaseUrl: string, amount: AmountLike, ): Promise { const b = await this.initBalance(currency, exchangeBaseUrl); b.available = Amounts.add(b.available, amount).amount; } async addPendingIncoming( currency: string, exchangeBaseUrl: string, amount: AmountLike, ): Promise { const b = await this.initBalance(currency, exchangeBaseUrl); b.pendingIncoming = Amounts.add(b.pendingIncoming, amount).amount; } async addPendingOutgoing( currency: string, exchangeBaseUrl: string, amount: AmountLike, ): Promise { const b = await this.initBalance(currency, exchangeBaseUrl); b.pendingOutgoing = Amounts.add(b.pendingOutgoing, amount).amount; } async setFlagIncomingAml( currency: string, exchangeBaseUrl: string, ): Promise { const b = await this.initBalance(currency, exchangeBaseUrl); b.flagIncomingAml = true; } async setFlagIncomingKyc( currency: string, exchangeBaseUrl: string, ): Promise { const b = await this.initBalance(currency, exchangeBaseUrl); b.flagIncomingKyc = true; } async setFlagIncomingConfirmation( currency: string, exchangeBaseUrl: string, ): Promise { const b = await this.initBalance(currency, exchangeBaseUrl); b.flagIncomingConfirmation = true; } async setFlagOutgoingKyc( currency: string, exchangeBaseUrl: string, ): Promise { const b = await this.initBalance(currency, exchangeBaseUrl); b.flagOutgoingKyc = true; } toBalancesResponse(): BalancesResponse { const balancesResponse: BalancesResponse = { balances: [], }; const balanceStore = this.balanceStore; Object.keys(balanceStore) .sort() .forEach((c) => { const v = balanceStore[c]; const flags: BalanceFlag[] = []; if (v.flagIncomingAml) { flags.push(BalanceFlag.IncomingAml); } if (v.flagIncomingKyc) { flags.push(BalanceFlag.IncomingKyc); } if (v.flagIncomingConfirmation) { flags.push(BalanceFlag.IncomingConfirmation); } if (v.flagOutgoingKyc) { flags.push(BalanceFlag.OutgoingKyc); } balancesResponse.balances.push({ scopeInfo: v.scopeInfo, available: Amounts.stringify(v.available), pendingIncoming: Amounts.stringify(v.pendingIncoming), pendingOutgoing: Amounts.stringify(v.pendingOutgoing), // FIXME: This field is basically not implemented, do we even need it? hasPendingTransactions: false, // FIXME: This field is basically not implemented, do we even need it? requiresUserInput: false, flags, }); }); return balancesResponse; } } /** * Get balance information. */ export async function getBalancesInsideTransaction( wex: WalletExecutionContext, tx: WalletDbReadOnlyTransaction< [ "exchanges", "exchangeDetails", "coinAvailability", "refreshGroups", "depositGroups", "withdrawalGroups", "globalCurrencyAuditors", "globalCurrencyExchanges", ] >, ): Promise { const balanceStore: BalancesStore = new BalancesStore(wex, tx); const keyRangeActive = GlobalIDB.KeyRange.bound( OPERATION_STATUS_ACTIVE_FIRST, OPERATION_STATUS_ACTIVE_LAST, ); await tx.coinAvailability.iter().forEachAsync(async (ca) => { const count = ca.visibleCoinCount ?? 0; for (let i = 0; i < count; i++) { await balanceStore.addAvailable( ca.currency, ca.exchangeBaseUrl, ca.value, ); } }); await tx.refreshGroups.iter().forEachAsync(async (r) => { switch (r.operationStatus) { case RefreshOperationStatus.Pending: case RefreshOperationStatus.Suspended: break; default: return; } const perExchange = r.infoPerExchange; if (!perExchange) { return; } for (const [e, x] of Object.entries(perExchange)) { await balanceStore.addAvailable(r.currency, e, x.outputEffective); } }); await tx.withdrawalGroups.indexes.byStatus .iter(keyRangeActive) .forEachAsync(async (wgRecord) => { const currency = Amounts.currencyOf(wgRecord.denomsSel.totalCoinValue); switch (wgRecord.status) { case WithdrawalGroupStatus.AbortedBank: case WithdrawalGroupStatus.AbortedExchange: case WithdrawalGroupStatus.FailedAbortingBank: case WithdrawalGroupStatus.FailedBankAborted: case WithdrawalGroupStatus.Done: // Does not count as pendingIncoming return; case WithdrawalGroupStatus.PendingReady: case WithdrawalGroupStatus.AbortingBank: case WithdrawalGroupStatus.PendingQueryingStatus: case WithdrawalGroupStatus.SuspendedWaitConfirmBank: case WithdrawalGroupStatus.SuspendedReady: case WithdrawalGroupStatus.SuspendedRegisteringBank: case WithdrawalGroupStatus.SuspendedAbortingBank: case WithdrawalGroupStatus.SuspendedQueryingStatus: // Pending, but no special flag. break; case WithdrawalGroupStatus.SuspendedKyc: case WithdrawalGroupStatus.PendingKyc: await balanceStore.setFlagIncomingKyc( currency, wgRecord.exchangeBaseUrl, ); break; case WithdrawalGroupStatus.PendingAml: case WithdrawalGroupStatus.SuspendedAml: await balanceStore.setFlagIncomingAml( currency, wgRecord.exchangeBaseUrl, ); break; case WithdrawalGroupStatus.PendingRegisteringBank: case WithdrawalGroupStatus.PendingWaitConfirmBank: await balanceStore.setFlagIncomingConfirmation( currency, wgRecord.exchangeBaseUrl, ); break; default: assertUnreachable(wgRecord.status); } await balanceStore.addPendingIncoming( currency, wgRecord.exchangeBaseUrl, wgRecord.denomsSel.totalCoinValue, ); }); await tx.depositGroups.indexes.byStatus .iter(keyRangeActive) .forEachAsync(async (dgRecord) => { const perExchange = dgRecord.infoPerExchange; if (!perExchange) { return; } for (const [e, x] of Object.entries(perExchange)) { const currency = Amounts.currencyOf(dgRecord.amount); switch (dgRecord.operationStatus) { case DepositOperationStatus.SuspendedKyc: case DepositOperationStatus.PendingKyc: await balanceStore.setFlagOutgoingKyc(currency, e); } switch (dgRecord.operationStatus) { case DepositOperationStatus.SuspendedKyc: case DepositOperationStatus.PendingKyc: case DepositOperationStatus.PendingTrack: case DepositOperationStatus.SuspendedAborting: case DepositOperationStatus.SuspendedDeposit: case DepositOperationStatus.SuspendedTrack: case DepositOperationStatus.PendingDeposit: { const perExchange = dgRecord.infoPerExchange; if (perExchange) { for (const [e, v] of Object.entries(perExchange)) { await balanceStore.addPendingOutgoing( currency, e, v.amountEffective, ); } } } } } }); return balanceStore.toBalancesResponse(); } /** * Get detailed balance information, sliced by exchange and by currency. */ export async function getBalances( wex: WalletExecutionContext, ): Promise { logger.trace("starting to compute balance"); const wbal = await wex.db.runReadWriteTx( [ "coinAvailability", "coins", "depositGroups", "exchangeDetails", "exchanges", "globalCurrencyAuditors", "globalCurrencyExchanges", "purchases", "refreshGroups", "withdrawalGroups", ], async (tx) => { return getBalancesInsideTransaction(wex, 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( wex: WalletExecutionContext, req: MerchantPaymentRestrictionsForBalance, ): Promise { const acceptableExchangeUrls = new Set(); const depositableExchangeUrls = new Set(); await wex.db.runReadOnlyTx(["exchanges", "exchangeDetails"], 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( wex: WalletExecutionContext, req: MerchantPaymentRestrictionsForBalance, ): Promise { const acceptability = await getAcceptableExchangeBaseUrls(wex, 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), }; await wex.db.runReadOnlyTx( ["coinAvailability", "refreshGroups"], async (tx) => { await tx.coinAvailability.iter().forEach((ca) => { if (ca.currency != req.currency) { return; } const singleCoinAmount: AmountJson = Amounts.parseOrThrow(ca.value); 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) => { if (r.currency != req.currency) { return; } d.balanceAvailable = Amounts.add( d.balanceAvailable, computeRefreshGroupAvailableAmount(r), ).amount; }); }, ); return d; } export async function getBalanceDetail( wex: WalletExecutionContext, req: GetBalanceDetailRequest, ): Promise { const exchanges: { exchangeBaseUrl: string; exchangePub: string }[] = []; const wires = new Array(); await wex.db.runReadOnlyTx(["exchanges", "exchangeDetails"], async (tx) => { const allExchanges = await tx.exchanges.iter().toArray(); for (const e of allExchanges) { const details = await getExchangeWireDetailsInTx(tx, e.baseUrl); if (!details || req.currency !== details.currency) { continue; } details.wireInfo.accounts.forEach((a) => { const payto = parsePaytoUri(a.payto_uri); if (payto && !wires.includes(payto.targetType)) { wires.push(payto.targetType); } }); exchanges.push({ exchangePub: details.masterPublicKey, exchangeBaseUrl: e.baseUrl, }); } }); return await getMerchantPaymentBalanceDetails(wex, { currency: req.currency, acceptedAuditors: [], acceptedExchanges: exchanges, acceptedWireMethods: wires, minAge: 0, }); } export interface PeerPaymentRestrictionsForBalance { currency: string; restrictExchangeTo?: string; } export interface PeerPaymentBalanceDetails { /** * Balance of type "available" (see balance.ts for definition). */ balanceAvailable: AmountJson; /** * Balance of type "material" (see balance.ts for definition). */ balanceMaterial: AmountJson; } export async function getPeerPaymentBalanceDetailsInTx( wex: WalletExecutionContext, tx: WalletDbReadOnlyTransaction<["coinAvailability", "refreshGroups"]>, req: PeerPaymentRestrictionsForBalance, ): Promise { let balanceAvailable = Amounts.zeroOfCurrency(req.currency); let balanceMaterial = Amounts.zeroOfCurrency(req.currency); await tx.coinAvailability.iter().forEach((ca) => { if (ca.currency != req.currency) { return; } if ( req.restrictExchangeTo && req.restrictExchangeTo !== ca.exchangeBaseUrl ) { return; } const singleCoinAmount: AmountJson = Amounts.parseOrThrow(ca.value); const coinAmount: AmountJson = Amounts.mult( singleCoinAmount, ca.freshCoinCount, ).amount; balanceAvailable = Amounts.add(balanceAvailable, coinAmount).amount; balanceMaterial = Amounts.add(balanceMaterial, coinAmount).amount; }); await tx.refreshGroups.iter().forEach((r) => { if (r.currency != req.currency) { return; } balanceAvailable = Amounts.add( balanceAvailable, computeRefreshGroupAvailableAmount(r), ).amount; }); return { balanceAvailable, balanceMaterial, }; }