diff options
Diffstat (limited to 'packages/taler-wallet-core/src/balance.ts')
-rw-r--r-- | packages/taler-wallet-core/src/balance.ts | 772 |
1 files changed, 772 insertions, 0 deletions
diff --git a/packages/taler-wallet-core/src/balance.ts b/packages/taler-wallet-core/src/balance.ts new file mode 100644 index 000000000..5a805b477 --- /dev/null +++ b/packages/taler-wallet-core/src/balance.ts @@ -0,0 +1,772 @@ +/* + 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/> + */ + +/** + * 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<string, ScopeInfo> = {}; + private balanceStore: Record<string, WalletBalance> = {}; + + 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<WalletBalance> { + 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<void> { + await this.initBalance(currency, exchangeBaseUrl); + } + + async addAvailable( + currency: string, + exchangeBaseUrl: string, + amount: AmountLike, + ): Promise<void> { + const b = await this.initBalance(currency, exchangeBaseUrl); + b.available = Amounts.add(b.available, amount).amount; + } + + async addPendingIncoming( + currency: string, + exchangeBaseUrl: string, + amount: AmountLike, + ): Promise<void> { + const b = await this.initBalance(currency, exchangeBaseUrl); + b.pendingIncoming = Amounts.add(b.pendingIncoming, amount).amount; + } + + async addPendingOutgoing( + currency: string, + exchangeBaseUrl: string, + amount: AmountLike, + ): Promise<void> { + const b = await this.initBalance(currency, exchangeBaseUrl); + b.pendingOutgoing = Amounts.add(b.pendingOutgoing, amount).amount; + } + + async setFlagIncomingAml( + currency: string, + exchangeBaseUrl: string, + ): Promise<void> { + const b = await this.initBalance(currency, exchangeBaseUrl); + b.flagIncomingAml = true; + } + + async setFlagIncomingKyc( + currency: string, + exchangeBaseUrl: string, + ): Promise<void> { + const b = await this.initBalance(currency, exchangeBaseUrl); + b.flagIncomingKyc = true; + } + + async setFlagIncomingConfirmation( + currency: string, + exchangeBaseUrl: string, + ): Promise<void> { + const b = await this.initBalance(currency, exchangeBaseUrl); + b.flagIncomingConfirmation = true; + } + + async setFlagOutgoingKyc( + currency: string, + exchangeBaseUrl: string, + ): Promise<void> { + 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<BalancesResponse> { + 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.AbortedOtherWallet: + case WithdrawalGroupStatus.AbortedUserRefused: + case WithdrawalGroupStatus.DialogProposed: + 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<BalancesResponse> { + 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<PaymentBalanceDetails> { + 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<PaymentBalanceDetails> { + 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<PaymentBalanceDetails> { + const exchanges: { exchangeBaseUrl: string; exchangePub: string }[] = []; + const wires = new Array<string>(); + 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, + }); +} |