From 612b85c18fc17af412d08e075e1fddaa67aa7bf0 Mon Sep 17 00:00:00 2001 From: Florian Dold Date: Wed, 21 Feb 2024 13:01:23 +0100 Subject: move helpers to util --- packages/taler-wallet-core/src/backup/index.ts | 3 +- packages/taler-wallet-core/src/balance.ts | 4 +- .../taler-wallet-core/src/coinSelection.test.ts | 248 ++++ packages/taler-wallet-core/src/coinSelection.ts | 1233 ++++++++++++++++++++ packages/taler-wallet-core/src/common.ts | 5 +- packages/taler-wallet-core/src/dbless.ts | 2 +- .../taler-wallet-core/src/denominations.test.ts | 870 ++++++++++++++ packages/taler-wallet-core/src/denominations.ts | 477 ++++++++ packages/taler-wallet-core/src/deposits.ts | 7 +- packages/taler-wallet-core/src/exchanges.ts | 10 +- packages/taler-wallet-core/src/index.ts | 2 +- .../src/instructedAmountConversion.test.ts | 767 ++++++++++++ .../src/instructedAmountConversion.ts | 850 ++++++++++++++ packages/taler-wallet-core/src/pay-merchant.ts | 7 +- packages/taler-wallet-core/src/pay-peer-common.ts | 7 +- .../taler-wallet-core/src/pay-peer-pull-credit.ts | 4 +- .../taler-wallet-core/src/pay-peer-pull-debit.ts | 6 +- .../taler-wallet-core/src/pay-peer-push-credit.ts | 4 +- .../taler-wallet-core/src/pay-peer-push-debit.ts | 6 +- packages/taler-wallet-core/src/recoup.ts | 2 +- packages/taler-wallet-core/src/refresh.ts | 6 +- packages/taler-wallet-core/src/reward.ts | 2 +- packages/taler-wallet-core/src/testing.ts | 2 +- packages/taler-wallet-core/src/transactions.ts | 5 +- .../src/util/assertUnreachable.ts | 19 - .../src/util/coinSelection.test.ts | 249 ---- .../taler-wallet-core/src/util/coinSelection.ts | 1232 ------------------- .../src/util/denominations.test.ts | 870 -------------- .../taler-wallet-core/src/util/denominations.ts | 477 -------- .../src/util/instructedAmountConversion.test.ts | 767 ------------ .../src/util/instructedAmountConversion.ts | 850 -------------- packages/taler-wallet-core/src/util/invariants.ts | 46 - packages/taler-wallet-core/src/wallet.ts | 6 +- packages/taler-wallet-core/src/withdraw.test.ts | 2 +- packages/taler-wallet-core/src/withdraw.ts | 15 +- 35 files changed, 4499 insertions(+), 4563 deletions(-) create mode 100644 packages/taler-wallet-core/src/coinSelection.test.ts create mode 100644 packages/taler-wallet-core/src/coinSelection.ts create mode 100644 packages/taler-wallet-core/src/denominations.test.ts create mode 100644 packages/taler-wallet-core/src/denominations.ts create mode 100644 packages/taler-wallet-core/src/instructedAmountConversion.test.ts create mode 100644 packages/taler-wallet-core/src/instructedAmountConversion.ts delete mode 100644 packages/taler-wallet-core/src/util/assertUnreachable.ts delete mode 100644 packages/taler-wallet-core/src/util/coinSelection.test.ts delete mode 100644 packages/taler-wallet-core/src/util/coinSelection.ts delete mode 100644 packages/taler-wallet-core/src/util/denominations.test.ts delete mode 100644 packages/taler-wallet-core/src/util/denominations.ts delete mode 100644 packages/taler-wallet-core/src/util/instructedAmountConversion.test.ts delete mode 100644 packages/taler-wallet-core/src/util/instructedAmountConversion.ts delete mode 100644 packages/taler-wallet-core/src/util/invariants.ts (limited to 'packages/taler-wallet-core/src') diff --git a/packages/taler-wallet-core/src/backup/index.ts b/packages/taler-wallet-core/src/backup/index.ts index 415af6fd6..a5d7eee80 100644 --- a/packages/taler-wallet-core/src/backup/index.ts +++ b/packages/taler-wallet-core/src/backup/index.ts @@ -46,6 +46,8 @@ import { bytesToString, canonicalJson, canonicalizeBaseUrl, + checkDbInvariant, + checkLogicInvariant, codecForBoolean, codecForConstString, codecForList, @@ -88,7 +90,6 @@ import { timestampPreciseToDb, } from "../db.js"; import { preparePayForUri } from "../pay-merchant.js"; -import { checkDbInvariant, checkLogicInvariant } from "../util/invariants.js"; import { InternalWalletState } from "../wallet.js"; const logger = new Logger("operations/backup.ts"); diff --git a/packages/taler-wallet-core/src/balance.ts b/packages/taler-wallet-core/src/balance.ts index 34f719ad3..a287748f1 100644 --- a/packages/taler-wallet-core/src/balance.ts +++ b/packages/taler-wallet-core/src/balance.ts @@ -56,9 +56,11 @@ import { AmountJson, AmountLike, Amounts, + assertUnreachable, BalanceFlag, BalancesResponse, canonicalizeBaseUrl, + checkLogicInvariant, GetBalanceDetailRequest, Logger, parsePaytoUri, @@ -78,8 +80,6 @@ import { getExchangeScopeInfo, getExchangeWireDetailsInTx, } from "./exchanges.js"; -import { assertUnreachable } from "./util/assertUnreachable.js"; -import { checkLogicInvariant } from "./util/invariants.js"; import { InternalWalletState } from "./wallet.js"; /** diff --git a/packages/taler-wallet-core/src/coinSelection.test.ts b/packages/taler-wallet-core/src/coinSelection.test.ts new file mode 100644 index 000000000..839cd22fb --- /dev/null +++ b/packages/taler-wallet-core/src/coinSelection.test.ts @@ -0,0 +1,248 @@ +/* + 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 + */ +import { + AbsoluteTime, + AmountString, + Amounts, + DenomKeyType, + Duration, + 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(), + 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/coinSelection.ts b/packages/taler-wallet-core/src/coinSelection.ts new file mode 100644 index 000000000..f0b435b54 --- /dev/null +++ b/packages/taler-wallet-core/src/coinSelection.ts @@ -0,0 +1,1233 @@ +/* + 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 + */ + +/** + * 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, + checkDbInvariant, + checkLogicInvariant, + 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 { isWithdrawableDenom } from "./denominations.js"; +import { getExchangeWireDetailsInTx } from "./exchanges.js"; +import { InternalWalletState } from "./wallet.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; +} + +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; + + lastDepositFee: AmountJson; +} + +/** + * Account for the fees of spending a coin. + */ +function tallyFees( + tally: Readonly, + wireFeesPerExchange: Record, + 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 { + 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 +): ReturnType { + return selectGreedy(...args); +} + +function selectGreedy( + req: SelectPayCoinRequestNg, + candidateDenoms: AvailableDenom[], + wireFeesPerExchange: Record, + 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]> { + 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 = {}; + 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 { + 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 +): ReturnType { + 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 { + 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/common.ts b/packages/taler-wallet-core/src/common.ts index 942a36c7e..9b69ad6c4 100644 --- a/packages/taler-wallet-core/src/common.ts +++ b/packages/taler-wallet-core/src/common.ts @@ -35,6 +35,9 @@ import { TalerProtocolTimestamp, TombstoneIdStr, TransactionIdStr, + assertUnreachable, + checkDbInvariant, + checkLogicInvariant, durationMul, } from "@gnu-taler/taler-util"; import { @@ -58,8 +61,6 @@ import { timestampPreciseToDb, } from "./db.js"; import { createRefreshGroup } from "./refresh.js"; -import { assertUnreachable } from "./util/assertUnreachable.js"; -import { checkDbInvariant, checkLogicInvariant } from "./util/invariants.js"; import { InternalWalletState } from "./wallet.js"; const logger = new Logger("operations/common.ts"); diff --git a/packages/taler-wallet-core/src/dbless.ts b/packages/taler-wallet-core/src/dbless.ts index 1d2ebe9db..c230a0504 100644 --- a/packages/taler-wallet-core/src/dbless.ts +++ b/packages/taler-wallet-core/src/dbless.ts @@ -58,7 +58,7 @@ import { TalerCryptoInterface } from "./crypto/cryptoImplementation.js"; import { DenominationRecord } from "./db.js"; import { ExchangeInfo, downloadExchangeInfo } from "./exchanges.js"; import { assembleRefreshRevealRequest } from "./refresh.js"; -import { isWithdrawableDenom } from "./util/denominations.js"; +import { isWithdrawableDenom } from "./denominations.js"; import { getBankStatusUrl, getBankWithdrawalInfo } from "./withdraw.js"; export { downloadExchangeInfo }; diff --git a/packages/taler-wallet-core/src/denominations.test.ts b/packages/taler-wallet-core/src/denominations.test.ts new file mode 100644 index 000000000..98af5d1a4 --- /dev/null +++ b/packages/taler-wallet-core/src/denominations.test.ts @@ -0,0 +1,870 @@ +/* + 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 + */ + +/** + * + * @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 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 as DenominationInfo, + { + value: VALUES[1], + stampStart: TIMESTAMPS[2], + stampExpireDeposit: TIMESTAMPS[4], + feeDeposit: VALUES[2], + } as Partial 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 as DenominationInfo, + { + value: VALUES[1], + stampStart: TIMESTAMPS[2], + stampExpireDeposit: TIMESTAMPS[4], + feeDeposit: VALUES[1], + } as Partial 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 as DenominationInfo, + { + value: VALUES[1], + stampStart: TIMESTAMPS[3], + stampExpireDeposit: TIMESTAMPS[4], + feeDeposit: VALUES[1], + } as Partial 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 as DenominationInfo, + { + value: VALUES[1], + stampStart: TIMESTAMPS[1], + stampExpireDeposit: TIMESTAMPS[4], + feeDeposit: VALUES[2], + } as Partial 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 as DenominationInfo, + { + value: VALUES[1], + stampStart: TIMESTAMPS[1], + stampExpireDeposit: TIMESTAMPS[4], + feeDeposit: VALUES[1], + } as Partial 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 as DenominationInfo, + { + value: VALUES[1], + stampStart: TIMESTAMPS[2], + stampExpireDeposit: TIMESTAMPS[4], + feeDeposit: VALUES[1], + } as Partial as DenominationInfo, + { + value: VALUES[1], + stampStart: TIMESTAMPS[2], + stampExpireDeposit: TIMESTAMPS[4], + feeDeposit: VALUES[2], + } as Partial 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 as DenominationInfo, + { + value: VALUES[1], + stampStart: TIMESTAMPS[2], + stampExpireDeposit: TIMESTAMPS[5], + feeDeposit: VALUES[1], + } as Partial as DenominationInfo, + { + value: VALUES[1], + stampStart: TIMESTAMPS[2], + stampExpireDeposit: TIMESTAMPS[4], + feeDeposit: VALUES[2], + } as Partial as DenominationInfo, + { + value: VALUES[1], + stampStart: TIMESTAMPS[5], + stampExpireDeposit: TIMESTAMPS[6], + feeDeposit: VALUES[3], + } as Partial 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 as DenominationInfo, + { + value: VALUES[1], + stampStart: TIMESTAMPS[2], + stampExpireDeposit: TIMESTAMPS[5], + feeDeposit: VALUES[1], + } as Partial as DenominationInfo, + { + value: VALUES[1], + stampStart: TIMESTAMPS[2], + stampExpireDeposit: TIMESTAMPS[5], + feeDeposit: VALUES[2], + } as Partial 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 as DenominationInfo, + { + value: VALUES[2], + stampStart: TIMESTAMPS[2], + stampExpireDeposit: TIMESTAMPS[4], + feeDeposit: VALUES[2], + } as Partial 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 as DenominationInfo, + { + value: VALUES[2], + stampStart: TIMESTAMPS[2], + stampExpireDeposit: TIMESTAMPS[4], + feeDeposit: VALUES[2], + } as Partial as DenominationInfo, + { + value: VALUES[1], + stampStart: TIMESTAMPS[1], + stampExpireDeposit: TIMESTAMPS[3], + feeDeposit: VALUES[1], + } as Partial as DenominationInfo, + { + value: VALUES[2], + stampStart: TIMESTAMPS[2], + stampExpireDeposit: TIMESTAMPS[4], + feeDeposit: VALUES[2], + } as Partial 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/denominations.ts b/packages/taler-wallet-core/src/denominations.ts new file mode 100644 index 000000000..177070622 --- /dev/null +++ b/packages/taler-wallet-core/src/denominations.ts @@ -0,0 +1,477 @@ +/* + 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 + */ + +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( + 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 = 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( + list: Type[], + idProp: PropsWithReturnType, + periodStartProp: PropsWithReturnType, + periodEndProp: PropsWithReturnType, + feeProp: PropsWithReturnType, + groupProp: PropsWithReturnType | 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[]) + .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/deposits.ts b/packages/taler-wallet-core/src/deposits.ts index 6c2c53996..617f32887 100644 --- a/packages/taler-wallet-core/src/deposits.ts +++ b/packages/taler-wallet-core/src/deposits.ts @@ -55,7 +55,10 @@ import { TransactionType, URL, WireFee, + assertUnreachable, canonicalJson, + checkDbInvariant, + checkLogicInvariant, codecForBatchDepositSuccess, codecForTackTransactionAccepted, codecForTackTransactionWired, @@ -69,6 +72,7 @@ import { stringToBytes, } from "@gnu-taler/taler-util"; import { readSuccessResponseJsonOrThrow } from "@gnu-taler/taler-util/http"; +import { selectPayCoinsNew } from "./coinSelection.js"; import { PendingTaskType, TaskId, @@ -105,9 +109,6 @@ import { notifyTransition, parseTransactionIdentifier, } from "./transactions.js"; -import { assertUnreachable } from "./util/assertUnreachable.js"; -import { selectPayCoinsNew } from "./util/coinSelection.js"; -import { checkDbInvariant, checkLogicInvariant } from "./util/invariants.js"; import { InternalWalletState } from "./wallet.js"; import { getCandidateWithdrawalDenomsTx } from "./withdraw.js"; diff --git a/packages/taler-wallet-core/src/exchanges.ts b/packages/taler-wallet-core/src/exchanges.ts index 610536619..a4732e474 100644 --- a/packages/taler-wallet-core/src/exchanges.ts +++ b/packages/taler-wallet-core/src/exchanges.ts @@ -73,6 +73,7 @@ import { WireInfo, assertUnreachable, canonicalizeBaseUrl, + checkDbInvariant, codecForExchangeKeysJson, durationFromSpec, durationMul, @@ -117,16 +118,15 @@ import { timestampProtocolFromDb, timestampProtocolToDb, } from "./db.js"; -import { DbReadOnlyTransaction } from "./query.js"; -import { createRecoupGroup } from "./recoup.js"; -import { createRefreshGroup } from "./refresh.js"; import { createTimeline, isWithdrawableDenom, selectBestForOverlappingDenominations, selectMinimumFee, -} from "./util/denominations.js"; -import { checkDbInvariant } from "./util/invariants.js"; +} from "./denominations.js"; +import { DbReadOnlyTransaction } from "./query.js"; +import { createRecoupGroup } from "./recoup.js"; +import { createRefreshGroup } from "./refresh.js"; import { WALLET_EXCHANGE_PROTOCOL_VERSION } from "./versions.js"; import { InternalWalletState } from "./wallet.js"; diff --git a/packages/taler-wallet-core/src/index.ts b/packages/taler-wallet-core/src/index.ts index d0de16ccd..fa984fc8f 100644 --- a/packages/taler-wallet-core/src/index.ts +++ b/packages/taler-wallet-core/src/index.ts @@ -32,7 +32,7 @@ export * from "./versions.js"; export * from "./wallet-api-types.js"; export * from "./wallet.js"; -export { createPairTimeline } from "./util/denominations.js"; +export { createPairTimeline } from "./denominations.js"; // FIXME: Should these really be exported?! export { diff --git a/packages/taler-wallet-core/src/instructedAmountConversion.test.ts b/packages/taler-wallet-core/src/instructedAmountConversion.test.ts new file mode 100644 index 000000000..3b618f797 --- /dev/null +++ b/packages/taler-wallet-core/src/instructedAmountConversion.test.ts @@ -0,0 +1,767 @@ +/* + 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 + */ +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: 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/instructedAmountConversion.ts b/packages/taler-wallet-core/src/instructedAmountConversion.ts new file mode 100644 index 000000000..2250188b7 --- /dev/null +++ b/packages/taler-wallet-core/src/instructedAmountConversion.ts @@ -0,0 +1,850 @@ +/* + 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 + */ + +import { GlobalIDB } from "@gnu-taler/idb-bridge"; +import { + AbsoluteTime, + AgeRestriction, + AmountJson, + AmountResponse, + Amounts, + ConvertAmountRequest, + Duration, + GetAmountRequest, + GetPlanForOperationRequest, + TransactionAmountMode, + TransactionType, + checkDbInvariant, + parsePaytoUri, + strcmp, +} from "@gnu-taler/taler-util"; +import { CoinInfo } from "./coinSelection.js"; +import { DenominationRecord, timestampProtocolFromDb } from "./db.js"; +import { getExchangeWireDetailsInTx } from "./exchanges.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; +} + +/** + * 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 { + const operationType = getOperationType(TransactionType.Deposit); + + return await ws.db.runReadOnlyTx( + ["exchanges", "exchangeDetails", "denominations", "coinAvailability"], + async (tx) => { + const list: CoinInfo[] = []; + const exchanges: Record = {}; + + 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 { + 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 { + // 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 { + throw Error("to be implemented after 1.0"); +} +export async function getMaxPeerPushAmount( + ws: InternalWalletState, + req: GetAmountRequest, +): Promise { + throw Error("to be implemented after 1.0"); +} +export async function convertWithdrawalAmount( + ws: InternalWalletState, + req: ConvertAmountRequest, +): Promise { + 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, + 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 { + const groupByExchange: Record = {}; + for (const d of denoms) { + if (!groupByExchange[d.exchangeBaseUrl]) { + groupByExchange[d.exchangeBaseUrl] = []; + } + groupByExchange[d.exchangeBaseUrl].push(d); + } + + const result: Record = {}; + 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/pay-merchant.ts b/packages/taler-wallet-core/src/pay-merchant.ts index 13580464b..5e01ae716 100644 --- a/packages/taler-wallet-core/src/pay-merchant.ts +++ b/packages/taler-wallet-core/src/pay-merchant.ts @@ -31,8 +31,10 @@ import { AmountJson, Amounts, AmountString, + assertUnreachable, AsyncFlag, CancellationToken, + checkDbInvariant, codecForAbortResponse, codecForMerchantContractTerms, codecForMerchantOrderRefundPickupResponse, @@ -95,6 +97,7 @@ import { readUnexpectedResponseDetails, throwUnexpectedRequestError, } from "@gnu-taler/taler-util/http"; +import { PreviousPayCoins, selectPayCoinsNew } from "./coinSelection.js"; import { constructTaskIdentifier, DbRetryInfo, @@ -137,9 +140,6 @@ import { notifyTransition, parseTransactionIdentifier, } from "./transactions.js"; -import { assertUnreachable } from "./util/assertUnreachable.js"; -import { PreviousPayCoins, selectPayCoinsNew } from "./util/coinSelection.js"; -import { checkDbInvariant } from "./util/invariants.js"; import { EXCHANGE_COINS_LOCK, InternalWalletState } from "./wallet.js"; import { getCandidateWithdrawalDenomsTx } from "./withdraw.js"; @@ -1984,7 +1984,6 @@ export async function processPurchase( return TaskRunResult.finished(); default: assertUnreachable(purchase.purchaseStatus); - // throw Error(`unexpected purchase status (${purchase.purchaseStatus})`); } } diff --git a/packages/taler-wallet-core/src/pay-peer-common.ts b/packages/taler-wallet-core/src/pay-peer-common.ts index abcffc83a..efb5bdb7e 100644 --- a/packages/taler-wallet-core/src/pay-peer-common.ts +++ b/packages/taler-wallet-core/src/pay-peer-common.ts @@ -22,23 +22,20 @@ import { AmountString, Amounts, Codec, - Logger, TalerProtocolTimestamp, buildCodecForObject, + checkDbInvariant, codecForAmountString, codecForTimestamp, codecOptional, } from "@gnu-taler/taler-util"; +import type { SelectedPeerCoin } from "./coinSelection.js"; import { SpendCoinDetails } from "./crypto/cryptoImplementation.js"; import { PeerPushPaymentCoinSelection, ReserveRecord } from "./db.js"; import { getTotalRefreshCost } from "./refresh.js"; -import type { SelectedPeerCoin } from "./util/coinSelection.js"; -import { checkDbInvariant } from "./util/invariants.js"; import { InternalWalletState } from "./wallet.js"; import { getCandidateWithdrawalDenomsTx } from "./withdraw.js"; -const logger = new Logger("operations/peer-to-peer.ts"); - /** * Get information about the coin selected for signatures. */ diff --git a/packages/taler-wallet-core/src/pay-peer-pull-credit.ts b/packages/taler-wallet-core/src/pay-peer-pull-credit.ts index e37a948ee..e764d2169 100644 --- a/packages/taler-wallet-core/src/pay-peer-pull-credit.ts +++ b/packages/taler-wallet-core/src/pay-peer-pull-credit.ts @@ -40,6 +40,8 @@ import { TransactionType, WalletAccountMergeFlags, WalletKycUuid, + assertUnreachable, + checkDbInvariant, codecForAny, codecForWalletKycUuid, encodeCrock, @@ -79,8 +81,6 @@ import { constructTransactionIdentifier, notifyTransition, } from "./transactions.js"; -import { assertUnreachable } from "./util/assertUnreachable.js"; -import { checkDbInvariant } from "./util/invariants.js"; import { InternalWalletState } from "./wallet.js"; import { getExchangeWithdrawalInfo, diff --git a/packages/taler-wallet-core/src/pay-peer-pull-debit.ts b/packages/taler-wallet-core/src/pay-peer-pull-debit.ts index b227e941a..9fa7eb575 100644 --- a/packages/taler-wallet-core/src/pay-peer-pull-debit.ts +++ b/packages/taler-wallet-core/src/pay-peer-pull-debit.ts @@ -48,6 +48,8 @@ import { TransactionMinorState, TransactionState, TransactionType, + assertUnreachable, + checkLogicInvariant, codecForAny, codecForExchangeGetContractResponse, codecForPeerContractTerms, @@ -63,6 +65,7 @@ import { readSuccessResponseJsonOrThrow, readTalerErrorResponse, } from "@gnu-taler/taler-util/http"; +import { PeerCoinRepair, selectPeerCoins } from "./coinSelection.js"; import { PendingTaskType, TaskId, @@ -92,9 +95,6 @@ import { notifyTransition, parseTransactionIdentifier, } from "./transactions.js"; -import { assertUnreachable } from "./util/assertUnreachable.js"; -import { PeerCoinRepair, selectPeerCoins } from "./util/coinSelection.js"; -import { checkLogicInvariant } from "./util/invariants.js"; import { InternalWalletState } from "./wallet.js"; const logger = new Logger("pay-peer-pull-debit.ts"); diff --git a/packages/taler-wallet-core/src/pay-peer-push-credit.ts b/packages/taler-wallet-core/src/pay-peer-push-credit.ts index 6dcf59be9..42a5b19df 100644 --- a/packages/taler-wallet-core/src/pay-peer-push-credit.ts +++ b/packages/taler-wallet-core/src/pay-peer-push-credit.ts @@ -37,6 +37,8 @@ import { TransactionType, WalletAccountMergeFlags, WalletKycUuid, + assertUnreachable, + checkDbInvariant, codecForAny, codecForExchangeGetContractResponse, codecForPeerContractTerms, @@ -80,8 +82,6 @@ import { notifyTransition, parseTransactionIdentifier, } from "./transactions.js"; -import { assertUnreachable } from "./util/assertUnreachable.js"; -import { checkDbInvariant } from "./util/invariants.js"; import { InternalWalletState } from "./wallet.js"; import { PerformCreateWithdrawalGroupResult, diff --git a/packages/taler-wallet-core/src/pay-peer-push-debit.ts b/packages/taler-wallet-core/src/pay-peer-push-debit.ts index 098602ee0..1bb3b8772 100644 --- a/packages/taler-wallet-core/src/pay-peer-push-debit.ts +++ b/packages/taler-wallet-core/src/pay-peer-push-debit.ts @@ -38,6 +38,8 @@ import { TransactionMinorState, TransactionState, TransactionType, + assertUnreachable, + checkLogicInvariant, encodeCrock, getRandomBytes, j2s, @@ -47,6 +49,7 @@ import { readSuccessResponseJsonOrThrow, readTalerErrorResponse, } from "@gnu-taler/taler-util/http"; +import { PeerCoinRepair, selectPeerCoins } from "./coinSelection.js"; import { PendingTaskType, TaskId, @@ -75,9 +78,6 @@ import { constructTransactionIdentifier, notifyTransition, } from "./transactions.js"; -import { assertUnreachable } from "./util/assertUnreachable.js"; -import { PeerCoinRepair, selectPeerCoins } from "./util/coinSelection.js"; -import { checkLogicInvariant } from "./util/invariants.js"; import { InternalWalletState } from "./wallet.js"; const logger = new Logger("pay-peer-push-debit.ts"); diff --git a/packages/taler-wallet-core/src/recoup.ts b/packages/taler-wallet-core/src/recoup.ts index 99c8aabc3..6f1546d57 100644 --- a/packages/taler-wallet-core/src/recoup.ts +++ b/packages/taler-wallet-core/src/recoup.ts @@ -33,6 +33,7 @@ import { TalerPreciseTimestamp, TransactionType, URL, + checkDbInvariant, codecForRecoupConfirmation, codecForReserveStatus, encodeCrock, @@ -60,7 +61,6 @@ import { } from "./db.js"; import { createRefreshGroup } from "./refresh.js"; import { constructTransactionIdentifier } from "./transactions.js"; -import { checkDbInvariant } from "./util/invariants.js"; import type { InternalWalletState } from "./wallet.js"; import { internalCreateWithdrawalGroup } from "./withdraw.js"; diff --git a/packages/taler-wallet-core/src/refresh.ts b/packages/taler-wallet-core/src/refresh.ts index 8f3a5cab5..3b75ae2f3 100644 --- a/packages/taler-wallet-core/src/refresh.ts +++ b/packages/taler-wallet-core/src/refresh.ts @@ -20,7 +20,9 @@ import { AmountJson, Amounts, amountToPretty, + assertUnreachable, CancellationToken, + checkDbInvariant, codecForExchangeMeltResponse, codecForExchangeRevealResponse, CoinPublicKeyString, @@ -60,6 +62,7 @@ import { readSuccessResponseJsonOrThrow, readUnexpectedResponseDetails, } from "@gnu-taler/taler-util/http"; +import { selectWithdrawalDenominations } from "./coinSelection.js"; import { constructTaskIdentifier, makeCoinAvailable, @@ -95,9 +98,6 @@ import { constructTransactionIdentifier, notifyTransition, } from "./transactions.js"; -import { assertUnreachable } from "./util/assertUnreachable.js"; -import { selectWithdrawalDenominations } from "./util/coinSelection.js"; -import { checkDbInvariant } from "./util/invariants.js"; import { EXCHANGE_COINS_LOCK, InternalWalletState } from "./wallet.js"; import { getCandidateWithdrawalDenomsTx } from "./withdraw.js"; diff --git a/packages/taler-wallet-core/src/reward.ts b/packages/taler-wallet-core/src/reward.ts index 6e7d3425c..6bfd3b324 100644 --- a/packages/taler-wallet-core/src/reward.ts +++ b/packages/taler-wallet-core/src/reward.ts @@ -27,6 +27,7 @@ import { TransactionMinorState, TransactionState, TransactionType, + assertUnreachable, } from "@gnu-taler/taler-util"; import { PendingTaskType, @@ -40,7 +41,6 @@ import { constructTransactionIdentifier, notifyTransition, } from "./transactions.js"; -import { assertUnreachable } from "./util/assertUnreachable.js"; import { InternalWalletState } from "./wallet.js"; const logger = new Logger("operations/tip.ts"); diff --git a/packages/taler-wallet-core/src/testing.ts b/packages/taler-wallet-core/src/testing.ts index 4dcb59868..bcb6abca0 100644 --- a/packages/taler-wallet-core/src/testing.ts +++ b/packages/taler-wallet-core/src/testing.ts @@ -28,6 +28,7 @@ import { addPaytoQueryParams, Amounts, AmountString, + checkLogicInvariant, CheckPaymentResponse, codecForAny, codecForCheckPaymentResponse, @@ -76,7 +77,6 @@ import { import { initiatePeerPushDebit } from "./pay-peer-push-debit.js"; import { getRefreshesForTransaction } from "./refresh.js"; import { getTransactionById, getTransactions } from "./transactions.js"; -import { checkLogicInvariant } from "./util/invariants.js"; import type { InternalWalletState } from "./wallet.js"; import { acceptWithdrawalFromUri } from "./withdraw.js"; diff --git a/packages/taler-wallet-core/src/transactions.ts b/packages/taler-wallet-core/src/transactions.ts index 828c4dec3..7d54ca980 100644 --- a/packages/taler-wallet-core/src/transactions.ts +++ b/packages/taler-wallet-core/src/transactions.ts @@ -21,6 +21,9 @@ import { GlobalIDB } from "@gnu-taler/idb-bridge"; import { AbsoluteTime, Amounts, + assertUnreachable, + checkDbInvariant, + checkLogicInvariant, DepositTransactionTrackingState, j2s, Logger, @@ -129,8 +132,6 @@ import { computeTipTransactionActions, RewardTransactionContext, } from "./reward.js"; -import { assertUnreachable } from "./util/assertUnreachable.js"; -import { checkDbInvariant, checkLogicInvariant } from "./util/invariants.js"; import type { InternalWalletState } from "./wallet.js"; import { augmentPaytoUrisForWithdrawal, 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 - */ - -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 - */ -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(), - 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 - */ - -/** - * 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; -} - -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; - - lastDepositFee: AmountJson; -} - -/** - * Account for the fees of spending a coin. - */ -function tallyFees( - tally: Readonly, - wireFeesPerExchange: Record, - 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 { - 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 -): ReturnType { - return selectGreedy(...args); -} - -function selectGreedy( - req: SelectPayCoinRequestNg, - candidateDenoms: AvailableDenom[], - wireFeesPerExchange: Record, - 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]> { - 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 = {}; - 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 { - 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 -): ReturnType { - 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 { - 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 - */ - -/** - * - * @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 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 as DenominationInfo, - { - value: VALUES[1], - stampStart: TIMESTAMPS[2], - stampExpireDeposit: TIMESTAMPS[4], - feeDeposit: VALUES[2], - } as Partial 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 as DenominationInfo, - { - value: VALUES[1], - stampStart: TIMESTAMPS[2], - stampExpireDeposit: TIMESTAMPS[4], - feeDeposit: VALUES[1], - } as Partial 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 as DenominationInfo, - { - value: VALUES[1], - stampStart: TIMESTAMPS[3], - stampExpireDeposit: TIMESTAMPS[4], - feeDeposit: VALUES[1], - } as Partial 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 as DenominationInfo, - { - value: VALUES[1], - stampStart: TIMESTAMPS[1], - stampExpireDeposit: TIMESTAMPS[4], - feeDeposit: VALUES[2], - } as Partial 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 as DenominationInfo, - { - value: VALUES[1], - stampStart: TIMESTAMPS[1], - stampExpireDeposit: TIMESTAMPS[4], - feeDeposit: VALUES[1], - } as Partial 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 as DenominationInfo, - { - value: VALUES[1], - stampStart: TIMESTAMPS[2], - stampExpireDeposit: TIMESTAMPS[4], - feeDeposit: VALUES[1], - } as Partial as DenominationInfo, - { - value: VALUES[1], - stampStart: TIMESTAMPS[2], - stampExpireDeposit: TIMESTAMPS[4], - feeDeposit: VALUES[2], - } as Partial 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 as DenominationInfo, - { - value: VALUES[1], - stampStart: TIMESTAMPS[2], - stampExpireDeposit: TIMESTAMPS[5], - feeDeposit: VALUES[1], - } as Partial as DenominationInfo, - { - value: VALUES[1], - stampStart: TIMESTAMPS[2], - stampExpireDeposit: TIMESTAMPS[4], - feeDeposit: VALUES[2], - } as Partial as DenominationInfo, - { - value: VALUES[1], - stampStart: TIMESTAMPS[5], - stampExpireDeposit: TIMESTAMPS[6], - feeDeposit: VALUES[3], - } as Partial 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 as DenominationInfo, - { - value: VALUES[1], - stampStart: TIMESTAMPS[2], - stampExpireDeposit: TIMESTAMPS[5], - feeDeposit: VALUES[1], - } as Partial as DenominationInfo, - { - value: VALUES[1], - stampStart: TIMESTAMPS[2], - stampExpireDeposit: TIMESTAMPS[5], - feeDeposit: VALUES[2], - } as Partial 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 as DenominationInfo, - { - value: VALUES[2], - stampStart: TIMESTAMPS[2], - stampExpireDeposit: TIMESTAMPS[4], - feeDeposit: VALUES[2], - } as Partial 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 as DenominationInfo, - { - value: VALUES[2], - stampStart: TIMESTAMPS[2], - stampExpireDeposit: TIMESTAMPS[4], - feeDeposit: VALUES[2], - } as Partial as DenominationInfo, - { - value: VALUES[1], - stampStart: TIMESTAMPS[1], - stampExpireDeposit: TIMESTAMPS[3], - feeDeposit: VALUES[1], - } as Partial as DenominationInfo, - { - value: VALUES[2], - stampStart: TIMESTAMPS[2], - stampExpireDeposit: TIMESTAMPS[4], - feeDeposit: VALUES[2], - } as Partial 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 - */ - -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( - 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 = 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( - list: Type[], - idProp: PropsWithReturnType, - periodStartProp: PropsWithReturnType, - periodEndProp: PropsWithReturnType, - feeProp: PropsWithReturnType, - groupProp: PropsWithReturnType | 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[]) - .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 - */ -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: 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 - */ - -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; -} - -/** - * 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 { - const operationType = getOperationType(TransactionType.Deposit); - - return await ws.db.runReadOnlyTx( - ["exchanges", "exchangeDetails", "denominations", "coinAvailability"], - async (tx) => { - const list: CoinInfo[] = []; - const exchanges: Record = {}; - - 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 { - 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 { - // 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 { - throw Error("to be implemented after 1.0"); -} -export async function getMaxPeerPushAmount( - ws: InternalWalletState, - req: GetAmountRequest, -): Promise { - throw Error("to be implemented after 1.0"); -} -export async function convertWithdrawalAmount( - ws: InternalWalletState, - req: ConvertAmountRequest, -): Promise { - 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, - 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 { - const groupByExchange: Record = {}; - for (const d of denoms) { - if (!groupByExchange[d.exchangeBaseUrl]) { - groupByExchange[d.exchangeBaseUrl] = []; - } - groupByExchange[d.exchangeBaseUrl].push(d); - } - - const result: Record = {}; - 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 - */ - -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"); - } - } -} diff --git a/packages/taler-wallet-core/src/wallet.ts b/packages/taler-wallet-core/src/wallet.ts index dfe7b2395..17fd74178 100644 --- a/packages/taler-wallet-core/src/wallet.ts +++ b/packages/taler-wallet-core/src/wallet.ts @@ -60,6 +60,8 @@ import { WalletCoreVersion, WalletNotification, WithdrawalDetailsForAmount, + assertUnreachable, + checkDbInvariant, codecForAbortTransaction, codecForAcceptBankIntegratedWithdrawalRequest, codecForAcceptExchangeTosRequest, @@ -246,15 +248,13 @@ import { retryTransaction, suspendTransaction, } from "./transactions.js"; -import { assertUnreachable } from "./util/assertUnreachable.js"; import { convertDepositAmount, convertPeerPushAmount, convertWithdrawalAmount, getMaxDepositAmount, getMaxPeerPushAmount, -} from "./util/instructedAmountConversion.js"; -import { checkDbInvariant } from "./util/invariants.js"; +} from "./instructedAmountConversion.js"; import { WALLET_BANK_CONVERSION_API_PROTOCOL_VERSION, WALLET_BANK_INTEGRATION_PROTOCOL_VERSION, diff --git a/packages/taler-wallet-core/src/withdraw.test.ts b/packages/taler-wallet-core/src/withdraw.test.ts index 3eb6413e4..3e92b1717 100644 --- a/packages/taler-wallet-core/src/withdraw.test.ts +++ b/packages/taler-wallet-core/src/withdraw.test.ts @@ -21,7 +21,7 @@ import { DenominationVerificationStatus, timestampProtocolToDb, } from "./db.js"; -import { selectWithdrawalDenominations } from "./util/coinSelection.js"; +import { selectWithdrawalDenominations } from "./coinSelection.js"; test("withdrawal selection bug repro", (t) => { const amount = { diff --git a/packages/taler-wallet-core/src/withdraw.ts b/packages/taler-wallet-core/src/withdraw.ts index 391fbc9e2..2d9f5c35c 100644 --- a/packages/taler-wallet-core/src/withdraw.ts +++ b/packages/taler-wallet-core/src/withdraw.ts @@ -69,7 +69,10 @@ import { WithdrawUriInfoResponse, WithdrawalExchangeAccountDetails, addPaytoQueryParams, + assertUnreachable, canonicalizeBaseUrl, + checkDbInvariant, + checkLogicInvariant, codeForBankWithdrawalOperationPostResponse, codecForCashinConversionResponse, codecForConversionBankConfig, @@ -91,6 +94,10 @@ import { readSuccessResponseJsonOrThrow, throwUnexpectedRequestError, } from "@gnu-taler/taler-util/http"; +import { + selectForcedWithdrawalDenominations, + selectWithdrawalDenominations, +} from "./coinSelection.js"; import { PendingTaskType, TaskId, @@ -120,6 +127,7 @@ import { WithdrawalRecordType, timestampPreciseToDb, } from "./db.js"; +import { isWithdrawableDenom } from "./denominations.js"; import { ReadyExchangeSummary, fetchFreshExchange, @@ -134,13 +142,6 @@ import { constructTransactionIdentifier, notifyTransition, } from "./transactions.js"; -import { assertUnreachable } from "./util/assertUnreachable.js"; -import { - selectForcedWithdrawalDenominations, - selectWithdrawalDenominations, -} from "./util/coinSelection.js"; -import { isWithdrawableDenom } from "./util/denominations.js"; -import { checkDbInvariant, checkLogicInvariant } from "./util/invariants.js"; import { WALLET_BANK_INTEGRATION_PROTOCOL_VERSION, WALLET_EXCHANGE_PROTOCOL_VERSION, -- cgit v1.2.3