/* 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 { AmountJson, AmountLike, Amounts, assertUnreachable, BalanceFlag, BalancesResponse, GetBalanceDetailRequest, j2s, Logger, parsePaytoUri, ScopeInfo, ScopeType, } from "@gnu-taler/taler-util"; import { ExchangeRestrictionSpec, findMatchingWire } from "./coinSelection.js"; import { DepositOperationStatus, ExchangeEntryDbRecordStatus, OPERATION_STATUS_ACTIVE_FIRST, OPERATION_STATUS_ACTIVE_LAST, PeerPushDebitStatus, RefreshGroupRecord, RefreshOperationStatus, WalletDbReadOnlyTransaction, WithdrawalGroupStatus, } from "./db.js"; import { getExchangeScopeInfo, getExchangeWireDetailsInTx, } from "./exchanges.js"; import { getDenomInfo, 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 addZero(currency: string, exchangeBaseUrl: string): Promise { await this.initBalance(currency, exchangeBaseUrl); } 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", "peerPushDebit", ] >, ): Promise { const balanceStore: BalancesStore = new BalancesStore(wex, tx); const keyRangeActive = GlobalIDB.KeyRange.bound( OPERATION_STATUS_ACTIVE_FIRST, OPERATION_STATUS_ACTIVE_LAST, ); await tx.exchanges.iter().forEachAsync(async (ex) => { if ( ex.entryStatus === ExchangeEntryDbRecordStatus.Used || ex.tosAcceptedTimestamp != null ) { const det = await getExchangeWireDetailsInTx(tx, ex.baseUrl); if (det) { await balanceStore.addZero(det.currency, ex.baseUrl); } } }); await tx.coinAvailability.iter().forEachAsync(async (ca) => { const count = ca.visibleCoinCount ?? 0; await balanceStore.addZero(ca.currency, ca.exchangeBaseUrl); 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.peerPushDebit.indexes.byStatus .iter(keyRangeActive) .forEachAsync(async (ppdRecord) => { switch (ppdRecord.status) { case PeerPushDebitStatus.AbortingDeletePurse: case PeerPushDebitStatus.SuspendedAbortingDeletePurse: case PeerPushDebitStatus.PendingReady: case PeerPushDebitStatus.SuspendedReady: case PeerPushDebitStatus.PendingCreatePurse: case PeerPushDebitStatus.SuspendedCreatePurse: { const currency = Amounts.currencyOf(ppdRecord.amount); await balanceStore.addPendingOutgoing( currency, ppdRecord.exchangeBaseUrl, ppdRecord.totalCost, ); break; } } }); 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( { storeNames: [ "coinAvailability", "coins", "depositGroups", "exchangeDetails", "exchanges", "globalCurrencyAuditors", "globalCurrencyExchanges", "purchases", "refreshGroups", "withdrawalGroups", "peerPushDebit", ], }, async (tx) => { return getBalancesInsideTransaction(wex, tx); }, ); logger.trace("finished computing wallet balance"); return wbal; } export interface PaymentRestrictionsForBalance { currency: string; minAge: number; restrictExchanges: ExchangeRestrictionSpec | undefined; restrictWireMethods: string[] | undefined; depositPaytoUri: string | undefined; } 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[]; } export interface PaymentBalanceDetails { /** * 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). */ balanceReceiverAcceptable: AmountJson; /** * Balance of type "merchant-depositable" (see balance.ts for definition). */ balanceReceiverDepositable: AmountJson; /** * Balance that's depositable with the exchange. * This balance is reduced by the exchange's debit restrictions * and wire fee configuration. */ balanceExchangeDepositable: AmountJson; maxEffectiveSpendAmount: AmountJson; } export async function getPaymentBalanceDetails( wex: WalletExecutionContext, req: PaymentRestrictionsForBalance, ): Promise { return await wex.db.runReadOnlyTx( { storeNames: [ "coinAvailability", "refreshGroups", "exchanges", "exchangeDetails", "denominations", ], }, async (tx) => { return getPaymentBalanceDetailsInTx(wex, tx, req); }, ); } export async function getPaymentBalanceDetailsInTx( wex: WalletExecutionContext, tx: WalletDbReadOnlyTransaction< [ "coinAvailability", "refreshGroups", "exchanges", "exchangeDetails", "denominations", ] >, req: PaymentRestrictionsForBalance, ): Promise { const d: PaymentBalanceDetails = { balanceAvailable: Amounts.zeroOfCurrency(req.currency), balanceMaterial: Amounts.zeroOfCurrency(req.currency), balanceAgeAcceptable: Amounts.zeroOfCurrency(req.currency), balanceReceiverAcceptable: Amounts.zeroOfCurrency(req.currency), balanceReceiverDepositable: Amounts.zeroOfCurrency(req.currency), maxEffectiveSpendAmount: Amounts.zeroOfCurrency(req.currency), balanceExchangeDepositable: Amounts.zeroOfCurrency(req.currency), }; logger.info(`computing balance details for ${j2s(req)}`); const availableCoins = await tx.coinAvailability.getAll(); for (const ca of availableCoins) { if (ca.currency != req.currency) { continue; } const denom = await getDenomInfo( wex, tx, ca.exchangeBaseUrl, ca.denomPubHash, ); if (!denom) { continue; } const wireDetails = await getExchangeWireDetailsInTx( tx, ca.exchangeBaseUrl, ); if (!wireDetails) { continue; } const singleCoinAmount: AmountJson = Amounts.parseOrThrow(ca.value); const coinAmount: AmountJson = Amounts.mult( singleCoinAmount, ca.freshCoinCount, ).amount; let wireOkay = false; if (req.restrictWireMethods == null) { wireOkay = true; } else { for (const wm of req.restrictWireMethods) { const wmf = findMatchingWire(wm, req.depositPaytoUri, wireDetails); if (wmf) { wireOkay = true; break; } } } if (wireOkay) { d.balanceExchangeDepositable = Amounts.add( d.balanceExchangeDepositable, coinAmount, ).amount; } let ageOkay = ca.maxAge === 0 || ca.maxAge > req.minAge; let merchantExchangeAcceptable = false; if (!req.restrictExchanges) { merchantExchangeAcceptable = true; } else { for (const ex of req.restrictExchanges.exchanges) { if (ex.exchangeBaseUrl === ca.exchangeBaseUrl) { merchantExchangeAcceptable = true; break; } } for (const acceptedAuditor of req.restrictExchanges.auditors) { for (const exchangeAuditor of wireDetails.auditors) { if (acceptedAuditor.auditorBaseUrl === exchangeAuditor.auditor_url) { merchantExchangeAcceptable = true; break; } } } } const merchantExchangeDepositable = merchantExchangeAcceptable && wireOkay; d.balanceAvailable = Amounts.add(d.balanceAvailable, coinAmount).amount; d.balanceMaterial = Amounts.add(d.balanceMaterial, coinAmount).amount; if (ageOkay) { d.balanceAgeAcceptable = Amounts.add( d.balanceAgeAcceptable, coinAmount, ).amount; if (merchantExchangeAcceptable) { d.balanceReceiverAcceptable = Amounts.add( d.balanceReceiverAcceptable, coinAmount, ).amount; if (merchantExchangeDepositable) { d.balanceReceiverDepositable = Amounts.add( d.balanceReceiverDepositable, coinAmount, ).amount; } } } if ( ageOkay && wireOkay && merchantExchangeAcceptable && merchantExchangeDepositable ) { d.maxEffectiveSpendAmount = Amounts.add( d.maxEffectiveSpendAmount, Amounts.mult(ca.value, ca.freshCoinCount).amount, ).amount; d.maxEffectiveSpendAmount = Amounts.sub( d.maxEffectiveSpendAmount, Amounts.mult(denom.feeDeposit, ca.freshCoinCount).amount, ).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( { storeNames: ["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 getPaymentBalanceDetails(wex, { currency: req.currency, restrictExchanges: { auditors: [], exchanges, }, restrictWireMethods: wires, minAge: 0, depositPaytoUri: undefined, }); }