summaryrefslogtreecommitdiff
path: root/packages/taler-wallet-core/src/util
diff options
context:
space:
mode:
Diffstat (limited to 'packages/taler-wallet-core/src/util')
-rw-r--r--packages/taler-wallet-core/src/util/assertUnreachable.ts19
-rw-r--r--packages/taler-wallet-core/src/util/coinSelection.test.ts249
-rw-r--r--packages/taler-wallet-core/src/util/coinSelection.ts1232
-rw-r--r--packages/taler-wallet-core/src/util/denominations.test.ts870
-rw-r--r--packages/taler-wallet-core/src/util/denominations.ts477
-rw-r--r--packages/taler-wallet-core/src/util/instructedAmountConversion.test.ts767
-rw-r--r--packages/taler-wallet-core/src/util/instructedAmountConversion.ts850
-rw-r--r--packages/taler-wallet-core/src/util/invariants.ts46
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");
- }
- }
-}