diff options
author | Florian Dold <florian@dold.me> | 2024-02-19 18:05:48 +0100 |
---|---|---|
committer | Florian Dold <florian@dold.me> | 2024-02-19 18:05:48 +0100 |
commit | e951075d2ef52fa8e9e7489c62031777c3a7e66b (patch) | |
tree | 64208c09a9162f3a99adccf30edc36de1ef884ef /packages/taler-wallet-core/src/balance.ts | |
parent | e975740ac4e9ba4bc531226784d640a018c00833 (diff) | |
download | wallet-core-e951075d2ef52fa8e9e7489c62031777c3a7e66b.tar.xz |
wallet-core: flatten directory structure
Diffstat (limited to 'packages/taler-wallet-core/src/balance.ts')
-rw-r--r-- | packages/taler-wallet-core/src/balance.ts | 730 |
1 files changed, 730 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..700e2c5d7 --- /dev/null +++ b/packages/taler-wallet-core/src/balance.ts @@ -0,0 +1,730 @@ +/* + 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 { + AllowedAuditorInfo, + AllowedExchangeInfo, + AmountJson, + AmountLike, + Amounts, + BalanceFlag, + BalancesResponse, + canonicalizeBaseUrl, + 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 { InternalWalletState } from "./internal-wallet-state.js"; +import { assertUnreachable } from "./util/assertUnreachable.js"; +import { checkLogicInvariant } from "./util/invariants.js"; +import { + getExchangeScopeInfo, + getExchangeWireDetailsInTx, +} from "./exchanges.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 ws: InternalWalletState, + 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 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.available, 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( + ws: InternalWalletState, + tx: WalletDbReadOnlyTransaction< + [ + "exchanges", + "exchangeDetails", + "coinAvailability", + "refreshGroups", + "depositGroups", + "withdrawalGroups", + "globalCurrencyAuditors", + "globalCurrencyExchanges", + ] + >, +): Promise<BalancesResponse> { + const balanceStore: BalancesStore = new BalancesStore(ws, 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); + } + } + }); + + return balanceStore.toBalancesResponse(); +} + +/** + * Get detailed balance information, sliced by exchange and by currency. + */ +export async function getBalances( + ws: InternalWalletState, +): Promise<BalancesResponse> { + logger.trace("starting to compute balance"); + + const wbal = await ws.db.runReadWriteTx( + [ + "coinAvailability", + "coins", + "depositGroups", + "exchangeDetails", + "exchanges", + "globalCurrencyAuditors", + "globalCurrencyExchanges", + "purchases", + "refreshGroups", + "withdrawalGroups", + ], + 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<AcceptableExchanges> { + const acceptableExchangeUrls = new Set<string>(); + const depositableExchangeUrls = new Set<string>(); + await ws.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<string>(); + const canonAuditors = new Set<string>(); + + 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<MerchantPaymentBalanceDetails> { + 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), + }; + + await ws.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( + ws: InternalWalletState, + req: GetBalanceDetailRequest, +): Promise<MerchantPaymentBalanceDetails> { + const exchanges: { exchangeBaseUrl: string; exchangePub: string }[] = []; + const wires = new Array<string>(); + await ws.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(ws, { + 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( + ws: InternalWalletState, + tx: WalletDbReadOnlyTransaction<["coinAvailability", "refreshGroups"]>, + req: PeerPaymentRestrictionsForBalance, +): Promise<PeerPaymentBalanceDetails> { + 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, + }; +} |