diff options
author | Florian Dold <florian@dold.me> | 2024-02-21 13:01:23 +0100 |
---|---|---|
committer | Florian Dold <florian@dold.me> | 2024-02-21 13:01:23 +0100 |
commit | 612b85c18fc17af412d08e075e1fddaa67aa7bf0 (patch) | |
tree | 2209cb052c94f70145d33271b7711e39728ad72b /packages/taler-wallet-core/src/util | |
parent | 06635c195816121ed7d90cf7bd3834850b674564 (diff) | |
download | wallet-core-612b85c18fc17af412d08e075e1fddaa67aa7bf0.tar.xz |
move helpers to util
Diffstat (limited to 'packages/taler-wallet-core/src/util')
8 files changed, 0 insertions, 4510 deletions
diff --git a/packages/taler-wallet-core/src/util/assertUnreachable.ts b/packages/taler-wallet-core/src/util/assertUnreachable.ts deleted file mode 100644 index 1819fd09e..000000000 --- a/packages/taler-wallet-core/src/util/assertUnreachable.ts +++ /dev/null @@ -1,19 +0,0 @@ -/* - 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/> - */ - -export function assertUnreachable(x: never): never { - throw new Error(`Didn't expect to get here ${x}`); -} diff --git a/packages/taler-wallet-core/src/util/coinSelection.test.ts b/packages/taler-wallet-core/src/util/coinSelection.test.ts deleted file mode 100644 index 0715c999f..000000000 --- a/packages/taler-wallet-core/src/util/coinSelection.test.ts +++ /dev/null @@ -1,249 +0,0 @@ -/* - This file is part of GNU Taler - (C) 2022 Taler Systems S.A. - - 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/> - */ -import { - AbsoluteTime, - AmountString, - Amounts, - DenomKeyType, - Duration, - TalerProtocolTimestamp, - j2s, -} from "@gnu-taler/taler-util"; -import test from "ava"; -import { - AvailableDenom, - testing_greedySelectPeer, - testing_selectGreedy, -} from "./coinSelection.js"; - -const inTheDistantFuture = AbsoluteTime.toProtocolTimestamp( - AbsoluteTime.addDuration(AbsoluteTime.now(), Duration.fromSpec({ hours: 1 })), -); - -const inThePast = AbsoluteTime.toProtocolTimestamp( - AbsoluteTime.subtractDuraction( - AbsoluteTime.now(), - Duration.fromSpec({ hours: 1 }), - ), -); - -test("p2p: should select the coin", (t) => { - const instructedAmount = Amounts.parseOrThrow("LOCAL:2"); - const tally = { - amountAcc: Amounts.zeroOfCurrency(instructedAmount.currency), - depositFeesAcc: Amounts.zeroOfCurrency(instructedAmount.currency), - lastDepositFee: Amounts.zeroOfCurrency(instructedAmount.currency), - }; - const coins = testing_greedySelectPeer( - createCandidates([ - { - amount: "LOCAL:10" as AmountString, - numAvailable: 5, - depositFee: "LOCAL:0.1" as AmountString, - fromExchange: "http://exchange.localhost/", - }, - ]), - instructedAmount, - tally, - ); - - t.log(j2s(coins)); - - t.assert(coins != null); - - t.deepEqual(coins, { - "hash0;32;http://exchange.localhost/": { - exchangeBaseUrl: "http://exchange.localhost/", - denomPubHash: "hash0", - maxAge: 32, - contributions: [Amounts.parseOrThrow("LOCAL:2.1")], - expireDeposit: inTheDistantFuture, - expireWithdraw: inTheDistantFuture, - }, - }); - - t.deepEqual(tally, { - amountAcc: Amounts.parseOrThrow("LOCAL:2"), - depositFeesAcc: Amounts.parseOrThrow("LOCAL:0.1"), - lastDepositFee: Amounts.parseOrThrow("LOCAL:0.1"), - }); -}); - -test("p2p: should select 3 coins", (t) => { - const instructedAmount = Amounts.parseOrThrow("LOCAL:20"); - const tally = { - amountAcc: Amounts.zeroOfCurrency(instructedAmount.currency), - depositFeesAcc: Amounts.zeroOfCurrency(instructedAmount.currency), - lastDepositFee: Amounts.zeroOfCurrency(instructedAmount.currency), - }; - const coins = testing_greedySelectPeer( - createCandidates([ - { - amount: "LOCAL:10" as AmountString, - numAvailable: 5, - depositFee: "LOCAL:0.1" as AmountString, - fromExchange: "http://exchange.localhost/", - }, - ]), - instructedAmount, - tally, - ); - - t.deepEqual(coins, { - "hash0;32;http://exchange.localhost/": { - exchangeBaseUrl: "http://exchange.localhost/", - denomPubHash: "hash0", - maxAge: 32, - contributions: [ - Amounts.parseOrThrow("LOCAL:9.9"), - Amounts.parseOrThrow("LOCAL:9.9"), - Amounts.parseOrThrow("LOCAL:0.5"), - ], - expireDeposit: inTheDistantFuture, - expireWithdraw: inTheDistantFuture, - }, - }); - - t.deepEqual(tally, { - amountAcc: Amounts.parseOrThrow("LOCAL:20"), - depositFeesAcc: Amounts.parseOrThrow("LOCAL:0.3"), - lastDepositFee: Amounts.parseOrThrow("LOCAL:0.1"), - }); -}); - -test("p2p: can't select since the instructed amount is too high", (t) => { - const instructedAmount = Amounts.parseOrThrow("LOCAL:60"); - const tally = { - amountAcc: Amounts.zeroOfCurrency(instructedAmount.currency), - depositFeesAcc: Amounts.zeroOfCurrency(instructedAmount.currency), - lastDepositFee: Amounts.zeroOfCurrency(instructedAmount.currency), - }; - const coins = testing_greedySelectPeer( - createCandidates([ - { - amount: "LOCAL:10" as AmountString, - numAvailable: 5, - depositFee: "LOCAL:0.1" as AmountString, - fromExchange: "http://exchange.localhost/", - }, - ]), - instructedAmount, - tally, - ); - - t.is(coins, undefined); - - t.deepEqual(tally, { - amountAcc: Amounts.parseOrThrow("LOCAL:49"), - depositFeesAcc: Amounts.parseOrThrow("LOCAL:0.5"), - lastDepositFee: Amounts.parseOrThrow("LOCAL:0.1"), - }); -}); - -test("pay: select one coin to pay with fee", (t) => { - const payment = Amounts.parseOrThrow("LOCAL:2"); - const exchangeWireFee = Amounts.parseOrThrow("LOCAL:0.1"); - const zero = Amounts.zeroOfCurrency(payment.currency); - const tally = { - amountPayRemaining: payment, - amountWireFeeLimitRemaining: zero, - amountDepositFeeLimitRemaining: zero, - customerDepositFees: zero, - customerWireFees: zero, - wireFeeCoveredForExchange: new Set<string>(), - lastDepositFee: zero, - }; - const coins = testing_selectGreedy( - { - auditors: [], - exchanges: [ - { - exchangeBaseUrl: "http://exchange.localhost/", - exchangePub: "E5M8CGRDHXF1RCVP3B8TQCTDYNQ7T4XHWR5SVEQRGVVMVME41VJ0", - }, - ], - contractTermsAmount: payment, - depositFeeLimit: zero, - wireFeeAmortization: 1, - wireFeeLimit: zero, - prevPayCoins: [], - wireMethod: "x-taler-bank", - }, - createCandidates([ - { - amount: "LOCAL:10" as AmountString, - numAvailable: 5, - depositFee: "LOCAL:0.1" as AmountString, - fromExchange: "http://exchange.localhost/", - }, - ]), - { "http://exchange.localhost/": exchangeWireFee }, - tally, - ); - - t.deepEqual(coins, { - "hash0;32;http://exchange.localhost/": { - exchangeBaseUrl: "http://exchange.localhost/", - denomPubHash: "hash0", - maxAge: 32, - contributions: [Amounts.parseOrThrow("LOCAL:2.2")], - expireDeposit: inTheDistantFuture, - expireWithdraw: inTheDistantFuture, - }, - }); - - t.deepEqual(tally, { - amountPayRemaining: Amounts.parseOrThrow("LOCAL:2"), - amountWireFeeLimitRemaining: zero, - amountDepositFeeLimitRemaining: zero, - customerDepositFees: zero, - customerWireFees: zero, - wireFeeCoveredForExchange: new Set(), - lastDepositFee: zero, - }); -}); - -function createCandidates( - ar: { - amount: AmountString; - depositFee: AmountString; - numAvailable: number; - fromExchange: string; - }[], -): AvailableDenom[] { - return ar.map((r, idx) => { - return { - denomPub: { - age_mask: 0, - cipher: DenomKeyType.Rsa, - rsa_public_key: "PPP", - }, - denomPubHash: `hash${idx}`, - value: r.amount, - feeDeposit: r.depositFee, - feeRefresh: "LOCAL:0" as AmountString, - feeRefund: "LOCAL:0" as AmountString, - feeWithdraw: "LOCAL:0" as AmountString, - stampExpireDeposit: inTheDistantFuture, - stampExpireLegal: inTheDistantFuture, - stampExpireWithdraw: inTheDistantFuture, - stampStart: inThePast, - exchangeBaseUrl: r.fromExchange, - numAvailable: r.numAvailable, - maxAge: 32, - }; - }); -} diff --git a/packages/taler-wallet-core/src/util/coinSelection.ts b/packages/taler-wallet-core/src/util/coinSelection.ts deleted file mode 100644 index 02eb3ae32..000000000 --- a/packages/taler-wallet-core/src/util/coinSelection.ts +++ /dev/null @@ -1,1232 +0,0 @@ -/* - This file is part of GNU Taler - (C) 2021 Taler Systems S.A. - - 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/> - */ - -/** - * Selection of coins for payments. - * - * @author Florian Dold - */ - -/** - * Imports. - */ -import { GlobalIDB } from "@gnu-taler/idb-bridge"; -import { - AbsoluteTime, - AccountRestriction, - AgeCommitmentProof, - AgeRestriction, - AllowedAuditorInfo, - AllowedExchangeInfo, - AmountJson, - AmountLike, - Amounts, - AmountString, - CoinPublicKeyString, - CoinStatus, - DenominationInfo, - DenominationPubKey, - DenomSelectionState, - Duration, - ForcedCoinSel, - ForcedDenomSel, - InternationalizedString, - j2s, - Logger, - parsePaytoUri, - PayCoinSelection, - PayMerchantInsufficientBalanceDetails, - PayPeerInsufficientBalanceDetails, - strcmp, - TalerProtocolTimestamp, - UnblindedSignature, -} from "@gnu-taler/taler-util"; -import { - getMerchantPaymentBalanceDetails, - getPeerPaymentBalanceDetailsInTx, -} from "../balance.js"; -import { getAutoRefreshExecuteThreshold } from "../common.js"; -import { DenominationRecord, WalletDbReadOnlyTransaction } from "../db.js"; -import { getExchangeWireDetailsInTx } from "../exchanges.js"; -import { InternalWalletState } from "../wallet.js"; -import { isWithdrawableDenom } from "./denominations.js"; -import { checkDbInvariant, checkLogicInvariant } from "./invariants.js"; - -const logger = new Logger("coinSelection.ts"); - -/** - * Structure to describe a coin that is available to be - * used in a payment. - */ -export interface AvailableCoinInfo { - /** - * Public key of the coin. - */ - coinPub: string; - - /** - * Coin's denomination public key. - * - * FIXME: We should only need the denomPubHash here, if at all. - */ - denomPub: DenominationPubKey; - - /** - * Full value of the coin. - */ - value: AmountJson; - - /** - * Amount still remaining (typically the full amount, - * as coins are always refreshed after use.) - */ - availableAmount: AmountJson; - - /** - * Deposit fee for the coin. - */ - feeDeposit: AmountJson; - - exchangeBaseUrl: string; - - maxAge: number; - ageCommitmentProof?: AgeCommitmentProof; -} - -export type PreviousPayCoins = { - coinPub: string; - contribution: AmountJson; - feeDeposit: AmountJson; - exchangeBaseUrl: string; -}[]; - -export interface CoinCandidateSelection { - candidateCoins: AvailableCoinInfo[]; - wireFeesPerExchange: Record<string, AmountJson>; -} - -export interface SelectPayCoinRequest { - candidates: CoinCandidateSelection; - contractTermsAmount: AmountJson; - depositFeeLimit: AmountJson; - wireFeeLimit: AmountJson; - wireFeeAmortization: number; - prevPayCoins?: PreviousPayCoins; - requiredMinimumAge?: number; -} - -export interface CoinSelectionTally { - /** - * Amount that still needs to be paid. - * May increase during the computation when fees need to be covered. - */ - amountPayRemaining: AmountJson; - - /** - * Allowance given by the merchant towards wire fees - */ - amountWireFeeLimitRemaining: AmountJson; - - /** - * Allowance given by the merchant towards deposit fees - * (and wire fees after wire fee limit is exhausted) - */ - amountDepositFeeLimitRemaining: AmountJson; - - customerDepositFees: AmountJson; - - customerWireFees: AmountJson; - - wireFeeCoveredForExchange: Set<string>; - - lastDepositFee: AmountJson; -} - -/** - * Account for the fees of spending a coin. - */ -function tallyFees( - tally: Readonly<CoinSelectionTally>, - wireFeesPerExchange: Record<string, AmountJson>, - wireFeeAmortization: number, - exchangeBaseUrl: string, - feeDeposit: AmountJson, -): CoinSelectionTally { - const currency = tally.amountPayRemaining.currency; - let amountWireFeeLimitRemaining = tally.amountWireFeeLimitRemaining; - let amountDepositFeeLimitRemaining = tally.amountDepositFeeLimitRemaining; - let customerDepositFees = tally.customerDepositFees; - let customerWireFees = tally.customerWireFees; - let amountPayRemaining = tally.amountPayRemaining; - const wireFeeCoveredForExchange = new Set(tally.wireFeeCoveredForExchange); - - if (!tally.wireFeeCoveredForExchange.has(exchangeBaseUrl)) { - const wf = - wireFeesPerExchange[exchangeBaseUrl] ?? Amounts.zeroOfCurrency(currency); - const wfForgiven = Amounts.min(amountWireFeeLimitRemaining, wf); - amountWireFeeLimitRemaining = Amounts.sub( - amountWireFeeLimitRemaining, - wfForgiven, - ).amount; - // The remaining, amortized amount needs to be paid by the - // wallet or covered by the deposit fee allowance. - let wfRemaining = Amounts.divide( - Amounts.sub(wf, wfForgiven).amount, - wireFeeAmortization, - ); - - // This is the amount forgiven via the deposit fee allowance. - const wfDepositForgiven = Amounts.min( - amountDepositFeeLimitRemaining, - wfRemaining, - ); - amountDepositFeeLimitRemaining = Amounts.sub( - amountDepositFeeLimitRemaining, - wfDepositForgiven, - ).amount; - - wfRemaining = Amounts.sub(wfRemaining, wfDepositForgiven).amount; - customerWireFees = Amounts.add(customerWireFees, wfRemaining).amount; - amountPayRemaining = Amounts.add(amountPayRemaining, wfRemaining).amount; - - wireFeeCoveredForExchange.add(exchangeBaseUrl); - } - - const dfForgiven = Amounts.min(feeDeposit, amountDepositFeeLimitRemaining); - - amountDepositFeeLimitRemaining = Amounts.sub( - amountDepositFeeLimitRemaining, - dfForgiven, - ).amount; - - // How much does the user spend on deposit fees for this coin? - const dfRemaining = Amounts.sub(feeDeposit, dfForgiven).amount; - customerDepositFees = Amounts.add(customerDepositFees, dfRemaining).amount; - amountPayRemaining = Amounts.add(amountPayRemaining, dfRemaining).amount; - - return { - amountDepositFeeLimitRemaining, - amountPayRemaining, - amountWireFeeLimitRemaining, - customerDepositFees, - customerWireFees, - wireFeeCoveredForExchange, - lastDepositFee: feeDeposit, - }; -} - -export type SelectPayCoinsResult = - | { - type: "failure"; - insufficientBalanceDetails: PayMerchantInsufficientBalanceDetails; - } - | { type: "success"; coinSel: PayCoinSelection }; - -/** - * Given a list of candidate coins, select coins to spend under the merchant's - * constraints. - * - * The prevPayCoins can be specified to "repair" a coin selection - * by adding additional coins, after a broken (e.g. double-spent) coin - * has been removed from the selection. - * - * This function is only exported for the sake of unit tests. - */ -export async function selectPayCoinsNew( - ws: InternalWalletState, - req: SelectPayCoinRequestNg, -): Promise<SelectPayCoinsResult> { - const { - contractTermsAmount, - depositFeeLimit, - wireFeeLimit, - wireFeeAmortization, - } = req; - - // FIXME: Why don't we do this in a transaction? - const [candidateDenoms, wireFeesPerExchange] = - await selectPayMerchantCandidates(ws, req); - - const coinPubs: string[] = []; - const coinContributions: AmountJson[] = []; - const currency = contractTermsAmount.currency; - - let tally: CoinSelectionTally = { - amountPayRemaining: contractTermsAmount, - amountWireFeeLimitRemaining: wireFeeLimit, - amountDepositFeeLimitRemaining: depositFeeLimit, - customerDepositFees: Amounts.zeroOfCurrency(currency), - customerWireFees: Amounts.zeroOfCurrency(currency), - wireFeeCoveredForExchange: new Set(), - lastDepositFee: Amounts.zeroOfCurrency(currency), - }; - - const prevPayCoins = req.prevPayCoins ?? []; - - // Look at existing pay coin selection and tally up - for (const prev of prevPayCoins) { - tally = tallyFees( - tally, - wireFeesPerExchange, - wireFeeAmortization, - prev.exchangeBaseUrl, - prev.feeDeposit, - ); - tally.amountPayRemaining = Amounts.sub( - tally.amountPayRemaining, - prev.contribution, - ).amount; - - coinPubs.push(prev.coinPub); - coinContributions.push(prev.contribution); - } - - let selectedDenom: SelResult | undefined; - if (req.forcedSelection) { - selectedDenom = selectForced(req, candidateDenoms); - } else { - // FIXME: Here, we should select coins in a smarter way. - // Instead of always spending the next-largest coin, - // we should try to find the smallest coin that covers the - // amount. - selectedDenom = selectGreedy( - req, - candidateDenoms, - wireFeesPerExchange, - tally, - ); - } - - if (!selectedDenom) { - const details = await getMerchantPaymentBalanceDetails(ws, { - acceptedAuditors: req.auditors, - acceptedExchanges: req.exchanges, - acceptedWireMethods: [req.wireMethod], - currency: Amounts.currencyOf(req.contractTermsAmount), - minAge: req.requiredMinimumAge ?? 0, - }); - let feeGapEstimate: AmountJson; - if ( - Amounts.cmp( - details.balanceMerchantDepositable, - req.contractTermsAmount, - ) >= 0 - ) { - // FIXME: We can probably give a better estimate. - feeGapEstimate = Amounts.add( - tally.amountPayRemaining, - tally.lastDepositFee, - ).amount; - } else { - feeGapEstimate = Amounts.zeroOfAmount(req.contractTermsAmount); - } - return { - type: "failure", - insufficientBalanceDetails: { - amountRequested: Amounts.stringify(req.contractTermsAmount), - balanceAgeAcceptable: Amounts.stringify(details.balanceAgeAcceptable), - balanceAvailable: Amounts.stringify(details.balanceAvailable), - balanceMaterial: Amounts.stringify(details.balanceMaterial), - balanceMerchantAcceptable: Amounts.stringify( - details.balanceMerchantAcceptable, - ), - balanceMerchantDepositable: Amounts.stringify( - details.balanceMerchantDepositable, - ), - feeGapEstimate: Amounts.stringify(feeGapEstimate), - }, - }; - } - - const finalSel = selectedDenom; - - logger.trace(`coin selection request ${j2s(req)}`); - logger.trace(`selected coins (via denoms) for payment: ${j2s(finalSel)}`); - - await ws.db.runReadOnlyTx(["coins", "denominations"], async (tx) => { - for (const dph of Object.keys(finalSel)) { - const selInfo = finalSel[dph]; - const numRequested = selInfo.contributions.length; - const query = [ - selInfo.exchangeBaseUrl, - selInfo.denomPubHash, - selInfo.maxAge, - CoinStatus.Fresh, - ]; - logger.trace(`query: ${j2s(query)}`); - const coins = - await tx.coins.indexes.byExchangeDenomPubHashAndAgeAndStatus.getAll( - query, - numRequested, - ); - if (coins.length != numRequested) { - throw Error( - `coin selection failed (not available anymore, got only ${coins.length}/${numRequested})`, - ); - } - coinPubs.push(...coins.map((x) => x.coinPub)); - coinContributions.push(...selInfo.contributions); - } - }); - - return { - type: "success", - coinSel: { - paymentAmount: Amounts.stringify(contractTermsAmount), - coinContributions: coinContributions.map((x) => Amounts.stringify(x)), - coinPubs, - customerDepositFees: Amounts.stringify(tally.customerDepositFees), - customerWireFees: Amounts.stringify(tally.customerWireFees), - }, - }; -} - -function makeAvailabilityKey( - exchangeBaseUrl: string, - denomPubHash: string, - maxAge: number, -): string { - return `${denomPubHash};${maxAge};${exchangeBaseUrl}`; -} - -/** - * Selection result. - */ -interface SelResult { - /** - * Map from an availability key - * to an array of contributions. - */ - [avKey: string]: { - exchangeBaseUrl: string; - denomPubHash: string; - expireWithdraw: TalerProtocolTimestamp; - expireDeposit: TalerProtocolTimestamp; - maxAge: number; - contributions: AmountJson[]; - }; -} - -export function testing_selectGreedy( - ...args: Parameters<typeof selectGreedy> -): ReturnType<typeof selectGreedy> { - return selectGreedy(...args); -} - -function selectGreedy( - req: SelectPayCoinRequestNg, - candidateDenoms: AvailableDenom[], - wireFeesPerExchange: Record<string, AmountJson>, - tally: CoinSelectionTally, -): SelResult | undefined { - const { wireFeeAmortization } = req; - const selectedDenom: SelResult = {}; - for (const denom of candidateDenoms) { - const contributions: AmountJson[] = []; - - // Don't use this coin if depositing it is more expensive than - // the amount it would give the merchant. - if (Amounts.cmp(denom.feeDeposit, denom.value) > 0) { - tally.lastDepositFee = Amounts.parseOrThrow(denom.feeDeposit); - continue; - } - - for ( - let i = 0; - i < denom.numAvailable && Amounts.isNonZero(tally.amountPayRemaining); - i++ - ) { - tally = tallyFees( - tally, - wireFeesPerExchange, - wireFeeAmortization, - denom.exchangeBaseUrl, - Amounts.parseOrThrow(denom.feeDeposit), - ); - - const coinSpend = Amounts.max( - Amounts.min(tally.amountPayRemaining, denom.value), - denom.feeDeposit, - ); - - tally.amountPayRemaining = Amounts.sub( - tally.amountPayRemaining, - coinSpend, - ).amount; - - contributions.push(coinSpend); - } - - if (contributions.length) { - const avKey = makeAvailabilityKey( - denom.exchangeBaseUrl, - denom.denomPubHash, - denom.maxAge, - ); - let sd = selectedDenom[avKey]; - if (!sd) { - sd = { - contributions: [], - denomPubHash: denom.denomPubHash, - exchangeBaseUrl: denom.exchangeBaseUrl, - maxAge: denom.maxAge, - expireDeposit: denom.stampExpireDeposit, - expireWithdraw: denom.stampExpireWithdraw, - }; - } - sd.contributions.push(...contributions); - selectedDenom[avKey] = sd; - } - } - return Amounts.isZero(tally.amountPayRemaining) ? selectedDenom : undefined; -} - -function selectForced( - req: SelectPayCoinRequestNg, - candidateDenoms: AvailableDenom[], -): SelResult | undefined { - const selectedDenom: SelResult = {}; - - const forcedSelection = req.forcedSelection; - checkLogicInvariant(!!forcedSelection); - - for (const forcedCoin of forcedSelection.coins) { - let found = false; - for (const aci of candidateDenoms) { - if (aci.numAvailable <= 0) { - continue; - } - if (Amounts.cmp(aci.value, forcedCoin.value) === 0) { - aci.numAvailable--; - const avKey = makeAvailabilityKey( - aci.exchangeBaseUrl, - aci.denomPubHash, - aci.maxAge, - ); - let sd = selectedDenom[avKey]; - if (!sd) { - sd = { - contributions: [], - denomPubHash: aci.denomPubHash, - exchangeBaseUrl: aci.exchangeBaseUrl, - maxAge: aci.maxAge, - expireDeposit: aci.stampExpireDeposit, - expireWithdraw: aci.stampExpireWithdraw, - }; - } - sd.contributions.push(Amounts.parseOrThrow(forcedCoin.value)); - selectedDenom[avKey] = sd; - found = true; - break; - } - } - if (!found) { - throw Error("can't find coin for forced coin selection"); - } - } - - return selectedDenom; -} - -export function checkAccountRestriction( - paytoUri: string, - restrictions: AccountRestriction[], -): { ok: boolean; hint?: string; hintI18n?: InternationalizedString } { - for (const myRestriction of restrictions) { - switch (myRestriction.type) { - case "deny": - return { ok: false }; - case "regex": - const regex = new RegExp(myRestriction.payto_regex); - if (!regex.test(paytoUri)) { - return { - ok: false, - hint: myRestriction.human_hint, - hintI18n: myRestriction.human_hint_i18n, - }; - } - } - } - return { - ok: true, - }; -} - -export interface SelectPayCoinRequestNg { - exchanges: AllowedExchangeInfo[]; - auditors: AllowedAuditorInfo[]; - wireMethod: string; - contractTermsAmount: AmountJson; - depositFeeLimit: AmountJson; - wireFeeLimit: AmountJson; - wireFeeAmortization: number; - prevPayCoins?: PreviousPayCoins; - requiredMinimumAge?: number; - forcedSelection?: ForcedCoinSel; - - /** - * Deposit payto URI, in case we already know the account that - * will be deposited into. - * - * That is typically the case when the wallet does a deposit to - * return funds to the user's own bank account. - */ - depositPaytoUri?: string; -} - -export type AvailableDenom = DenominationInfo & { - maxAge: number; - numAvailable: number; -}; - -async function selectPayMerchantCandidates( - ws: InternalWalletState, - req: SelectPayCoinRequestNg, -): Promise<[AvailableDenom[], Record<string, AmountJson>]> { - return await ws.db.runReadOnlyTx( - ["exchanges", "exchangeDetails", "denominations", "coinAvailability"], - async (tx) => { - // FIXME: Use the existing helper (from balance.ts) to - // get acceptable exchanges. - const denoms: AvailableDenom[] = []; - const exchanges = await tx.exchanges.iter().toArray(); - const wfPerExchange: Record<string, AmountJson> = {}; - loopExchange: for (const exchange of exchanges) { - const exchangeDetails = await getExchangeWireDetailsInTx( - tx, - exchange.baseUrl, - ); - // 1.- exchange has same currency - if (exchangeDetails?.currency !== req.contractTermsAmount.currency) { - continue; - } - let wireMethodFee: string | undefined; - // 2.- exchange supports wire method - loopWireAccount: for (const acc of exchangeDetails.wireInfo.accounts) { - const pp = parsePaytoUri(acc.payto_uri); - checkLogicInvariant(!!pp); - if (pp.targetType !== req.wireMethod) { - continue; - } - const wireFeeStr = exchangeDetails.wireInfo.feesForType[ - req.wireMethod - ]?.find((x) => { - return AbsoluteTime.isBetween( - AbsoluteTime.now(), - AbsoluteTime.fromProtocolTimestamp(x.startStamp), - AbsoluteTime.fromProtocolTimestamp(x.endStamp), - ); - })?.wireFee; - let debitAccountCheckOk = false; - if (req.depositPaytoUri) { - // FIXME: We should somehow propagate the hint here! - const checkResult = checkAccountRestriction( - req.depositPaytoUri, - acc.debit_restrictions, - ); - if (checkResult.ok) { - debitAccountCheckOk = true; - } - } else { - debitAccountCheckOk = true; - } - - if (wireFeeStr) { - wireMethodFee = wireFeeStr; - break loopWireAccount; - } - } - if (!wireMethodFee) { - continue; - } - wfPerExchange[exchange.baseUrl] = Amounts.parseOrThrow(wireMethodFee); - - // 3.- exchange is trusted in the exchange list or auditor list - let accepted = false; - for (const allowedExchange of req.exchanges) { - if (allowedExchange.exchangePub === exchangeDetails.masterPublicKey) { - accepted = true; - break; - } - } - for (const allowedAuditor of req.auditors) { - for (const providedAuditor of exchangeDetails.auditors) { - if (allowedAuditor.auditorPub === providedAuditor.auditor_pub) { - accepted = true; - break; - } - } - } - if (!accepted) { - continue; - } - // 4.- filter coins restricted by age - let ageLower = 0; - let ageUpper = AgeRestriction.AGE_UNRESTRICTED; - if (req.requiredMinimumAge) { - ageLower = req.requiredMinimumAge; - } - const myExchangeCoins = - await tx.coinAvailability.indexes.byExchangeAgeAvailability.getAll( - GlobalIDB.KeyRange.bound( - [exchangeDetails.exchangeBaseUrl, ageLower, 1], - [ - exchangeDetails.exchangeBaseUrl, - ageUpper, - Number.MAX_SAFE_INTEGER, - ], - ), - ); - // 5.- save denoms with how many coins are available - // FIXME: Check that the individual denomination is audited! - // FIXME: Should we exclude denominations that are - // not spendable anymore? - for (const coinAvail of myExchangeCoins) { - const denom = await tx.denominations.get([ - coinAvail.exchangeBaseUrl, - coinAvail.denomPubHash, - ]); - checkDbInvariant(!!denom); - if (denom.isRevoked || !denom.isOffered) { - continue; - } - denoms.push({ - ...DenominationRecord.toDenomInfo(denom), - numAvailable: coinAvail.freshCoinCount ?? 0, - maxAge: coinAvail.maxAge, - }); - } - } - logger.info(`available denoms ${j2s(denoms)}`); - // Sort by available amount (descending), deposit fee (ascending) and - // denomPub (ascending) if deposit fee is the same - // (to guarantee deterministic results) - denoms.sort( - (o1, o2) => - -Amounts.cmp(o1.value, o2.value) || - Amounts.cmp(o1.feeDeposit, o2.feeDeposit) || - strcmp(o1.denomPubHash, o2.denomPubHash), - ); - return [denoms, wfPerExchange]; - }, - ); -} - -/** - * Get a list of denominations (with repetitions possible) - * whose total value is as close as possible to the available - * amount, but never larger. - */ -export function selectWithdrawalDenominations( - amountAvailable: AmountJson, - denoms: DenominationRecord[], - denomselAllowLate: boolean = false, -): DenomSelectionState { - let remaining = Amounts.copy(amountAvailable); - - const selectedDenoms: { - count: number; - denomPubHash: string; - }[] = []; - - let totalCoinValue = Amounts.zeroOfCurrency(amountAvailable.currency); - let totalWithdrawCost = Amounts.zeroOfCurrency(amountAvailable.currency); - - denoms = denoms.filter((d) => isWithdrawableDenom(d, denomselAllowLate)); - denoms.sort((d1, d2) => Amounts.cmp(d2.value, d1.value)); - - for (const d of denoms) { - const cost = Amounts.add(d.value, d.fees.feeWithdraw).amount; - const res = Amounts.divmod(remaining, cost); - const count = res.quotient; - remaining = Amounts.sub(remaining, Amounts.mult(cost, count).amount).amount; - if (count > 0) { - totalCoinValue = Amounts.add( - totalCoinValue, - Amounts.mult(d.value, count).amount, - ).amount; - totalWithdrawCost = Amounts.add( - totalWithdrawCost, - Amounts.mult(cost, count).amount, - ).amount; - selectedDenoms.push({ - count, - denomPubHash: d.denomPubHash, - }); - } - - if (Amounts.isZero(remaining)) { - break; - } - } - - if (logger.shouldLogTrace()) { - logger.trace( - `selected withdrawal denoms for ${Amounts.stringify(totalCoinValue)}`, - ); - for (const sd of selectedDenoms) { - logger.trace(`denom_pub_hash=${sd.denomPubHash}, count=${sd.count}`); - } - logger.trace("(end of withdrawal denom list)"); - } - - return { - selectedDenoms, - totalCoinValue: Amounts.stringify(totalCoinValue), - totalWithdrawCost: Amounts.stringify(totalWithdrawCost), - }; -} - -export function selectForcedWithdrawalDenominations( - amountAvailable: AmountJson, - denoms: DenominationRecord[], - forcedDenomSel: ForcedDenomSel, - denomselAllowLate: boolean, -): DenomSelectionState { - const selectedDenoms: { - count: number; - denomPubHash: string; - }[] = []; - - let totalCoinValue = Amounts.zeroOfCurrency(amountAvailable.currency); - let totalWithdrawCost = Amounts.zeroOfCurrency(amountAvailable.currency); - - denoms = denoms.filter((d) => isWithdrawableDenom(d, denomselAllowLate)); - denoms.sort((d1, d2) => Amounts.cmp(d2.value, d1.value)); - - for (const fds of forcedDenomSel.denoms) { - const count = fds.count; - const denom = denoms.find((x) => { - return Amounts.cmp(x.value, fds.value) == 0; - }); - if (!denom) { - throw Error( - `unable to find denom for forced selection (value ${fds.value})`, - ); - } - const cost = Amounts.add(denom.value, denom.fees.feeWithdraw).amount; - totalCoinValue = Amounts.add( - totalCoinValue, - Amounts.mult(denom.value, count).amount, - ).amount; - totalWithdrawCost = Amounts.add( - totalWithdrawCost, - Amounts.mult(cost, count).amount, - ).amount; - selectedDenoms.push({ - count, - denomPubHash: denom.denomPubHash, - }); - } - - return { - selectedDenoms, - totalCoinValue: Amounts.stringify(totalCoinValue), - totalWithdrawCost: Amounts.stringify(totalWithdrawCost), - }; -} - -export interface CoinInfo { - id: string; - value: AmountJson; - denomDeposit: AmountJson; - denomWithdraw: AmountJson; - denomRefresh: AmountJson; - totalAvailable: number | undefined; - exchangeWire: AmountJson | undefined; - exchangePurse: AmountJson | undefined; - duration: Duration; - exchangeBaseUrl: string; - maxAge: number; -} - -export interface SelectedPeerCoin { - coinPub: string; - coinPriv: string; - contribution: AmountString; - denomPubHash: string; - denomSig: UnblindedSignature; - ageCommitmentProof: AgeCommitmentProof | undefined; -} - -export interface PeerCoinSelectionDetails { - exchangeBaseUrl: string; - - /** - * Info of Coins that were selected. - */ - coins: SelectedPeerCoin[]; - - /** - * How much of the deposit fees is the customer paying? - */ - depositFees: AmountJson; - - maxExpirationDate: TalerProtocolTimestamp; -} - -export type SelectPeerCoinsResult = - | { type: "success"; result: PeerCoinSelectionDetails } - | { - type: "failure"; - insufficientBalanceDetails: PayPeerInsufficientBalanceDetails; - }; - -export interface PeerCoinRepair { - exchangeBaseUrl: string; - coinPubs: CoinPublicKeyString[]; - contribs: AmountJson[]; -} - -export interface PeerCoinSelectionRequest { - instructedAmount: AmountJson; - - /** - * Instruct the coin selection to repair this coin - * selection instead of selecting completely new coins. - */ - repair?: PeerCoinRepair; -} - -/** - * Get coin availability information for a certain exchange. - */ -async function selectPayPeerCandidatesForExchange( - ws: InternalWalletState, - tx: WalletDbReadOnlyTransaction<["coinAvailability", "denominations"]>, - exchangeBaseUrl: string, -): Promise<AvailableDenom[]> { - const denoms: AvailableDenom[] = []; - - let ageLower = 0; - let ageUpper = AgeRestriction.AGE_UNRESTRICTED; - const myExchangeCoins = - await tx.coinAvailability.indexes.byExchangeAgeAvailability.getAll( - GlobalIDB.KeyRange.bound( - [exchangeBaseUrl, ageLower, 1], - [exchangeBaseUrl, ageUpper, Number.MAX_SAFE_INTEGER], - ), - ); - - for (const coinAvail of myExchangeCoins) { - const denom = await tx.denominations.get([ - coinAvail.exchangeBaseUrl, - coinAvail.denomPubHash, - ]); - checkDbInvariant(!!denom); - if (denom.isRevoked || !denom.isOffered) { - continue; - } - denoms.push({ - ...DenominationRecord.toDenomInfo(denom), - numAvailable: coinAvail.freshCoinCount ?? 0, - maxAge: coinAvail.maxAge, - }); - } - // Sort by available amount (descending), deposit fee (ascending) and - // denomPub (ascending) if deposit fee is the same - // (to guarantee deterministic results) - denoms.sort( - (o1, o2) => - -Amounts.cmp(o1.value, o2.value) || - Amounts.cmp(o1.feeDeposit, o2.feeDeposit) || - strcmp(o1.denomPubHash, o2.denomPubHash), - ); - - return denoms; -} - -interface PeerCoinSelectionTally { - amountAcc: AmountJson; - depositFeesAcc: AmountJson; - lastDepositFee: AmountJson; -} - -/** - * exporting for testing - */ -export function testing_greedySelectPeer( - ...args: Parameters<typeof greedySelectPeer> -): ReturnType<typeof greedySelectPeer> { - return greedySelectPeer(...args); -} - -function greedySelectPeer( - candidates: AvailableDenom[], - instructedAmount: AmountLike, - tally: PeerCoinSelectionTally, -): SelResult | undefined { - const selectedDenom: SelResult = {}; - for (const denom of candidates) { - const contributions: AmountJson[] = []; - for ( - let i = 0; - i < denom.numAvailable && - Amounts.cmp(tally.amountAcc, instructedAmount) < 0; - i++ - ) { - const amountPayRemaining = Amounts.sub( - instructedAmount, - tally.amountAcc, - ).amount; - // Maximum amount the coin could effectively contribute. - const maxCoinContrib = Amounts.sub(denom.value, denom.feeDeposit).amount; - - const coinSpend = Amounts.min( - Amounts.add(amountPayRemaining, denom.feeDeposit).amount, - maxCoinContrib, - ); - - tally.amountAcc = Amounts.add(tally.amountAcc, coinSpend).amount; - tally.amountAcc = Amounts.sub(tally.amountAcc, denom.feeDeposit).amount; - - tally.depositFeesAcc = Amounts.add( - tally.depositFeesAcc, - denom.feeDeposit, - ).amount; - - tally.lastDepositFee = Amounts.parseOrThrow(denom.feeDeposit); - - contributions.push(coinSpend); - } - if (contributions.length > 0) { - const avKey = makeAvailabilityKey( - denom.exchangeBaseUrl, - denom.denomPubHash, - denom.maxAge, - ); - let sd = selectedDenom[avKey]; - if (!sd) { - sd = { - contributions: [], - denomPubHash: denom.denomPubHash, - exchangeBaseUrl: denom.exchangeBaseUrl, - maxAge: denom.maxAge, - expireDeposit: denom.stampExpireDeposit, - expireWithdraw: denom.stampExpireWithdraw, - }; - } - sd.contributions.push(...contributions); - selectedDenom[avKey] = sd; - } - if (Amounts.cmp(tally.amountAcc, instructedAmount) >= 0) { - break; - } - } - - if (Amounts.cmp(tally.amountAcc, instructedAmount) >= 0) { - return selectedDenom; - } - return undefined; -} - -export async function selectPeerCoins( - ws: InternalWalletState, - req: PeerCoinSelectionRequest, -): Promise<SelectPeerCoinsResult> { - const instructedAmount = req.instructedAmount; - if (Amounts.isZero(instructedAmount)) { - // Other parts of the code assume that we have at least - // one coin to spend. - throw new Error("amount of zero not allowed"); - } - return await ws.db.runReadWriteTx( - [ - "exchanges", - "contractTerms", - "coins", - "coinAvailability", - "denominations", - "refreshGroups", - "peerPushDebit", - ], - async (tx) => { - const exchanges = await tx.exchanges.iter().toArray(); - const exchangeFeeGap: { [url: string]: AmountJson } = {}; - const currency = Amounts.currencyOf(instructedAmount); - for (const exch of exchanges) { - if (exch.detailsPointer?.currency !== currency) { - continue; - } - const candidates = await selectPayPeerCandidatesForExchange( - ws, - tx, - exch.baseUrl, - ); - const tally: PeerCoinSelectionTally = { - amountAcc: Amounts.zeroOfCurrency(currency), - depositFeesAcc: Amounts.zeroOfCurrency(currency), - lastDepositFee: Amounts.zeroOfCurrency(currency), - }; - const resCoins: { - coinPub: string; - coinPriv: string; - contribution: AmountString; - denomPubHash: string; - denomSig: UnblindedSignature; - ageCommitmentProof: AgeCommitmentProof | undefined; - }[] = []; - - if (req.repair && req.repair.exchangeBaseUrl === exch.baseUrl) { - for (let i = 0; i < req.repair.coinPubs.length; i++) { - const contrib = req.repair.contribs[i]; - const coin = await tx.coins.get(req.repair.coinPubs[i]); - if (!coin) { - throw Error("repair not possible, coin not found"); - } - const denom = await ws.getDenomInfo( - ws, - tx, - coin.exchangeBaseUrl, - coin.denomPubHash, - ); - checkDbInvariant(!!denom); - resCoins.push({ - coinPriv: coin.coinPriv, - coinPub: coin.coinPub, - contribution: Amounts.stringify(contrib), - denomPubHash: coin.denomPubHash, - denomSig: coin.denomSig, - ageCommitmentProof: coin.ageCommitmentProof, - }); - const depositFee = Amounts.parseOrThrow(denom.feeDeposit); - tally.lastDepositFee = depositFee; - tally.amountAcc = Amounts.add( - tally.amountAcc, - Amounts.sub(contrib, depositFee).amount, - ).amount; - tally.depositFeesAcc = Amounts.add( - tally.depositFeesAcc, - depositFee, - ).amount; - } - } - - const selectedDenom = greedySelectPeer( - candidates, - instructedAmount, - tally, - ); - - if (selectedDenom) { - let minAutorefreshExecuteThreshold = TalerProtocolTimestamp.never(); - for (const dph of Object.keys(selectedDenom)) { - const selInfo = selectedDenom[dph]; - // Compute earliest time that a selected denom - // would have its coins auto-refreshed. - minAutorefreshExecuteThreshold = TalerProtocolTimestamp.min( - minAutorefreshExecuteThreshold, - AbsoluteTime.toProtocolTimestamp( - getAutoRefreshExecuteThreshold({ - stampExpireDeposit: selInfo.expireDeposit, - stampExpireWithdraw: selInfo.expireWithdraw, - }), - ), - ); - const numRequested = selInfo.contributions.length; - const query = [ - selInfo.exchangeBaseUrl, - selInfo.denomPubHash, - selInfo.maxAge, - CoinStatus.Fresh, - ]; - logger.info(`query: ${j2s(query)}`); - const coins = - await tx.coins.indexes.byExchangeDenomPubHashAndAgeAndStatus.getAll( - query, - numRequested, - ); - if (coins.length != numRequested) { - throw Error( - `coin selection failed (not available anymore, got only ${coins.length}/${numRequested})`, - ); - } - for (let i = 0; i < selInfo.contributions.length; i++) { - resCoins.push({ - coinPriv: coins[i].coinPriv, - coinPub: coins[i].coinPub, - contribution: Amounts.stringify(selInfo.contributions[i]), - ageCommitmentProof: coins[i].ageCommitmentProof, - denomPubHash: selInfo.denomPubHash, - denomSig: coins[i].denomSig, - }); - } - } - - const res: PeerCoinSelectionDetails = { - exchangeBaseUrl: exch.baseUrl, - coins: resCoins, - depositFees: tally.depositFeesAcc, - maxExpirationDate: minAutorefreshExecuteThreshold, - }; - return { type: "success", result: res }; - } - - const diff = Amounts.sub(instructedAmount, tally.amountAcc).amount; - exchangeFeeGap[exch.baseUrl] = Amounts.add( - tally.lastDepositFee, - diff, - ).amount; - - continue; - } - - // We were unable to select coins. - // Now we need to produce error details. - - const infoGeneral = await getPeerPaymentBalanceDetailsInTx(ws, tx, { - currency, - }); - - const perExchange: PayPeerInsufficientBalanceDetails["perExchange"] = {}; - - let maxFeeGapEstimate = Amounts.zeroOfCurrency(currency); - - for (const exch of exchanges) { - if (exch.detailsPointer?.currency !== currency) { - continue; - } - const infoExchange = await getPeerPaymentBalanceDetailsInTx(ws, tx, { - currency, - restrictExchangeTo: exch.baseUrl, - }); - let gap = - exchangeFeeGap[exch.baseUrl] ?? Amounts.zeroOfCurrency(currency); - if (Amounts.cmp(infoExchange.balanceMaterial, instructedAmount) < 0) { - // Show fee gap only if we should've been able to pay with the material amount - gap = Amounts.zeroOfCurrency(currency); - } - perExchange[exch.baseUrl] = { - balanceAvailable: Amounts.stringify(infoExchange.balanceAvailable), - balanceMaterial: Amounts.stringify(infoExchange.balanceMaterial), - feeGapEstimate: Amounts.stringify(gap), - }; - - maxFeeGapEstimate = Amounts.max(maxFeeGapEstimate, gap); - } - - const errDetails: PayPeerInsufficientBalanceDetails = { - amountRequested: Amounts.stringify(instructedAmount), - balanceAvailable: Amounts.stringify(infoGeneral.balanceAvailable), - balanceMaterial: Amounts.stringify(infoGeneral.balanceMaterial), - feeGapEstimate: Amounts.stringify(maxFeeGapEstimate), - perExchange, - }; - - return { type: "failure", insufficientBalanceDetails: errDetails }; - }, - ); -} diff --git a/packages/taler-wallet-core/src/util/denominations.test.ts b/packages/taler-wallet-core/src/util/denominations.test.ts deleted file mode 100644 index 98af5d1a4..000000000 --- a/packages/taler-wallet-core/src/util/denominations.test.ts +++ /dev/null @@ -1,870 +0,0 @@ -/* - This file is part of GNU Taler - (C) 2022 Taler Systems S.A. - - 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/> - */ - -/** - * - * @author Sebastian Javier Marchano (sebasjm) - */ - -import { - AbsoluteTime, - FeeDescription, - FeeDescriptionPair, - Amounts, - DenominationInfo, - AmountString, -} from "@gnu-taler/taler-util"; -// import { expect } from "chai"; -import { - createPairTimeline, - createTimeline, - selectBestForOverlappingDenominations, -} from "./denominations.js"; -import test, { ExecutionContext } from "ava"; - -/** - * Create some constants to be used as reference in the tests - */ -const VALUES: AmountString[] = Array.from({ length: 10 }).map( - (undef, t) => `USD:${t}` as AmountString, -); -const TIMESTAMPS = Array.from({ length: 20 }).map((undef, t_s) => ({ t_s })); -const ABS_TIME = TIMESTAMPS.map((m) => AbsoluteTime.fromProtocolTimestamp(m)); - -function normalize( - list: DenominationInfo[], -): (DenominationInfo & { group: string })[] { - return list.map((e, idx) => ({ - ...e, - denomPubHash: `id${idx}`, - group: Amounts.stringifyValue(e.value), - })); -} - -//Avoiding to make an error-prone/time-consuming refactor -//this function calls AVA's deepEqual from a chai interface -function expect(t: ExecutionContext, thing: any): any { - return { - deep: { - equal: (another: any) => t.deepEqual(thing, another), - equals: (another: any) => t.deepEqual(thing, another), - }, - }; -} - -// describe("Denomination timeline creation", (t) => { -// describe("single value example", (t) => { - -test("should have one row with start and exp", (t) => { - const timeline = createTimeline( - normalize([ - { - value: VALUES[1], - stampStart: TIMESTAMPS[1], - stampExpireDeposit: TIMESTAMPS[2], - feeDeposit: VALUES[1], - } as Partial<DenominationInfo> as DenominationInfo, - ]), - "denomPubHash", - "stampStart", - "stampExpireDeposit", - "feeDeposit", - "group", - selectBestForOverlappingDenominations, - ); - - expect(t, timeline).deep.equal([ - { - group: Amounts.stringifyValue(VALUES[1]), - from: ABS_TIME[1], - until: ABS_TIME[2], - fee: VALUES[1], - } as FeeDescription, - ]); -}); - -test("should have two rows with the second denom in the middle if second is better", (t) => { - const timeline = createTimeline( - normalize([ - { - value: VALUES[1], - stampStart: TIMESTAMPS[1], - stampExpireDeposit: TIMESTAMPS[3], - feeDeposit: VALUES[1], - } as Partial<DenominationInfo> as DenominationInfo, - { - value: VALUES[1], - stampStart: TIMESTAMPS[2], - stampExpireDeposit: TIMESTAMPS[4], - feeDeposit: VALUES[2], - } as Partial<DenominationInfo> as DenominationInfo, - ]), - "denomPubHash", - "stampStart", - "stampExpireDeposit", - "feeDeposit", - "group", - selectBestForOverlappingDenominations, - ); - - expect(t, timeline).deep.equal([ - { - group: Amounts.stringifyValue(VALUES[1]), - from: ABS_TIME[1], - until: ABS_TIME[3], - fee: VALUES[1], - }, - { - group: Amounts.stringifyValue(VALUES[1]), - from: ABS_TIME[3], - until: ABS_TIME[4], - fee: VALUES[2], - }, - ] as FeeDescription[]); -}); - -test("should have two rows with the first denom in the middle if second is worse", (t) => { - const timeline = createTimeline( - normalize([ - { - value: VALUES[1], - stampStart: TIMESTAMPS[1], - stampExpireDeposit: TIMESTAMPS[3], - feeDeposit: VALUES[2], - } as Partial<DenominationInfo> as DenominationInfo, - { - value: VALUES[1], - stampStart: TIMESTAMPS[2], - stampExpireDeposit: TIMESTAMPS[4], - feeDeposit: VALUES[1], - } as Partial<DenominationInfo> as DenominationInfo, - ]), - "denomPubHash", - "stampStart", - "stampExpireDeposit", - "feeDeposit", - "group", - selectBestForOverlappingDenominations, - ); - - expect(t, timeline).deep.equal([ - { - group: Amounts.stringifyValue(VALUES[1]), - from: ABS_TIME[1], - until: ABS_TIME[2], - fee: VALUES[2], - }, - { - group: Amounts.stringifyValue(VALUES[1]), - from: ABS_TIME[2], - until: ABS_TIME[4], - fee: VALUES[1], - }, - ] as FeeDescription[]); -}); - -test("should add a gap when there no fee", (t) => { - const timeline = createTimeline( - normalize([ - { - value: VALUES[1], - stampStart: TIMESTAMPS[1], - stampExpireDeposit: TIMESTAMPS[2], - feeDeposit: VALUES[2], - } as Partial<DenominationInfo> as DenominationInfo, - { - value: VALUES[1], - stampStart: TIMESTAMPS[3], - stampExpireDeposit: TIMESTAMPS[4], - feeDeposit: VALUES[1], - } as Partial<DenominationInfo> as DenominationInfo, - ]), - "denomPubHash", - "stampStart", - "stampExpireDeposit", - "feeDeposit", - "group", - selectBestForOverlappingDenominations, - ); - - expect(t, timeline).deep.equal([ - { - group: Amounts.stringifyValue(VALUES[1]), - from: ABS_TIME[1], - until: ABS_TIME[2], - fee: VALUES[2], - }, - { - group: Amounts.stringifyValue(VALUES[1]), - from: ABS_TIME[2], - until: ABS_TIME[3], - }, - { - group: Amounts.stringifyValue(VALUES[1]), - from: ABS_TIME[3], - until: ABS_TIME[4], - fee: VALUES[1], - }, - ] as FeeDescription[]); -}); - -test("should have three rows when first denom is between second and second is worse", (t) => { - const timeline = createTimeline( - normalize([ - { - value: VALUES[1], - stampStart: TIMESTAMPS[2], - stampExpireDeposit: TIMESTAMPS[3], - feeDeposit: VALUES[1], - } as Partial<DenominationInfo> as DenominationInfo, - { - value: VALUES[1], - stampStart: TIMESTAMPS[1], - stampExpireDeposit: TIMESTAMPS[4], - feeDeposit: VALUES[2], - } as Partial<DenominationInfo> as DenominationInfo, - ]), - "denomPubHash", - "stampStart", - "stampExpireDeposit", - "feeDeposit", - "group", - selectBestForOverlappingDenominations, - ); - expect(t, timeline).deep.equal([ - { - group: Amounts.stringifyValue(VALUES[1]), - from: ABS_TIME[1], - until: ABS_TIME[2], - fee: VALUES[2], - }, - { - group: Amounts.stringifyValue(VALUES[1]), - from: ABS_TIME[2], - until: ABS_TIME[3], - fee: VALUES[1], - }, - { - group: Amounts.stringifyValue(VALUES[1]), - from: ABS_TIME[3], - until: ABS_TIME[4], - fee: VALUES[2], - }, - ] as FeeDescription[]); -}); - -test("should have one row when first denom is between second and second is better", (t) => { - const timeline = createTimeline( - normalize([ - { - value: VALUES[1], - stampStart: TIMESTAMPS[2], - stampExpireDeposit: TIMESTAMPS[3], - feeDeposit: VALUES[2], - } as Partial<DenominationInfo> as DenominationInfo, - { - value: VALUES[1], - stampStart: TIMESTAMPS[1], - stampExpireDeposit: TIMESTAMPS[4], - feeDeposit: VALUES[1], - } as Partial<DenominationInfo> as DenominationInfo, - ]), - "denomPubHash", - "stampStart", - "stampExpireDeposit", - "feeDeposit", - "group", - selectBestForOverlappingDenominations, - ); - - expect(t, timeline).deep.equal([ - { - group: Amounts.stringifyValue(VALUES[1]), - from: ABS_TIME[1], - until: ABS_TIME[4], - fee: VALUES[1], - }, - ] as FeeDescription[]); -}); - -test("should only add the best1", (t) => { - const timeline = createTimeline( - normalize([ - { - value: VALUES[1], - stampStart: TIMESTAMPS[1], - stampExpireDeposit: TIMESTAMPS[3], - feeDeposit: VALUES[2], - } as Partial<DenominationInfo> as DenominationInfo, - { - value: VALUES[1], - stampStart: TIMESTAMPS[2], - stampExpireDeposit: TIMESTAMPS[4], - feeDeposit: VALUES[1], - } as Partial<DenominationInfo> as DenominationInfo, - { - value: VALUES[1], - stampStart: TIMESTAMPS[2], - stampExpireDeposit: TIMESTAMPS[4], - feeDeposit: VALUES[2], - } as Partial<DenominationInfo> as DenominationInfo, - ]), - "denomPubHash", - "stampStart", - "stampExpireDeposit", - "feeDeposit", - "group", - selectBestForOverlappingDenominations, - ); - - expect(t, timeline).deep.equal([ - { - group: Amounts.stringifyValue(VALUES[1]), - from: ABS_TIME[1], - until: ABS_TIME[2], - fee: VALUES[2], - }, - { - group: Amounts.stringifyValue(VALUES[1]), - from: ABS_TIME[2], - until: ABS_TIME[4], - fee: VALUES[1], - }, - ] as FeeDescription[]); -}); - -test("should only add the best2", (t) => { - const timeline = createTimeline( - normalize([ - { - value: VALUES[1], - stampStart: TIMESTAMPS[1], - stampExpireDeposit: TIMESTAMPS[3], - feeDeposit: VALUES[2], - } as Partial<DenominationInfo> as DenominationInfo, - { - value: VALUES[1], - stampStart: TIMESTAMPS[2], - stampExpireDeposit: TIMESTAMPS[5], - feeDeposit: VALUES[1], - } as Partial<DenominationInfo> as DenominationInfo, - { - value: VALUES[1], - stampStart: TIMESTAMPS[2], - stampExpireDeposit: TIMESTAMPS[4], - feeDeposit: VALUES[2], - } as Partial<DenominationInfo> as DenominationInfo, - { - value: VALUES[1], - stampStart: TIMESTAMPS[5], - stampExpireDeposit: TIMESTAMPS[6], - feeDeposit: VALUES[3], - } as Partial<DenominationInfo> as DenominationInfo, - ]), - "denomPubHash", - "stampStart", - "stampExpireDeposit", - "feeDeposit", - "group", - selectBestForOverlappingDenominations, - ); - - expect(t, timeline).deep.equal([ - { - group: Amounts.stringifyValue(VALUES[1]), - from: ABS_TIME[1], - until: ABS_TIME[2], - fee: VALUES[2], - }, - { - group: Amounts.stringifyValue(VALUES[1]), - from: ABS_TIME[2], - until: ABS_TIME[5], - fee: VALUES[1], - }, - { - group: Amounts.stringifyValue(VALUES[1]), - from: ABS_TIME[5], - until: ABS_TIME[6], - fee: VALUES[3], - }, - ] as FeeDescription[]); -}); - -test("should only add the best3", (t) => { - const timeline = createTimeline( - normalize([ - { - value: VALUES[1], - stampStart: TIMESTAMPS[2], - stampExpireDeposit: TIMESTAMPS[5], - feeDeposit: VALUES[3], - } as Partial<DenominationInfo> as DenominationInfo, - { - value: VALUES[1], - stampStart: TIMESTAMPS[2], - stampExpireDeposit: TIMESTAMPS[5], - feeDeposit: VALUES[1], - } as Partial<DenominationInfo> as DenominationInfo, - { - value: VALUES[1], - stampStart: TIMESTAMPS[2], - stampExpireDeposit: TIMESTAMPS[5], - feeDeposit: VALUES[2], - } as Partial<DenominationInfo> as DenominationInfo, - ]), - "denomPubHash", - "stampStart", - "stampExpireDeposit", - "feeDeposit", - "group", - selectBestForOverlappingDenominations, - ); - - expect(t, timeline).deep.equal([ - { - group: Amounts.stringifyValue(VALUES[1]), - from: ABS_TIME[2], - until: ABS_TIME[5], - fee: VALUES[1], - }, - ] as FeeDescription[]); -}); -// }) - -// describe("multiple value example", (t) => { - -//TODO: test the same start but different value - -test("should not merge when there is different value", (t) => { - const timeline = createTimeline( - normalize([ - { - value: VALUES[1], - stampStart: TIMESTAMPS[1], - stampExpireDeposit: TIMESTAMPS[3], - feeDeposit: VALUES[1], - } as Partial<DenominationInfo> as DenominationInfo, - { - value: VALUES[2], - stampStart: TIMESTAMPS[2], - stampExpireDeposit: TIMESTAMPS[4], - feeDeposit: VALUES[2], - } as Partial<DenominationInfo> as DenominationInfo, - ]), - "denomPubHash", - "stampStart", - "stampExpireDeposit", - "feeDeposit", - "group", - selectBestForOverlappingDenominations, - ); - - expect(t, timeline).deep.equal([ - { - group: Amounts.stringifyValue(VALUES[1]), - from: ABS_TIME[1], - until: ABS_TIME[3], - fee: VALUES[1], - }, - { - group: Amounts.stringifyValue(VALUES[2]), - from: ABS_TIME[2], - until: ABS_TIME[4], - fee: VALUES[2], - }, - ] as FeeDescription[]); -}); - -test("should not merge when there is different value (with duplicates)", (t) => { - const timeline = createTimeline( - normalize([ - { - value: VALUES[1], - stampStart: TIMESTAMPS[1], - stampExpireDeposit: TIMESTAMPS[3], - feeDeposit: VALUES[1], - } as Partial<DenominationInfo> as DenominationInfo, - { - value: VALUES[2], - stampStart: TIMESTAMPS[2], - stampExpireDeposit: TIMESTAMPS[4], - feeDeposit: VALUES[2], - } as Partial<DenominationInfo> as DenominationInfo, - { - value: VALUES[1], - stampStart: TIMESTAMPS[1], - stampExpireDeposit: TIMESTAMPS[3], - feeDeposit: VALUES[1], - } as Partial<DenominationInfo> as DenominationInfo, - { - value: VALUES[2], - stampStart: TIMESTAMPS[2], - stampExpireDeposit: TIMESTAMPS[4], - feeDeposit: VALUES[2], - } as Partial<DenominationInfo> as DenominationInfo, - ]), - "denomPubHash", - "stampStart", - "stampExpireDeposit", - "feeDeposit", - "group", - selectBestForOverlappingDenominations, - ); - - expect(t, timeline).deep.equal([ - { - group: Amounts.stringifyValue(VALUES[1]), - from: ABS_TIME[1], - until: ABS_TIME[3], - fee: VALUES[1], - }, - { - group: Amounts.stringifyValue(VALUES[2]), - from: ABS_TIME[2], - until: ABS_TIME[4], - fee: VALUES[2], - }, - ] as FeeDescription[]); -}); - -// it.skip("real world example: bitcoin exchange", (t) => { -// const timeline = createDenominationTimeline( -// bitcoinExchanges[0].denominations.filter(d => Amounts.cmp(d.value, Amounts.parseOrThrow('BITCOINBTC:0.01048576'))), -// "stampExpireDeposit", "feeDeposit"); - -// expect(t,timeline).deep.equal([{ -// fee: Amounts.parseOrThrow('BITCOINBTC:0.00000001'), -// from: { t_ms: 1652978648000 }, -// until: { t_ms: 1699633748000 }, -// value: Amounts.parseOrThrow('BITCOINBTC:0.01048576'), -// }, { -// fee: Amounts.parseOrThrow('BITCOINBTC:0.00000003'), -// from: { t_ms: 1699633748000 }, -// until: { t_ms: 1707409448000 }, -// value: Amounts.parseOrThrow('BITCOINBTC:0.01048576'), -// }] as FeeDescription[]) -// }) - -// }) - -// }) - -// describe("Denomination timeline pair creation", (t) => { - -// describe("single value example", (t) => { - -test("should return empty", (t) => { - const left = [] as FeeDescription[]; - const right = [] as FeeDescription[]; - - const pairs = createPairTimeline(left, right); - - expect(t, pairs).deep.equals([]); -}); - -test("should return first element", (t) => { - const left = [ - { - group: Amounts.stringifyValue(VALUES[1]), - from: ABS_TIME[1], - until: ABS_TIME[3], - fee: VALUES[1], - }, - ] as FeeDescription[]; - - const right = [] as FeeDescription[]; - - { - const pairs = createPairTimeline(left, right); - expect(t, pairs).deep.equals([ - { - from: ABS_TIME[1], - until: ABS_TIME[3], - group: Amounts.stringifyValue(VALUES[1]), - left: VALUES[1], - right: undefined, - }, - ] as FeeDescriptionPair[]); - } - { - const pairs = createPairTimeline(right, left); - expect(t, pairs).deep.equals([ - { - from: ABS_TIME[1], - until: ABS_TIME[3], - group: Amounts.stringifyValue(VALUES[1]), - right: VALUES[1], - left: undefined, - }, - ] as FeeDescriptionPair[]); - } -}); - -test("should add both to the same row", (t) => { - const left = [ - { - group: Amounts.stringifyValue(VALUES[1]), - from: ABS_TIME[1], - until: ABS_TIME[3], - fee: VALUES[1], - }, - ] as FeeDescription[]; - - const right = [ - { - group: Amounts.stringifyValue(VALUES[1]), - from: ABS_TIME[1], - until: ABS_TIME[3], - fee: VALUES[2], - }, - ] as FeeDescription[]; - - { - const pairs = createPairTimeline(left, right); - expect(t, pairs).deep.equals([ - { - from: ABS_TIME[1], - until: ABS_TIME[3], - group: Amounts.stringifyValue(VALUES[1]), - left: VALUES[1], - right: VALUES[2], - }, - ] as FeeDescriptionPair[]); - } - { - const pairs = createPairTimeline(right, left); - expect(t, pairs).deep.equals([ - { - from: ABS_TIME[1], - until: ABS_TIME[3], - group: Amounts.stringifyValue(VALUES[1]), - left: VALUES[2], - right: VALUES[1], - }, - ] as FeeDescriptionPair[]); - } -}); - -test("should repeat the first and change the second", (t) => { - const left = [ - { - group: Amounts.stringifyValue(VALUES[1]), - from: ABS_TIME[1], - until: ABS_TIME[5], - fee: VALUES[1], - }, - ] as FeeDescription[]; - - const right = [ - { - group: Amounts.stringifyValue(VALUES[1]), - from: ABS_TIME[1], - until: ABS_TIME[2], - fee: VALUES[2], - }, - { - group: Amounts.stringifyValue(VALUES[1]), - from: ABS_TIME[2], - until: ABS_TIME[3], - }, - { - group: Amounts.stringifyValue(VALUES[1]), - from: ABS_TIME[3], - until: ABS_TIME[4], - fee: VALUES[3], - }, - ] as FeeDescription[]; - - { - const pairs = createPairTimeline(left, right); - expect(t, pairs).deep.equals([ - { - from: ABS_TIME[1], - until: ABS_TIME[2], - group: Amounts.stringifyValue(VALUES[1]), - left: VALUES[1], - right: VALUES[2], - }, - { - from: ABS_TIME[2], - until: ABS_TIME[3], - group: Amounts.stringifyValue(VALUES[1]), - left: VALUES[1], - right: undefined, - }, - { - from: ABS_TIME[3], - until: ABS_TIME[4], - group: Amounts.stringifyValue(VALUES[1]), - left: VALUES[1], - right: VALUES[3], - }, - { - from: ABS_TIME[4], - until: ABS_TIME[5], - group: Amounts.stringifyValue(VALUES[1]), - left: VALUES[1], - right: undefined, - }, - ] as FeeDescriptionPair[]); - } -}); - -// }) - -// describe("multiple value example", (t) => { - -test("should separate denominations of different value", (t) => { - const left = [ - { - group: Amounts.stringifyValue(VALUES[1]), - from: ABS_TIME[1], - until: ABS_TIME[3], - fee: VALUES[1], - }, - ] as FeeDescription[]; - - const right = [ - { - group: Amounts.stringifyValue(VALUES[2]), - from: ABS_TIME[1], - until: ABS_TIME[3], - fee: VALUES[2], - }, - ] as FeeDescription[]; - - { - const pairs = createPairTimeline(left, right); - expect(t, pairs).deep.equals([ - { - from: ABS_TIME[1], - until: ABS_TIME[3], - group: Amounts.stringifyValue(VALUES[1]), - left: VALUES[1], - right: undefined, - }, - { - from: ABS_TIME[1], - until: ABS_TIME[3], - group: Amounts.stringifyValue(VALUES[2]), - left: undefined, - right: VALUES[2], - }, - ] as FeeDescriptionPair[]); - } - { - const pairs = createPairTimeline(right, left); - expect(t, pairs).deep.equals([ - { - from: ABS_TIME[1], - until: ABS_TIME[3], - group: Amounts.stringifyValue(VALUES[1]), - left: undefined, - right: VALUES[1], - }, - { - from: ABS_TIME[1], - until: ABS_TIME[3], - group: Amounts.stringifyValue(VALUES[2]), - left: VALUES[2], - right: undefined, - }, - ] as FeeDescriptionPair[]); - } -}); - -test("should separate denominations of different value2", (t) => { - const left = [ - { - group: Amounts.stringifyValue(VALUES[1]), - from: ABS_TIME[1], - until: ABS_TIME[2], - fee: VALUES[1], - }, - { - group: Amounts.stringifyValue(VALUES[1]), - from: ABS_TIME[2], - until: ABS_TIME[4], - fee: VALUES[2], - }, - ] as FeeDescription[]; - - const right = [ - { - group: Amounts.stringifyValue(VALUES[2]), - from: ABS_TIME[1], - until: ABS_TIME[3], - fee: VALUES[2], - }, - ] as FeeDescription[]; - - { - const pairs = createPairTimeline(left, right); - expect(t, pairs).deep.equals([ - { - from: ABS_TIME[1], - until: ABS_TIME[2], - group: Amounts.stringifyValue(VALUES[1]), - left: VALUES[1], - right: undefined, - }, - { - from: ABS_TIME[2], - until: ABS_TIME[4], - group: Amounts.stringifyValue(VALUES[1]), - left: VALUES[2], - right: undefined, - }, - { - from: ABS_TIME[1], - until: ABS_TIME[3], - group: Amounts.stringifyValue(VALUES[2]), - left: undefined, - right: VALUES[2], - }, - ] as FeeDescriptionPair[]); - } - // { - // const pairs = createDenominationPairTimeline(right, left) - // expect(t,pairs).deep.equals([{ - // from: moments[1], - // until: moments[3], - // value: values[1], - // left: undefined, - // right: values[1], - // }, { - // from: moments[1], - // until: moments[3], - // value: values[2], - // left: values[2], - // right: undefined, - // }] as FeeDescriptionPair[]) - // } -}); -// it.skip("should render real world", (t) => { -// const left = createDenominationTimeline( -// bitcoinExchanges[0].denominations.filter(d => Amounts.cmp(d.value, Amounts.parseOrThrow('BITCOINBTC:0.01048576'))), -// "stampExpireDeposit", "feeDeposit"); -// const right = createDenominationTimeline( -// bitcoinExchanges[1].denominations.filter(d => Amounts.cmp(d.value, Amounts.parseOrThrow('BITCOINBTC:0.01048576'))), -// "stampExpireDeposit", "feeDeposit"); - -// const pairs = createDenominationPairTimeline(left, right) -// }) - -// }) -// }) diff --git a/packages/taler-wallet-core/src/util/denominations.ts b/packages/taler-wallet-core/src/util/denominations.ts deleted file mode 100644 index 9557c078a..000000000 --- a/packages/taler-wallet-core/src/util/denominations.ts +++ /dev/null @@ -1,477 +0,0 @@ -/* - This file is part of GNU Taler - (C) 2021 Taler Systems S.A. - - 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/> - */ - -import { - AbsoluteTime, - AmountJson, - Amounts, - AmountString, - DenominationInfo, - Duration, - durationFromSpec, - FeeDescription, - FeeDescriptionPair, - TalerProtocolTimestamp, - TimePoint, -} from "@gnu-taler/taler-util"; -import { DenominationRecord, timestampProtocolFromDb } from "../db.js"; - -/** - * Given a list of denominations with the same value and same period of time: - * return the one that will be used. - * The best denomination is the one that will minimize the fee cost. - * - * @param list denominations of same value - * @returns - */ -export function selectBestForOverlappingDenominations< - T extends DenominationInfo, ->(list: T[]): T | undefined { - let minDeposit: DenominationInfo | undefined = undefined; - //TODO: improve denomination selection, this is a trivial implementation - list.forEach((e) => { - if (minDeposit === undefined) { - minDeposit = e; - return; - } - if (Amounts.cmp(minDeposit.feeDeposit, e.feeDeposit) > -1) { - minDeposit = e; - } - }); - return minDeposit; -} - -export function selectMinimumFee<T extends { fee: AmountString }>( - list: T[], -): T | undefined { - let minFee: T | undefined = undefined; - //TODO: improve denomination selection, this is a trivial implementation - list.forEach((e) => { - if (minFee === undefined) { - minFee = e; - return; - } - if (Amounts.cmp(minFee.fee, e.fee) > -1) { - minFee = e; - } - }); - return minFee; -} - -type PropsWithReturnType<T extends object, F> = Exclude< - { - [K in keyof T]: T[K] extends F ? K : never; - }[keyof T], - undefined ->; - -/** - * Takes two timelines and create one to compare them. - * - * For both lists the next condition should be true: - * for any element in the position "idx" then - * list[idx].until === list[idx+1].from - * - * @see {createTimeline} - * - * @param left list denominations @type {FeeDescription} - * @param right list denominations @type {FeeDescription} - * @returns list of pairs for the same time - */ -export function createPairTimeline( - left: FeeDescription[], - right: FeeDescription[], -): FeeDescriptionPair[] { - //FIXME: we need to create a copy of the array because - //this algorithm is using splice, remove splice and - //remove this array duplication - left = [...left]; - right = [...right]; - - //both list empty, discarded - if (left.length === 0 && right.length === 0) return []; - - const pairList: FeeDescriptionPair[] = []; - - let li = 0; //left list index - let ri = 0; //right list index - - while (li < left.length && ri < right.length) { - const currentGroup = - Number.parseFloat(left[li].group) < Number.parseFloat(right[ri].group) - ? left[li].group - : right[ri].group; - const lgs = li; //left group start index - const rgs = ri; //right group start index - - let lgl = 0; //left group length (until next value) - while (li + lgl < left.length && left[li + lgl].group === currentGroup) { - lgl++; - } - let rgl = 0; //right group length (until next value) - while (ri + rgl < right.length && right[ri + rgl].group === currentGroup) { - rgl++; - } - const leftGroupIsEmpty = lgl === 0; - const rightGroupIsEmpty = rgl === 0; - //check which start after, add gap so both list starts at the same time - // one list may be empty - const leftStartTime: AbsoluteTime = leftGroupIsEmpty - ? AbsoluteTime.never() - : left[li].from; - const rightStartTime: AbsoluteTime = rightGroupIsEmpty - ? AbsoluteTime.never() - : right[ri].from; - - //first time cut is the smallest time - let timeCut: AbsoluteTime = leftStartTime; - - if (AbsoluteTime.cmp(leftStartTime, rightStartTime) < 0) { - const ends = rightGroupIsEmpty ? left[li + lgl - 1].until : right[0].from; - - right.splice(ri, 0, { - from: leftStartTime, - until: ends, - group: left[li].group, - }); - rgl++; - - timeCut = leftStartTime; - } - if (AbsoluteTime.cmp(leftStartTime, rightStartTime) > 0) { - const ends = leftGroupIsEmpty ? right[ri + rgl - 1].until : left[0].from; - - left.splice(li, 0, { - from: rightStartTime, - until: ends, - group: right[ri].group, - }); - lgl++; - - timeCut = rightStartTime; - } - - //check which ends sooner, add gap so both list ends at the same time - // here both list are non empty - const leftEndTime: AbsoluteTime = left[li + lgl - 1].until; - const rightEndTime: AbsoluteTime = right[ri + rgl - 1].until; - - if (AbsoluteTime.cmp(leftEndTime, rightEndTime) > 0) { - right.splice(ri + rgl, 0, { - from: rightEndTime, - until: leftEndTime, - group: left[0].group, - }); - rgl++; - } - if (AbsoluteTime.cmp(leftEndTime, rightEndTime) < 0) { - left.splice(li + lgl, 0, { - from: leftEndTime, - until: rightEndTime, - group: right[0].group, - }); - lgl++; - } - - //now both lists are non empty and (starts,ends) at the same time - while (li < lgs + lgl && ri < rgs + rgl) { - if ( - AbsoluteTime.cmp(left[li].from, timeCut) !== 0 && - AbsoluteTime.cmp(right[ri].from, timeCut) !== 0 - ) { - // timeCut comes from the latest "until" (expiration from the previous) - // and this value comes from the latest left or right - // it should be the same as the "from" from one of the latest left or right - // otherwise it means that there is missing a gap object in the middle - // the list is not complete and the behavior is undefined - throw Error( - "one of the list is not completed: list[i].until !== list[i+1].from", - ); - } - - pairList.push({ - left: left[li].fee, - right: right[ri].fee, - from: timeCut, - until: AbsoluteTime.never(), - group: currentGroup, - }); - - if (left[li].until.t_ms === right[ri].until.t_ms) { - timeCut = left[li].until; - ri++; - li++; - } else if (left[li].until.t_ms < right[ri].until.t_ms) { - timeCut = left[li].until; - li++; - } else if (left[li].until.t_ms > right[ri].until.t_ms) { - timeCut = right[ri].until; - ri++; - } - pairList[pairList.length - 1].until = timeCut; - - // if ( - // (li < left.length && left[li].group !== currentGroup) || - // (ri < right.length && right[ri].group !== currentGroup) - // ) { - // //value changed, should break - // //this if will catch when both (left and right) change at the same time - // //if just one side changed it will catch in the while condition - // break; - // } - } - } - //one of the list left or right can still have elements - if (li < left.length) { - let timeCut = - pairList.length > 0 && - pairList[pairList.length - 1].group === left[li].group - ? pairList[pairList.length - 1].until - : left[li].from; - while (li < left.length) { - pairList.push({ - left: left[li].fee, - right: undefined, - from: timeCut, - until: left[li].until, - group: left[li].group, - }); - timeCut = left[li].until; - li++; - } - } - if (ri < right.length) { - let timeCut = - pairList.length > 0 && - pairList[pairList.length - 1].group === right[ri].group - ? pairList[pairList.length - 1].until - : right[ri].from; - while (ri < right.length) { - pairList.push({ - right: right[ri].fee, - left: undefined, - from: timeCut, - until: right[ri].until, - group: right[ri].group, - }); - timeCut = right[ri].until; - ri++; - } - } - return pairList; -} - -/** - * Create a usage timeline with the entity given. - * - * If there are multiple entities that can be used in the same period, - * the list will contain the one that minimize the fee cost. - * @see selectBestForOverlappingDenominations - * - * @param list list of entities - * @param idProp property used for identification - * @param periodStartProp property of element of the list that will be used as start of the usage period - * @param periodEndProp property of element of the list that will be used as end of the usage period - * @param feeProp property of the element of the list that will be used as fee reference - * @param groupProp property of the element of the list that will be used for grouping - * @returns list of @type {FeeDescription} sorted by usage period - */ -export function createTimeline<Type extends object>( - list: Type[], - idProp: PropsWithReturnType<Type, string>, - periodStartProp: PropsWithReturnType<Type, TalerProtocolTimestamp>, - periodEndProp: PropsWithReturnType<Type, TalerProtocolTimestamp>, - feeProp: PropsWithReturnType<Type, AmountString>, - groupProp: PropsWithReturnType<Type, string> | undefined, - selectBestForOverlapping: (l: Type[]) => Type | undefined, -): FeeDescription[] { - /** - * First we create a list with with point in the timeline sorted - * by time and categorized by starting or ending. - */ - const sortedPointsInTime = list - .reduce((ps, denom) => { - //exclude denoms with bad configuration - const id = denom[idProp] as string; - const stampStart = denom[periodStartProp] as TalerProtocolTimestamp; - const stampEnd = denom[periodEndProp] as TalerProtocolTimestamp; - const fee = denom[feeProp] as AmountJson; - const group = !groupProp ? "" : (denom[groupProp] as string); - - if (!id) { - throw Error( - `denomination without hash ${JSON.stringify(denom, undefined, 2)}`, - ); - } - if (stampStart.t_s >= stampEnd.t_s) { - throw Error(`denom ${id} has start after the end`); - } - ps.push({ - type: "start", - fee: Amounts.stringify(fee), - group, - id, - moment: AbsoluteTime.fromProtocolTimestamp(stampStart), - denom, - }); - ps.push({ - type: "end", - fee: Amounts.stringify(fee), - group, - id, - moment: AbsoluteTime.fromProtocolTimestamp(stampEnd), - denom, - }); - return ps; - }, [] as TimePoint<Type>[]) - .sort((a, b) => { - const v = a.group == b.group ? 0 : a.group > b.group ? 1 : -1; - if (v != 0) return v; - const t = AbsoluteTime.cmp(a.moment, b.moment); - if (t != 0) return t; - if (a.type === b.type) return 0; - return a.type === "start" ? 1 : -1; - }); - - const activeAtTheSameTime: Type[] = []; - return sortedPointsInTime.reduce((result, cursor, idx) => { - /** - * Now that we have move one step forward, we should - * update the previous element ending period with the - * current start time. - */ - let prev = result.length > 0 ? result[result.length - 1] : undefined; - const prevHasSameValue = prev && prev.group == cursor.group; - if (prev) { - if (prevHasSameValue) { - prev.until = cursor.moment; - - if (prev.from.t_ms === prev.until.t_ms) { - result.pop(); - prev = result[result.length - 1]; - } - } else { - // the last end adds a gap that we have to remove - result.pop(); - } - } - - /** - * With the current moment in the iteration we - * should keep updated which entities are current - * active in this period of time. - */ - if (cursor.type === "end") { - const loc = activeAtTheSameTime.findIndex((v) => v[idProp] === cursor.id); - if (loc === -1) { - throw Error(`denomination ${cursor.id} has an end but no start`); - } - activeAtTheSameTime.splice(loc, 1); - } else if (cursor.type === "start") { - activeAtTheSameTime.push(cursor.denom); - } else { - const exhaustiveCheck: never = cursor.type; - throw new Error(`not TimePoint defined for type: ${exhaustiveCheck}`); - } - - if (idx == sortedPointsInTime.length - 1) { - /** - * This is the last element in the list, if we continue - * a gap will normally be added which is not necessary. - * Also, the last element should be ending and the list of active - * element should be empty - */ - if (cursor.type !== "end") { - throw Error( - `denomination ${cursor.id} starts after ending or doesn't have an ending`, - ); - } - if (activeAtTheSameTime.length > 0) { - throw Error( - `there are ${activeAtTheSameTime.length} denominations without ending`, - ); - } - return result; - } - - const current = selectBestForOverlapping(activeAtTheSameTime); - - if (current) { - /** - * We have a candidate to add in the list, check that we are - * not adding a duplicate. - * Next element in the list will defined the ending. - */ - const currentFee = current[feeProp] as AmountJson; - if ( - prev === undefined || //is the first - !prev.fee || //is a gap - Amounts.cmp(prev.fee, currentFee) !== 0 // prev has different fee - ) { - result.push({ - group: cursor.group, - from: cursor.moment, - until: AbsoluteTime.never(), //not yet known - fee: Amounts.stringify(currentFee), - }); - } else { - prev.until = cursor.moment; - } - } else { - /** - * No active element in this period of time, so we add a gap (no fee) - * Next element in the list will defined the ending. - */ - result.push({ - group: cursor.group, - from: cursor.moment, - until: AbsoluteTime.never(), //not yet known - }); - } - - return result; - }, [] as FeeDescription[]); -} - -/** - * Check if a denom is withdrawable based on the expiration time, - * revocation and offered state. - */ -export function isWithdrawableDenom( - d: DenominationRecord, - denomselAllowLate?: boolean, -): boolean { - const now = AbsoluteTime.now(); - const start = AbsoluteTime.fromProtocolTimestamp( - timestampProtocolFromDb(d.stampStart), - ); - const withdrawExpire = AbsoluteTime.fromProtocolTimestamp( - timestampProtocolFromDb(d.stampExpireWithdraw), - ); - const started = AbsoluteTime.cmp(now, start) >= 0; - let lastPossibleWithdraw: AbsoluteTime; - if (denomselAllowLate) { - lastPossibleWithdraw = start; - } else { - lastPossibleWithdraw = AbsoluteTime.subtractDuraction( - withdrawExpire, - durationFromSpec({ minutes: 5 }), - ); - } - const remaining = Duration.getRemaining(lastPossibleWithdraw, now); - const stillOkay = remaining.d_ms !== 0; - return started && stillOkay && !d.isRevoked && d.isOffered; -} diff --git a/packages/taler-wallet-core/src/util/instructedAmountConversion.test.ts b/packages/taler-wallet-core/src/util/instructedAmountConversion.test.ts deleted file mode 100644 index 3b618f797..000000000 --- a/packages/taler-wallet-core/src/util/instructedAmountConversion.test.ts +++ /dev/null @@ -1,767 +0,0 @@ -/* - This file is part of GNU Taler - (C) 2022 Taler Systems S.A. - - 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/> - */ -import { - AbsoluteTime, - AgeRestriction, - AmountJson, - Amounts, - Duration, - TransactionAmountMode, -} from "@gnu-taler/taler-util"; -import test, { ExecutionContext } from "ava"; -import { CoinInfo } from "./coinSelection.js"; -import { - convertDepositAmountForAvailableCoins, - getMaxDepositAmountForAvailableCoins, - convertWithdrawalAmountFromAvailableCoins, -} from "./instructedAmountConversion.js"; - -function makeCurrencyHelper(currency: string) { - return (sx: TemplateStringsArray, ...vx: any[]) => { - const s = String.raw({ raw: sx }, ...vx); - return Amounts.parseOrThrow(`${currency}:${s}`); - }; -} - -const kudos = makeCurrencyHelper("kudos"); - -function defaultFeeConfig(value: AmountJson, totalAvailable: number): CoinInfo { - return { - id: Amounts.stringify(value), - denomDeposit: kudos`0.01`, - denomRefresh: kudos`0.01`, - denomWithdraw: kudos`0.01`, - exchangeBaseUrl: "1", - duration: Duration.getForever(), - exchangePurse: undefined, - exchangeWire: undefined, - maxAge: AgeRestriction.AGE_UNRESTRICTED, - totalAvailable, - value, - }; -} -type Coin = [AmountJson, number]; - -/** - * Making a deposit with effective amount - * - */ - -test("deposit effective 2", (t) => { - const coinList: Coin[] = [ - [kudos`2`, 5], - [kudos`5`, 5], - ]; - const result = convertDepositAmountForAvailableCoins( - { - list: coinList.map(([v, t]) => defaultFeeConfig(v, t)), - exchanges: {}, - }, - kudos`2`, - TransactionAmountMode.Effective, - ); - t.is(Amounts.stringifyValue(result.effective), "2"); - t.is(Amounts.stringifyValue(result.raw), "1.99"); -}); - -test("deposit effective 10", (t) => { - const coinList: Coin[] = [ - [kudos`2`, 5], - [kudos`5`, 5], - ]; - const result = convertDepositAmountForAvailableCoins( - { - list: coinList.map(([v, t]) => defaultFeeConfig(v, t)), - exchanges: {}, - }, - kudos`10`, - TransactionAmountMode.Effective, - ); - t.is(Amounts.stringifyValue(result.effective), "10"); - t.is(Amounts.stringifyValue(result.raw), "9.98"); -}); - -test("deposit effective 24", (t) => { - const coinList: Coin[] = [ - [kudos`2`, 5], - [kudos`5`, 5], - ]; - const result = convertDepositAmountForAvailableCoins( - { - list: coinList.map(([v, t]) => defaultFeeConfig(v, t)), - exchanges: {}, - }, - kudos`24`, - TransactionAmountMode.Effective, - ); - t.is(Amounts.stringifyValue(result.effective), "24"); - t.is(Amounts.stringifyValue(result.raw), "23.94"); -}); - -test("deposit effective 40", (t) => { - const coinList: Coin[] = [ - [kudos`2`, 5], - [kudos`5`, 5], - ]; - const result = convertDepositAmountForAvailableCoins( - { - list: coinList.map(([v, t]) => defaultFeeConfig(v, t)), - exchanges: {}, - }, - kudos`40`, - TransactionAmountMode.Effective, - ); - t.is(Amounts.stringifyValue(result.effective), "35"); - t.is(Amounts.stringifyValue(result.raw), "34.9"); -}); - -test("deposit with wire fee effective 2", (t) => { - const coinList: Coin[] = [ - [kudos`2`, 5], - [kudos`5`, 5], - ]; - const result = convertDepositAmountForAvailableCoins( - { - list: coinList.map(([v, t]) => defaultFeeConfig(v, t)), - exchanges: { - one: { - wireFee: kudos`0.1`, - purseFee: kudos`0.00`, - creditDeadline: AbsoluteTime.never(), - debitDeadline: AbsoluteTime.never(), - }, - }, - }, - kudos`2`, - TransactionAmountMode.Effective, - ); - t.is(Amounts.stringifyValue(result.effective), "2"); - t.is(Amounts.stringifyValue(result.raw), "1.89"); -}); - -/** - * Making a deposit with raw amount, using the result from effective - * - */ - -test("deposit raw 1.99 (effective 2)", (t) => { - const coinList: Coin[] = [ - [kudos`2`, 5], - [kudos`5`, 5], - ]; - const result = convertDepositAmountForAvailableCoins( - { - list: coinList.map(([v, t]) => defaultFeeConfig(v, t)), - exchanges: {}, - }, - kudos`1.99`, - TransactionAmountMode.Raw, - ); - t.is(Amounts.stringifyValue(result.effective), "2"); - t.is(Amounts.stringifyValue(result.raw), "1.99"); -}); - -test("deposit raw 9.98 (effective 10)", (t) => { - const coinList: Coin[] = [ - [kudos`2`, 5], - [kudos`5`, 5], - ]; - const result = convertDepositAmountForAvailableCoins( - { - list: coinList.map(([v, t]) => defaultFeeConfig(v, t)), - exchanges: {}, - }, - kudos`9.98`, - TransactionAmountMode.Raw, - ); - t.is(Amounts.stringifyValue(result.effective), "10"); - t.is(Amounts.stringifyValue(result.raw), "9.98"); -}); - -test("deposit raw 23.94 (effective 24)", (t) => { - const coinList: Coin[] = [ - [kudos`2`, 5], - [kudos`5`, 5], - ]; - const result = convertDepositAmountForAvailableCoins( - { - list: coinList.map(([v, t]) => defaultFeeConfig(v, t)), - exchanges: {}, - }, - kudos`23.94`, - TransactionAmountMode.Raw, - ); - t.is(Amounts.stringifyValue(result.effective), "24"); - t.is(Amounts.stringifyValue(result.raw), "23.94"); -}); - -test("deposit raw 34.9 (effective 40)", (t) => { - const coinList: Coin[] = [ - [kudos`2`, 5], - [kudos`5`, 5], - ]; - const result = convertDepositAmountForAvailableCoins( - { - list: coinList.map(([v, t]) => defaultFeeConfig(v, t)), - exchanges: {}, - }, - kudos`34.9`, - TransactionAmountMode.Raw, - ); - t.is(Amounts.stringifyValue(result.effective), "35"); - t.is(Amounts.stringifyValue(result.raw), "34.9"); -}); - -test("deposit with wire fee raw 2", (t) => { - const coinList: Coin[] = [ - [kudos`2`, 5], - [kudos`5`, 5], - ]; - const result = convertDepositAmountForAvailableCoins( - { - list: coinList.map(([v, t]) => defaultFeeConfig(v, t)), - exchanges: { - one: { - wireFee: kudos`0.1`, - purseFee: kudos`0.00`, - creditDeadline: AbsoluteTime.never(), - debitDeadline: AbsoluteTime.never(), - }, - }, - }, - kudos`2`, - TransactionAmountMode.Effective, - ); - t.is(Amounts.stringifyValue(result.effective), "2"); - t.is(Amounts.stringifyValue(result.raw), "1.89"); -}); - -/** - * Calculating the max amount possible to deposit - * - */ - -test("deposit max 35", (t) => { - const coinList: Coin[] = [ - [kudos`2`, 5], - [kudos`5`, 5], - ]; - const result = getMaxDepositAmountForAvailableCoins( - { - list: coinList.map(([v, t]) => defaultFeeConfig(v, t)), - exchanges: { - "2": { - wireFee: kudos`0.00`, - purseFee: kudos`0.00`, - creditDeadline: AbsoluteTime.never(), - debitDeadline: AbsoluteTime.never(), - }, - }, - }, - "KUDOS", - ); - t.is(Amounts.stringifyValue(result.raw), "34.9"); - t.is(Amounts.stringifyValue(result.effective), "35"); -}); - -test("deposit max 35 with wirefee", (t) => { - const coinList: Coin[] = [ - [kudos`2`, 5], - [kudos`5`, 5], - ]; - const result = getMaxDepositAmountForAvailableCoins( - { - list: coinList.map(([v, t]) => defaultFeeConfig(v, t)), - exchanges: { - "2": { - wireFee: kudos`1`, - purseFee: kudos`0.00`, - creditDeadline: AbsoluteTime.never(), - debitDeadline: AbsoluteTime.never(), - }, - }, - }, - "KUDOS", - ); - t.is(Amounts.stringifyValue(result.raw), "33.9"); - t.is(Amounts.stringifyValue(result.effective), "35"); -}); - -test("deposit max repeated denom", (t) => { - const coinList: Coin[] = [ - [kudos`2`, 1], - [kudos`2`, 1], - [kudos`5`, 1], - ]; - const result = getMaxDepositAmountForAvailableCoins( - { - list: coinList.map(([v, t]) => defaultFeeConfig(v, t)), - exchanges: { - "2": { - wireFee: kudos`0.00`, - purseFee: kudos`0.00`, - creditDeadline: AbsoluteTime.never(), - debitDeadline: AbsoluteTime.never(), - }, - }, - }, - "KUDOS", - ); - t.is(Amounts.stringifyValue(result.raw), "8.97"); - t.is(Amounts.stringifyValue(result.effective), "9"); -}); - -/** - * Making a withdrawal with effective amount - * - */ - -test("withdraw effective 2", (t) => { - const coinList: Coin[] = [ - [kudos`2`, 5], - [kudos`5`, 5], - ]; - const result = convertWithdrawalAmountFromAvailableCoins( - { - list: coinList.map(([v, t]) => defaultFeeConfig(v, t)), - exchanges: {}, - }, - kudos`2`, - TransactionAmountMode.Effective, - ); - t.is(Amounts.stringifyValue(result.effective), "2"); - t.is(Amounts.stringifyValue(result.raw), "2.01"); -}); - -test("withdraw effective 10", (t) => { - const coinList: Coin[] = [ - [kudos`2`, 5], - [kudos`5`, 5], - ]; - const result = convertWithdrawalAmountFromAvailableCoins( - { - list: coinList.map(([v, t]) => defaultFeeConfig(v, t)), - exchanges: {}, - }, - kudos`10`, - TransactionAmountMode.Effective, - ); - t.is(Amounts.stringifyValue(result.effective), "10"); - t.is(Amounts.stringifyValue(result.raw), "10.02"); -}); - -test("withdraw effective 24", (t) => { - const coinList: Coin[] = [ - [kudos`2`, 5], - [kudos`5`, 5], - ]; - const result = convertWithdrawalAmountFromAvailableCoins( - { - list: coinList.map(([v, t]) => defaultFeeConfig(v, t)), - exchanges: {}, - }, - kudos`24`, - TransactionAmountMode.Effective, - ); - t.is(Amounts.stringifyValue(result.effective), "24"); - t.is(Amounts.stringifyValue(result.raw), "24.06"); -}); - -test("withdraw effective 40", (t) => { - const coinList: Coin[] = [ - [kudos`2`, 5], - [kudos`5`, 5], - ]; - const result = convertWithdrawalAmountFromAvailableCoins( - { - list: coinList.map(([v, t]) => defaultFeeConfig(v, t)), - exchanges: {}, - }, - kudos`40`, - TransactionAmountMode.Effective, - ); - t.is(Amounts.stringifyValue(result.effective), "40"); - t.is(Amounts.stringifyValue(result.raw), "40.08"); -}); - -/** - * Making a deposit with raw amount, using the result from effective - * - */ - -test("withdraw raw 2.01 (effective 2)", (t) => { - const coinList: Coin[] = [ - [kudos`2`, 5], - [kudos`5`, 5], - ]; - const result = convertWithdrawalAmountFromAvailableCoins( - { - list: coinList.map(([v, t]) => defaultFeeConfig(v, t)), - exchanges: {}, - }, - kudos`2.01`, - TransactionAmountMode.Raw, - ); - t.is(Amounts.stringifyValue(result.effective), "2"); - t.is(Amounts.stringifyValue(result.raw), "2.01"); -}); - -test("withdraw raw 10.02 (effective 10)", (t) => { - const coinList: Coin[] = [ - [kudos`2`, 5], - [kudos`5`, 5], - ]; - const result = convertWithdrawalAmountFromAvailableCoins( - { - list: coinList.map(([v, t]) => defaultFeeConfig(v, t)), - exchanges: {}, - }, - kudos`10.02`, - TransactionAmountMode.Raw, - ); - t.is(Amounts.stringifyValue(result.effective), "10"); - t.is(Amounts.stringifyValue(result.raw), "10.02"); -}); - -test("withdraw raw 24.06 (effective 24)", (t) => { - const coinList: Coin[] = [ - [kudos`2`, 5], - [kudos`5`, 5], - ]; - const result = convertWithdrawalAmountFromAvailableCoins( - { - list: coinList.map(([v, t]) => defaultFeeConfig(v, t)), - exchanges: {}, - }, - kudos`24.06`, - TransactionAmountMode.Raw, - ); - t.is(Amounts.stringifyValue(result.effective), "24"); - t.is(Amounts.stringifyValue(result.raw), "24.06"); -}); - -test("withdraw raw 40.08 (effective 40)", (t) => { - const coinList: Coin[] = [ - [kudos`2`, 5], - [kudos`5`, 5], - ]; - const result = convertWithdrawalAmountFromAvailableCoins( - { - list: coinList.map(([v, t]) => defaultFeeConfig(v, t)), - exchanges: {}, - }, - kudos`40.08`, - TransactionAmountMode.Raw, - ); - t.is(Amounts.stringifyValue(result.effective), "40"); - t.is(Amounts.stringifyValue(result.raw), "40.08"); -}); - -test("withdraw raw 25", (t) => { - const coinList: Coin[] = [ - [kudos`0.1`, 0], - [kudos`1`, 0], - [kudos`2`, 0], - [kudos`5`, 0], - ]; - const result = convertWithdrawalAmountFromAvailableCoins( - { - list: coinList.map(([v, t]) => defaultFeeConfig(v, t)), - exchanges: {}, - }, - kudos`25`, - TransactionAmountMode.Raw, - ); - t.is(Amounts.stringifyValue(result.effective), "24.8"); - t.is(Amounts.stringifyValue(result.raw), "24.94"); -}); - -test("withdraw effective 24.8 (raw 25)", (t) => { - const coinList: Coin[] = [ - [kudos`0.1`, 0], - [kudos`1`, 0], - [kudos`2`, 0], - [kudos`5`, 0], - ]; - const result = convertWithdrawalAmountFromAvailableCoins( - { - list: coinList.map(([v, t]) => defaultFeeConfig(v, t)), - exchanges: {}, - }, - kudos`24.8`, - TransactionAmountMode.Effective, - ); - t.is(Amounts.stringifyValue(result.effective), "24.8"); - t.is(Amounts.stringifyValue(result.raw), "24.94"); -}); - -/** - * Making a deposit with refresh - * - */ - -test("deposit with refresh: effective 3", (t) => { - const coinList: Coin[] = [ - [kudos`0.1`, 0], - [kudos`1`, 0], - [kudos`2`, 5], - [kudos`5`, 5], - ]; - const result = convertDepositAmountForAvailableCoins( - { - list: coinList.map(([v, t]) => defaultFeeConfig(v, t)), - exchanges: {}, - }, - kudos`3`, - TransactionAmountMode.Effective, - ); - t.is(Amounts.stringifyValue(result.effective), "3.1"); - t.is(Amounts.stringifyValue(result.raw), "2.98"); - expectDefined(t, result.refresh); - //FEES - //deposit 2 x 0.01 - //refresh 1 x 0.01 - //withdraw 9 x 0.01 - //----------------- - //op 0.12 - - //coins sent 2 x 2.0 - //coins recv 9 x 0.1 - //------------------- - //effective 3.10 - //raw 2.98 - t.is(Amounts.stringifyValue(result.refresh.selected.id), "2"); - t.deepEqual(asCoinList(result.refresh.coins), [[kudos`0.1`, 9]]); -}); - -test("deposit with refresh: raw 2.98 (effective 3)", (t) => { - const coinList: Coin[] = [ - [kudos`0.1`, 0], - [kudos`1`, 0], - [kudos`2`, 5], - [kudos`5`, 5], - ]; - const result = convertDepositAmountForAvailableCoins( - { - list: coinList.map(([v, t]) => defaultFeeConfig(v, t)), - exchanges: {}, - }, - kudos`2.98`, - TransactionAmountMode.Raw, - ); - t.is(Amounts.stringifyValue(result.effective), "3.2"); - t.is(Amounts.stringifyValue(result.raw), "3.09"); - expectDefined(t, result.refresh); - //FEES - //deposit 1 x 0.01 - //refresh 1 x 0.01 - //withdraw 8 x 0.01 - //----------------- - //op 0.10 - - //coins sent 1 x 2.0 - //coins recv 8 x 0.1 - //------------------- - //effective 3.20 - //raw 3.09 - t.is(Amounts.stringifyValue(result.refresh.selected.id), "2"); - t.deepEqual(asCoinList(result.refresh.coins), [[kudos`0.1`, 8]]); -}); - -test("deposit with refresh: effective 3.2 (raw 2.98)", (t) => { - const coinList: Coin[] = [ - [kudos`0.1`, 0], - [kudos`1`, 0], - [kudos`2`, 5], - [kudos`5`, 5], - ]; - const result = convertDepositAmountForAvailableCoins( - { - list: coinList.map(([v, t]) => defaultFeeConfig(v, t)), - exchanges: {}, - }, - kudos`3.2`, - TransactionAmountMode.Effective, - ); - t.is(Amounts.stringifyValue(result.effective), "3.3"); - t.is(Amounts.stringifyValue(result.raw), "3.2"); - expectDefined(t, result.refresh); - //FEES - //deposit 2 x 0.01 - //refresh 1 x 0.01 - //withdraw 7 x 0.01 - //----------------- - //op 0.10 - - //coins sent 2 x 2.0 - //coins recv 7 x 0.1 - //------------------- - //effective 3.30 - //raw 3.20 - t.is(Amounts.stringifyValue(result.refresh.selected.id), "2"); - t.deepEqual(asCoinList(result.refresh.coins), [[kudos`0.1`, 7]]); -}); - -function expectDefined<T>( - t: ExecutionContext, - v: T | undefined, -): asserts v is T { - t.assert(v !== undefined); -} - -function asCoinList(v: { info: CoinInfo; size: number }[]): any { - return v.map((c) => { - return [c.info.value, c.size]; - }); -} - -/** - * regression tests - */ - -test("demo: withdraw raw 25", (t) => { - const coinList: Coin[] = [ - [kudos`0.1`, 0], - [kudos`1`, 0], - [kudos`2`, 0], - [kudos`5`, 0], - [kudos`10`, 0], - ]; - const result = convertWithdrawalAmountFromAvailableCoins( - { - list: coinList.map(([v, t]) => defaultFeeConfig(v, t)), - exchanges: {}, - }, - kudos`25`, - TransactionAmountMode.Raw, - ); - t.is(Amounts.stringifyValue(result.effective), "24.8"); - t.is(Amounts.stringifyValue(result.raw), "24.92"); - // coins received - // 8 x 0.1 - // 2 x 0.2 - // 2 x 10.0 - // total effective 24.8 - // fee 12 x 0.01 = 0.12 - // total raw 24.92 - // left in reserve 25 - 24.92 == 0.08 - - //current wallet impl: hides the left in reserve fee - //shows fee = 0.2 -}); - -test("demo: deposit max after withdraw raw 25", (t) => { - const coinList: Coin[] = [ - [kudos`0.1`, 8], - [kudos`1`, 0], - [kudos`2`, 2], - [kudos`5`, 0], - [kudos`10`, 2], - ]; - const result = getMaxDepositAmountForAvailableCoins( - { - list: coinList.map(([v, t]) => defaultFeeConfig(v, t)), - exchanges: { - one: { - wireFee: kudos`0.01`, - purseFee: kudos`0.00`, - creditDeadline: AbsoluteTime.never(), - debitDeadline: AbsoluteTime.never(), - }, - }, - }, - "KUDOS", - ); - t.is(Amounts.stringifyValue(result.effective), "24.8"); - t.is(Amounts.stringifyValue(result.raw), "24.67"); - - // 8 x 0.1 - // 2 x 0.2 - // 2 x 10.0 - // total effective 24.8 - // deposit fee 12 x 0.01 = 0.12 - // wire fee 0.01 - // total raw: 24.8 - 0.13 = 24.67 - - // current wallet impl fee 0.14 -}); - -test("demo: withdraw raw 13", (t) => { - const coinList: Coin[] = [ - [kudos`0.1`, 0], - [kudos`1`, 0], - [kudos`2`, 0], - [kudos`5`, 0], - [kudos`10`, 0], - ]; - const result = convertWithdrawalAmountFromAvailableCoins( - { - list: coinList.map(([v, t]) => defaultFeeConfig(v, t)), - exchanges: {}, - }, - kudos`13`, - TransactionAmountMode.Raw, - ); - t.is(Amounts.stringifyValue(result.effective), "12.8"); - t.is(Amounts.stringifyValue(result.raw), "12.9"); - // coins received - // 8 x 0.1 - // 1 x 0.2 - // 1 x 10.0 - // total effective 12.8 - // fee 10 x 0.01 = 0.10 - // total raw 12.9 - // left in reserve 13 - 12.9 == 0.1 - - //current wallet impl: hides the left in reserve fee - //shows fee = 0.2 -}); - -test("demo: deposit max after withdraw raw 13", (t) => { - const coinList: Coin[] = [ - [kudos`0.1`, 8], - [kudos`1`, 0], - [kudos`2`, 1], - [kudos`5`, 0], - [kudos`10`, 1], - ]; - const result = getMaxDepositAmountForAvailableCoins( - { - list: coinList.map(([v, t]) => defaultFeeConfig(v, t)), - exchanges: { - one: { - wireFee: kudos`0.01`, - purseFee: kudos`0.00`, - creditDeadline: AbsoluteTime.never(), - debitDeadline: AbsoluteTime.never(), - }, - }, - }, - "KUDOS", - ); - t.is(Amounts.stringifyValue(result.effective), "12.8"); - t.is(Amounts.stringifyValue(result.raw), "12.69"); - - // 8 x 0.1 - // 1 x 0.2 - // 1 x 10.0 - // total effective 12.8 - // deposit fee 10 x 0.01 = 0.10 - // wire fee 0.01 - // total raw: 12.8 - 0.11 = 12.69 - - // current wallet impl fee 0.14 -}); diff --git a/packages/taler-wallet-core/src/util/instructedAmountConversion.ts b/packages/taler-wallet-core/src/util/instructedAmountConversion.ts deleted file mode 100644 index 1cd30fece..000000000 --- a/packages/taler-wallet-core/src/util/instructedAmountConversion.ts +++ /dev/null @@ -1,850 +0,0 @@ -/* - This file is part of GNU Taler - (C) 2023 Taler Systems S.A. - - 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/> - */ - -import { GlobalIDB } from "@gnu-taler/idb-bridge"; -import { - AbsoluteTime, - AgeRestriction, - AmountJson, - AmountResponse, - Amounts, - ConvertAmountRequest, - Duration, - GetAmountRequest, - GetPlanForOperationRequest, - TransactionAmountMode, - TransactionType, - parsePaytoUri, - strcmp, -} from "@gnu-taler/taler-util"; -import { DenominationRecord, timestampProtocolFromDb } from "../db.js"; -import { getExchangeWireDetailsInTx } from "../exchanges.js"; -import { CoinInfo } from "./coinSelection.js"; -import { checkDbInvariant } from "./invariants.js"; -import { InternalWalletState } from "../wallet.js"; - -/** - * If the operation going to be plan subtracts - * or adds amount in the wallet db - */ -export enum OperationType { - Credit = "credit", - Debit = "debit", -} - -// FIXME: Name conflict ... -interface ExchangeInfo { - wireFee: AmountJson | undefined; - purseFee: AmountJson | undefined; - creditDeadline: AbsoluteTime; - debitDeadline: AbsoluteTime; -} - -function getOperationType(txType: TransactionType): OperationType { - const operationType = - txType === TransactionType.Withdrawal - ? OperationType.Credit - : txType === TransactionType.Deposit - ? OperationType.Debit - : undefined; - if (!operationType) { - throw Error(`operation type ${txType} not yet supported`); - } - return operationType; -} - -interface SelectedCoins { - totalValue: AmountJson; - coins: { info: CoinInfo; size: number }[]; - refresh?: RefreshChoice; -} - -function getCoinsFilter(req: GetPlanForOperationRequest): CoinsFilter { - switch (req.type) { - case TransactionType.Withdrawal: { - return { - exchanges: - req.exchangeUrl === undefined ? undefined : [req.exchangeUrl], - }; - } - case TransactionType.Deposit: { - const payto = parsePaytoUri(req.account); - if (!payto) { - throw Error(`wrong payto ${req.account}`); - } - return { - wireMethod: payto.targetType, - }; - } - } -} - -interface RefreshChoice { - /** - * Amount that need to be covered - */ - gap: AmountJson; - totalFee: AmountJson; - selected: CoinInfo; - totalChangeValue: AmountJson; - refreshEffective: AmountJson; - coins: { info: CoinInfo; size: number }[]; - - // totalValue: AmountJson; - // totalDepositFee: AmountJson; - // totalRefreshFee: AmountJson; - // totalChangeContribution: AmountJson; - // totalChangeWithdrawalFee: AmountJson; -} - -interface CoinsFilter { - shouldCalculatePurseFee?: boolean; - exchanges?: string[]; - wireMethod?: string; - ageRestricted?: number; -} - -interface AvailableCoins { - list: CoinInfo[]; - exchanges: Record<string, ExchangeInfo>; -} - -/** - * Get all the denoms that can be used for a operation that is limited - * by the following restrictions. - * This function is costly (by the database access) but with high chances - * of being cached - */ -async function getAvailableDenoms( - ws: InternalWalletState, - op: TransactionType, - currency: string, - filters: CoinsFilter = {}, -): Promise<AvailableCoins> { - const operationType = getOperationType(TransactionType.Deposit); - - return await ws.db.runReadOnlyTx( - ["exchanges", "exchangeDetails", "denominations", "coinAvailability"], - async (tx) => { - const list: CoinInfo[] = []; - const exchanges: Record<string, ExchangeInfo> = {}; - - const databaseExchanges = await tx.exchanges.iter().toArray(); - const filteredExchanges = - filters.exchanges ?? databaseExchanges.map((e) => e.baseUrl); - - for (const exchangeBaseUrl of filteredExchanges) { - const exchangeDetails = await getExchangeWireDetailsInTx( - tx, - exchangeBaseUrl, - ); - // 1.- exchange has same currency - if (exchangeDetails?.currency !== currency) { - continue; - } - - let deadline = AbsoluteTime.never(); - // 2.- exchange supports wire method - let wireFee: AmountJson | undefined; - if (filters.wireMethod) { - const wireMethodWithDates = - exchangeDetails.wireInfo.feesForType[filters.wireMethod]; - - if (!wireMethodWithDates) { - throw Error( - `exchange ${exchangeBaseUrl} doesn't have wire method ${filters.wireMethod}`, - ); - } - const wireMethodFee = wireMethodWithDates.find((x) => { - return AbsoluteTime.isBetween( - AbsoluteTime.now(), - AbsoluteTime.fromProtocolTimestamp(x.startStamp), - AbsoluteTime.fromProtocolTimestamp(x.endStamp), - ); - }); - - if (!wireMethodFee) { - throw Error( - `exchange ${exchangeBaseUrl} doesn't have wire fee defined for this period`, - ); - } - wireFee = Amounts.parseOrThrow(wireMethodFee.wireFee); - deadline = AbsoluteTime.min( - deadline, - AbsoluteTime.fromProtocolTimestamp(wireMethodFee.endStamp), - ); - } - // exchanges[exchangeBaseUrl].wireFee = wireMethodFee; - - // 3.- exchange supports wire method - let purseFee: AmountJson | undefined; - if (filters.shouldCalculatePurseFee) { - const purseFeeFound = exchangeDetails.globalFees.find((x) => { - return AbsoluteTime.isBetween( - AbsoluteTime.now(), - AbsoluteTime.fromProtocolTimestamp(x.startDate), - AbsoluteTime.fromProtocolTimestamp(x.endDate), - ); - }); - if (!purseFeeFound) { - throw Error( - `exchange ${exchangeBaseUrl} doesn't have purse fee defined for this period`, - ); - } - purseFee = Amounts.parseOrThrow(purseFeeFound.purseFee); - deadline = AbsoluteTime.min( - deadline, - AbsoluteTime.fromProtocolTimestamp(purseFeeFound.endDate), - ); - } - - let creditDeadline = AbsoluteTime.never(); - let debitDeadline = AbsoluteTime.never(); - //4.- filter coins restricted by age - if (operationType === OperationType.Credit) { - // FIXME: Use denom groups instead of querying all denominations! - const ds = - await tx.denominations.indexes.byExchangeBaseUrl.getAll( - exchangeBaseUrl, - ); - for (const denom of ds) { - const expiresWithdraw = AbsoluteTime.fromProtocolTimestamp( - timestampProtocolFromDb(denom.stampExpireWithdraw), - ); - const expiresDeposit = AbsoluteTime.fromProtocolTimestamp( - timestampProtocolFromDb(denom.stampExpireDeposit), - ); - creditDeadline = AbsoluteTime.min(deadline, expiresWithdraw); - debitDeadline = AbsoluteTime.min(deadline, expiresDeposit); - list.push( - buildCoinInfoFromDenom( - denom, - purseFee, - wireFee, - AgeRestriction.AGE_UNRESTRICTED, - Number.MAX_SAFE_INTEGER, // Max withdrawable from single denom - ), - ); - } - } else { - const ageLower = filters.ageRestricted ?? 0; - const ageUpper = AgeRestriction.AGE_UNRESTRICTED; - - const myExchangeCoins = - await tx.coinAvailability.indexes.byExchangeAgeAvailability.getAll( - GlobalIDB.KeyRange.bound( - [exchangeDetails.exchangeBaseUrl, ageLower, 1], - [ - exchangeDetails.exchangeBaseUrl, - ageUpper, - Number.MAX_SAFE_INTEGER, - ], - ), - ); - //5.- save denoms with how many coins are available - // FIXME: Check that the individual denomination is audited! - // FIXME: Should we exclude denominations that are - // not spendable anymore? - for (const coinAvail of myExchangeCoins) { - const denom = await tx.denominations.get([ - coinAvail.exchangeBaseUrl, - coinAvail.denomPubHash, - ]); - checkDbInvariant(!!denom); - if (denom.isRevoked || !denom.isOffered) { - continue; - } - const expiresWithdraw = AbsoluteTime.fromProtocolTimestamp( - timestampProtocolFromDb(denom.stampExpireWithdraw), - ); - const expiresDeposit = AbsoluteTime.fromProtocolTimestamp( - timestampProtocolFromDb(denom.stampExpireDeposit), - ); - creditDeadline = AbsoluteTime.min(deadline, expiresWithdraw); - debitDeadline = AbsoluteTime.min(deadline, expiresDeposit); - list.push( - buildCoinInfoFromDenom( - denom, - purseFee, - wireFee, - coinAvail.maxAge, - coinAvail.freshCoinCount, - ), - ); - } - } - - exchanges[exchangeBaseUrl] = { - purseFee, - wireFee, - debitDeadline, - creditDeadline, - }; - } - - return { list, exchanges }; - }, - ); -} - -function buildCoinInfoFromDenom( - denom: DenominationRecord, - purseFee: AmountJson | undefined, - wireFee: AmountJson | undefined, - maxAge: number, - total: number, -): CoinInfo { - return { - id: denom.denomPubHash, - denomWithdraw: Amounts.parseOrThrow(denom.fees.feeWithdraw), - denomDeposit: Amounts.parseOrThrow(denom.fees.feeDeposit), - denomRefresh: Amounts.parseOrThrow(denom.fees.feeRefresh), - exchangePurse: purseFee, - exchangeWire: wireFee, - exchangeBaseUrl: denom.exchangeBaseUrl, - duration: AbsoluteTime.difference( - AbsoluteTime.now(), - AbsoluteTime.fromProtocolTimestamp( - timestampProtocolFromDb(denom.stampExpireDeposit), - ), - ), - totalAvailable: total, - value: Amounts.parseOrThrow(denom.value), - maxAge, - }; -} - -export async function convertDepositAmount( - ws: InternalWalletState, - req: ConvertAmountRequest, -): Promise<AmountResponse> { - const amount = Amounts.parseOrThrow(req.amount); - // const filter = getCoinsFilter(req); - - const denoms = await getAvailableDenoms( - ws, - TransactionType.Deposit, - amount.currency, - {}, - ); - const result = convertDepositAmountForAvailableCoins( - denoms, - amount, - req.type, - ); - - return { - effectiveAmount: Amounts.stringify(result.effective), - rawAmount: Amounts.stringify(result.raw), - }; -} - -const LOG_REFRESH = false; -const LOG_DEPOSIT = false; -export function convertDepositAmountForAvailableCoins( - denoms: AvailableCoins, - amount: AmountJson, - mode: TransactionAmountMode, -): AmountAndRefresh { - const zero = Amounts.zeroOfCurrency(amount.currency); - if (!denoms.list.length) { - // no coins in the database - return { effective: zero, raw: zero }; - } - const depositDenoms = rankDenominationForDeposit(denoms.list, mode); - - //FIXME: we are not taking into account - // * exchanges with multiple accounts - // * wallet with multiple exchanges - const wireFee = Object.values(denoms.exchanges)[0]?.wireFee ?? zero; - const adjustedAmount = Amounts.add(amount, wireFee).amount; - - const selected = selectGreedyCoins(depositDenoms, adjustedAmount); - - const gap = Amounts.sub(amount, selected.totalValue).amount; - - const result = getTotalEffectiveAndRawForDeposit( - selected.coins, - amount.currency, - ); - result.raw = Amounts.sub(result.raw, wireFee).amount; - - if (Amounts.isZero(gap)) { - // exact amount founds - return result; - } - - if (LOG_DEPOSIT) { - const logInfo = selected.coins.map((c) => { - return `${Amounts.stringifyValue(c.info.id)} x ${c.size}`; - }); - console.log( - "deposit used:", - logInfo.join(", "), - "gap:", - Amounts.stringifyValue(gap), - ); - } - - const refreshDenoms = rankDenominationForRefresh(denoms.list); - /** - * FIXME: looking for refresh AFTER selecting greedy is not optimal - */ - const refreshCoin = searchBestRefreshCoin( - depositDenoms, - refreshDenoms, - gap, - mode, - ); - - if (refreshCoin) { - const fee = Amounts.sub(result.effective, result.raw).amount; - const effective = Amounts.add( - result.effective, - refreshCoin.refreshEffective, - ).amount; - const raw = Amounts.sub(effective, fee, refreshCoin.totalFee).amount; - //found with change - return { - effective, - raw, - refresh: refreshCoin, - }; - } - - // there is a gap, but no refresh coin was found - return result; -} - -export async function getMaxDepositAmount( - ws: InternalWalletState, - req: GetAmountRequest, -): Promise<AmountResponse> { - // const filter = getCoinsFilter(req); - - const denoms = await getAvailableDenoms( - ws, - TransactionType.Deposit, - req.currency, - {}, - ); - - const result = getMaxDepositAmountForAvailableCoins(denoms, req.currency); - return { - effectiveAmount: Amounts.stringify(result.effective), - rawAmount: Amounts.stringify(result.raw), - }; -} - -export function getMaxDepositAmountForAvailableCoins( - denoms: AvailableCoins, - currency: string, -) { - const zero = Amounts.zeroOfCurrency(currency); - if (!denoms.list.length) { - // no coins in the database - return { effective: zero, raw: zero }; - } - - const result = getTotalEffectiveAndRawForDeposit( - denoms.list.map((info) => { - return { info, size: info.totalAvailable ?? 0 }; - }), - currency, - ); - - const wireFee = Object.values(denoms.exchanges)[0]?.wireFee ?? zero; - result.raw = Amounts.sub(result.raw, wireFee).amount; - - return result; -} - -export async function convertPeerPushAmount( - ws: InternalWalletState, - req: ConvertAmountRequest, -): Promise<AmountResponse> { - throw Error("to be implemented after 1.0"); -} -export async function getMaxPeerPushAmount( - ws: InternalWalletState, - req: GetAmountRequest, -): Promise<AmountResponse> { - throw Error("to be implemented after 1.0"); -} -export async function convertWithdrawalAmount( - ws: InternalWalletState, - req: ConvertAmountRequest, -): Promise<AmountResponse> { - const amount = Amounts.parseOrThrow(req.amount); - - const denoms = await getAvailableDenoms( - ws, - TransactionType.Withdrawal, - amount.currency, - {}, - ); - - const result = convertWithdrawalAmountFromAvailableCoins( - denoms, - amount, - req.type, - ); - - return { - effectiveAmount: Amounts.stringify(result.effective), - rawAmount: Amounts.stringify(result.raw), - }; -} - -export function convertWithdrawalAmountFromAvailableCoins( - denoms: AvailableCoins, - amount: AmountJson, - mode: TransactionAmountMode, -) { - const zero = Amounts.zeroOfCurrency(amount.currency); - if (!denoms.list.length) { - // no coins in the database - return { effective: zero, raw: zero }; - } - const withdrawDenoms = rankDenominationForWithdrawals(denoms.list, mode); - - const selected = selectGreedyCoins(withdrawDenoms, amount); - - return getTotalEffectiveAndRawForWithdrawal(selected.coins, amount.currency); -} - -/** ***************************************************** - * HELPERS - * ***************************************************** - */ - -/** - * - * @param depositDenoms - * @param refreshDenoms - * @param amount - * @param mode - * @returns - */ -function searchBestRefreshCoin( - depositDenoms: SelectableElement[], - refreshDenoms: Record<string, SelectableElement[]>, - amount: AmountJson, - mode: TransactionAmountMode, -): RefreshChoice | undefined { - let choice: RefreshChoice | undefined = undefined; - let refreshIdx = 0; - refreshIteration: while (refreshIdx < depositDenoms.length) { - const d = depositDenoms[refreshIdx]; - - const denomContribution = - mode === TransactionAmountMode.Effective - ? d.value - : Amounts.sub(d.value, d.info.denomRefresh, d.info.denomDeposit).amount; - - const changeAfterDeposit = Amounts.sub(denomContribution, amount).amount; - if (Amounts.isZero(changeAfterDeposit)) { - //this coin is not big enough to use for refresh - //since the list is sorted, we can break here - break refreshIteration; - } - - const withdrawDenoms = refreshDenoms[d.info.exchangeBaseUrl]; - const change = selectGreedyCoins(withdrawDenoms, changeAfterDeposit); - - const zero = Amounts.zeroOfCurrency(amount.currency); - const withdrawChangeFee = change.coins.reduce((cur, prev) => { - return Amounts.add( - cur, - Amounts.mult(prev.info.denomWithdraw, prev.size).amount, - ).amount; - }, zero); - - const withdrawChangeValue = change.coins.reduce((cur, prev) => { - return Amounts.add(cur, Amounts.mult(prev.info.value, prev.size).amount) - .amount; - }, zero); - - const totalFee = Amounts.add( - d.info.denomDeposit, - d.info.denomRefresh, - withdrawChangeFee, - ).amount; - - if (!choice || Amounts.cmp(totalFee, choice.totalFee) === -1) { - //found cheaper change - choice = { - gap: amount, - totalFee: totalFee, - totalChangeValue: change.totalValue, //change after refresh - refreshEffective: Amounts.sub(d.info.value, withdrawChangeValue).amount, // what of the denom used is not recovered - selected: d.info, - coins: change.coins, - }; - } - refreshIdx++; - } - if (choice) { - if (LOG_REFRESH) { - const logInfo = choice.coins.map((c) => { - return `${Amounts.stringifyValue(c.info.id)} x ${c.size}`; - }); - console.log( - "refresh used:", - Amounts.stringifyValue(choice.selected.value), - "change:", - logInfo.join(", "), - "fee:", - Amounts.stringifyValue(choice.totalFee), - "refreshEffective:", - Amounts.stringifyValue(choice.refreshEffective), - "totalChangeValue:", - Amounts.stringifyValue(choice.totalChangeValue), - ); - } - } - return choice; -} - -/** - * Returns a copy of the list sorted for the best denom to withdraw first - * - * @param denoms - * @returns - */ -function rankDenominationForWithdrawals( - denoms: CoinInfo[], - mode: TransactionAmountMode, -): SelectableElement[] { - const copyList = [...denoms]; - /** - * Rank coins - */ - copyList.sort((d1, d2) => { - // the best coin to use is - // 1.- the one that contrib more and pay less fee - // 2.- it takes more time before expires - - //different exchanges may have different wireFee - //ranking should take the relative contribution in the exchange - //which is (value - denomFee / fixedFee) - const rate1 = Amounts.divmod(d1.value, d1.denomWithdraw).quotient; - const rate2 = Amounts.divmod(d2.value, d2.denomWithdraw).quotient; - const contribCmp = rate1 === rate2 ? 0 : rate1 < rate2 ? 1 : -1; - return ( - contribCmp || - Duration.cmp(d1.duration, d2.duration) || - strcmp(d1.id, d2.id) - ); - }); - - return copyList.map((info) => { - switch (mode) { - case TransactionAmountMode.Effective: { - //if the user instructed "effective" then we need to selected - //greedy total coin value - return { - info, - value: info.value, - total: Number.MAX_SAFE_INTEGER, - }; - } - case TransactionAmountMode.Raw: { - //if the user instructed "raw" then we need to selected - //greedy total coin raw amount (without fee) - return { - info, - value: Amounts.add(info.value, info.denomWithdraw).amount, - total: Number.MAX_SAFE_INTEGER, - }; - } - } - }); -} - -/** - * Returns a copy of the list sorted for the best denom to deposit first - * - * @param denoms - * @returns - */ -function rankDenominationForDeposit( - denoms: CoinInfo[], - mode: TransactionAmountMode, -): SelectableElement[] { - const copyList = [...denoms]; - /** - * Rank coins - */ - copyList.sort((d1, d2) => { - // the best coin to use is - // 1.- the one that contrib more and pay less fee - // 2.- it takes more time before expires - - //different exchanges may have different wireFee - //ranking should take the relative contribution in the exchange - //which is (value - denomFee / fixedFee) - const rate1 = Amounts.divmod(d1.value, d1.denomDeposit).quotient; - const rate2 = Amounts.divmod(d2.value, d2.denomDeposit).quotient; - const contribCmp = rate1 === rate2 ? 0 : rate1 < rate2 ? 1 : -1; - return ( - contribCmp || - Duration.cmp(d1.duration, d2.duration) || - strcmp(d1.id, d2.id) - ); - }); - - return copyList.map((info) => { - switch (mode) { - case TransactionAmountMode.Effective: { - //if the user instructed "effective" then we need to selected - //greedy total coin value - return { - info, - value: info.value, - total: info.totalAvailable ?? 0, - }; - } - case TransactionAmountMode.Raw: { - //if the user instructed "raw" then we need to selected - //greedy total coin raw amount (without fee) - return { - info, - value: Amounts.sub(info.value, info.denomDeposit).amount, - total: info.totalAvailable ?? 0, - }; - } - } - }); -} - -/** - * Returns a copy of the list sorted for the best denom to withdraw first - * - * @param denoms - * @returns - */ -function rankDenominationForRefresh( - denoms: CoinInfo[], -): Record<string, SelectableElement[]> { - const groupByExchange: Record<string, CoinInfo[]> = {}; - for (const d of denoms) { - if (!groupByExchange[d.exchangeBaseUrl]) { - groupByExchange[d.exchangeBaseUrl] = []; - } - groupByExchange[d.exchangeBaseUrl].push(d); - } - - const result: Record<string, SelectableElement[]> = {}; - for (const d of denoms) { - result[d.exchangeBaseUrl] = rankDenominationForWithdrawals( - groupByExchange[d.exchangeBaseUrl], - TransactionAmountMode.Raw, - ); - } - return result; -} - -interface SelectableElement { - total: number; - value: AmountJson; - info: CoinInfo; -} - -function selectGreedyCoins( - coins: SelectableElement[], - limit: AmountJson, -): SelectedCoins { - const result: SelectedCoins = { - totalValue: Amounts.zeroOfCurrency(limit.currency), - coins: [], - }; - if (!coins.length) return result; - - let denomIdx = 0; - iterateDenoms: while (denomIdx < coins.length) { - const denom = coins[denomIdx]; - // let total = denom.total; - const left = Amounts.sub(limit, result.totalValue).amount; - - if (Amounts.isZero(denom.value)) { - // 0 contribution denoms should be the last - break iterateDenoms; - } - - //use Amounts.divmod instead of iterate - const div = Amounts.divmod(left, denom.value); - const size = Math.min(div.quotient, denom.total); - if (size > 0) { - const mul = Amounts.mult(denom.value, size).amount; - const progress = Amounts.add(result.totalValue, mul).amount; - - result.totalValue = progress; - result.coins.push({ info: denom.info, size }); - denom.total = denom.total - size; - } - - //go next denom - denomIdx++; - } - - return result; -} - -type AmountWithFee = { raw: AmountJson; effective: AmountJson }; -type AmountAndRefresh = AmountWithFee & { refresh?: RefreshChoice }; - -export function getTotalEffectiveAndRawForDeposit( - list: { info: CoinInfo; size: number }[], - currency: string, -): AmountWithFee { - const init = { - raw: Amounts.zeroOfCurrency(currency), - effective: Amounts.zeroOfCurrency(currency), - }; - return list.reduce((prev, cur) => { - const ef = Amounts.mult(cur.info.value, cur.size).amount; - const rw = Amounts.mult( - Amounts.sub(cur.info.value, cur.info.denomDeposit).amount, - cur.size, - ).amount; - - prev.effective = Amounts.add(prev.effective, ef).amount; - prev.raw = Amounts.add(prev.raw, rw).amount; - return prev; - }, init); -} - -function getTotalEffectiveAndRawForWithdrawal( - list: { info: CoinInfo; size: number }[], - currency: string, -): AmountWithFee { - const init = { - raw: Amounts.zeroOfCurrency(currency), - effective: Amounts.zeroOfCurrency(currency), - }; - return list.reduce((prev, cur) => { - const ef = Amounts.mult(cur.info.value, cur.size).amount; - const rw = Amounts.mult( - Amounts.add(cur.info.value, cur.info.denomWithdraw).amount, - cur.size, - ).amount; - - prev.effective = Amounts.add(prev.effective, ef).amount; - prev.raw = Amounts.add(prev.raw, rw).amount; - return prev; - }, init); -} diff --git a/packages/taler-wallet-core/src/util/invariants.ts b/packages/taler-wallet-core/src/util/invariants.ts deleted file mode 100644 index 3598d857c..000000000 --- a/packages/taler-wallet-core/src/util/invariants.ts +++ /dev/null @@ -1,46 +0,0 @@ -/* - This file is part of GNU Taler - (C) 2020 Taler Systems S.A. - - 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/> - */ - -export class InvariantViolatedError extends Error { - constructor(message?: string) { - super(message); - Object.setPrototypeOf(this, InvariantViolatedError.prototype); - } -} - -/** - * Helpers for invariants. - */ - -export function checkDbInvariant(b: boolean, m?: string): asserts b { - if (!b) { - if (m) { - throw Error(`BUG: database invariant failed (${m})`); - } else { - throw Error("BUG: database invariant failed"); - } - } -} - -export function checkLogicInvariant(b: boolean, m?: string): asserts b { - if (!b) { - if (m) { - throw Error(`BUG: logic invariant failed (${m})`); - } else { - throw Error("BUG: logic invariant failed"); - } - } -} |