From a386de8a9c1aa3fff76b4cb37fb3287213981387 Mon Sep 17 00:00:00 2001 From: Florian Dold Date: Tue, 29 Aug 2023 18:33:51 +0200 Subject: [PATCH] wallet-core: split coin selection and instructed amount conversion --- .../src/operations/pay-peer-common.ts | 265 +---- .../src/operations/pay-peer-pull-debit.ts | 3 +- .../src/operations/pay-peer-push-debit.ts | 3 +- .../src/util/coinSelection.test.ts | 742 ------------ .../src/util/coinSelection.ts | 1024 ++++------------- .../util/instructedAmountConversion.test.ts | 763 ++++++++++++ .../src/util/instructedAmountConversion.ts | 849 ++++++++++++++ packages/taler-wallet-core/src/wallet.ts | 14 +- 8 files changed, 1865 insertions(+), 1798 deletions(-) create mode 100644 packages/taler-wallet-core/src/util/instructedAmountConversion.test.ts create mode 100644 packages/taler-wallet-core/src/util/instructedAmountConversion.ts diff --git a/packages/taler-wallet-core/src/operations/pay-peer-common.ts b/packages/taler-wallet-core/src/operations/pay-peer-common.ts index 4fdfecb4d..49f255eb9 100644 --- a/packages/taler-wallet-core/src/operations/pay-peer-common.ts +++ b/packages/taler-wallet-core/src/operations/pay-peer-common.ts @@ -43,8 +43,6 @@ import { import { SpendCoinDetails } from "../crypto/cryptoImplementation.js"; import { DenominationRecord, - KycPendingInfo, - KycUserType, PeerPushPaymentCoinSelection, ReserveRecord, } from "../db.js"; @@ -52,68 +50,13 @@ import { InternalWalletState } from "../internal-wallet-state.js"; import { checkDbInvariant } from "../util/invariants.js"; import { getPeerPaymentBalanceDetailsInTx } from "./balance.js"; import { getTotalRefreshCost } from "./refresh.js"; +import type { PeerCoinInfo, PeerCoinSelectionRequest, SelectPeerCoinsResult, SelectedPeerCoin } from "../util/coinSelection.js"; const logger = new Logger("operations/peer-to-peer.ts"); -interface SelectedPeerCoin { - coinPub: string; - coinPriv: string; - contribution: AmountString; - denomPubHash: string; - denomSig: UnblindedSignature; - ageCommitmentProof: AgeCommitmentProof | undefined; -} - -interface PeerCoinSelectionDetails { - exchangeBaseUrl: string; - - /** - * Info of Coins that were selected. - */ - coins: SelectedPeerCoin[]; - - /** - * How much of the deposit fees is the customer paying? - */ - depositFees: AmountJson; -} - -/** - * Information about a selected coin for peer to peer payments. - */ -interface CoinInfo { - /** - * Public key of the coin. - */ - coinPub: string; - - coinPriv: string; - - /** - * Deposit fee for the coin. - */ - feeDeposit: AmountJson; - - value: AmountJson; - - denomPubHash: string; - - denomSig: UnblindedSignature; - - maxAge: number; - - ageCommitmentProof?: AgeCommitmentProof; -} - -export type SelectPeerCoinsResult = - | { type: "success"; result: PeerCoinSelectionDetails } - | { - type: "failure"; - insufficientBalanceDetails: PayPeerInsufficientBalanceDetails; - }; - /** * Get information about the coin selected for signatures + * * @param ws * @param csel * @returns @@ -153,211 +96,7 @@ export async function queryCoinInfosForSelection( return infos; } -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; -} - -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 - .mktx((x) => [ - x.exchanges, - x.contractTerms, - x.coins, - x.coinAvailability, - x.denominations, - x.refreshGroups, - x.peerPushPaymentInitiations, - ]) - .runReadWrite(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; - } - // FIXME: Can't we do this faster by using coinAvailability? - const coins = ( - await tx.coins.indexes.byBaseUrl.getAll(exch.baseUrl) - ).filter((x) => x.status === CoinStatus.Fresh); - const coinInfos: CoinInfo[] = []; - for (const coin of coins) { - const denom = await ws.getDenomInfo( - ws, - tx, - coin.exchangeBaseUrl, - coin.denomPubHash, - ); - if (!denom) { - throw Error("denom not found"); - } - coinInfos.push({ - coinPub: coin.coinPub, - feeDeposit: Amounts.parseOrThrow(denom.feeDeposit), - value: Amounts.parseOrThrow(denom.value), - denomPubHash: denom.denomPubHash, - coinPriv: coin.coinPriv, - denomSig: coin.denomSig, - maxAge: coin.maxAge, - ageCommitmentProof: coin.ageCommitmentProof, - }); - } - if (coinInfos.length === 0) { - continue; - } - coinInfos.sort( - (o1, o2) => - -Amounts.cmp(o1.value, o2.value) || - strcmp(o1.denomPubHash, o2.denomPubHash), - ); - let amountAcc = Amounts.zeroOfCurrency(currency); - let depositFeesAcc = Amounts.zeroOfCurrency(currency); - const resCoins: { - coinPub: string; - coinPriv: string; - contribution: AmountString; - denomPubHash: string; - denomSig: UnblindedSignature; - ageCommitmentProof: AgeCommitmentProof | undefined; - }[] = []; - let lastDepositFee = Amounts.zeroOfCurrency(currency); - - if (req.repair) { - 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); - lastDepositFee = depositFee; - amountAcc = Amounts.add( - amountAcc, - Amounts.sub(contrib, depositFee).amount, - ).amount; - depositFeesAcc = Amounts.add(depositFeesAcc, depositFee).amount; - } - } - - for (const coin of coinInfos) { - if (Amounts.cmp(amountAcc, instructedAmount) >= 0) { - break; - } - const gap = Amounts.add( - coin.feeDeposit, - Amounts.sub(instructedAmount, amountAcc).amount, - ).amount; - const contrib = Amounts.min(gap, coin.value); - amountAcc = Amounts.add( - amountAcc, - Amounts.sub(contrib, coin.feeDeposit).amount, - ).amount; - depositFeesAcc = Amounts.add(depositFeesAcc, coin.feeDeposit).amount; - resCoins.push({ - coinPriv: coin.coinPriv, - coinPub: coin.coinPub, - contribution: Amounts.stringify(contrib), - denomPubHash: coin.denomPubHash, - denomSig: coin.denomSig, - ageCommitmentProof: coin.ageCommitmentProof, - }); - lastDepositFee = coin.feeDeposit; - } - if (Amounts.cmp(amountAcc, instructedAmount) >= 0) { - const res: PeerCoinSelectionDetails = { - exchangeBaseUrl: exch.baseUrl, - coins: resCoins, - depositFees: depositFeesAcc, - }; - return { type: "success", result: res }; - } - const diff = Amounts.sub(instructedAmount, amountAcc).amount; - exchangeFeeGap[exch.baseUrl] = Amounts.add(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 }; - }); -} export async function getTotalPeerPaymentCost( ws: InternalWalletState, diff --git a/packages/taler-wallet-core/src/operations/pay-peer-pull-debit.ts b/packages/taler-wallet-core/src/operations/pay-peer-pull-debit.ts index 8ba84585c..0de91bf97 100644 --- a/packages/taler-wallet-core/src/operations/pay-peer-pull-debit.ts +++ b/packages/taler-wallet-core/src/operations/pay-peer-pull-debit.ts @@ -68,11 +68,9 @@ import { spendCoins, } from "./common.js"; import { - PeerCoinRepair, codecForExchangePurseStatus, getTotalPeerPaymentCost, queryCoinInfosForSelection, - selectPeerCoins, } from "./pay-peer-common.js"; import { constructTransactionIdentifier, @@ -80,6 +78,7 @@ import { parseTransactionIdentifier, stopLongpolling, } from "./transactions.js"; +import { PeerCoinRepair, selectPeerCoins } from "../util/coinSelection.js"; const logger = new Logger("pay-peer-pull-debit.ts"); diff --git a/packages/taler-wallet-core/src/operations/pay-peer-push-debit.ts b/packages/taler-wallet-core/src/operations/pay-peer-push-debit.ts index c853bc0ef..2349e5c4a 100644 --- a/packages/taler-wallet-core/src/operations/pay-peer-push-debit.ts +++ b/packages/taler-wallet-core/src/operations/pay-peer-push-debit.ts @@ -68,17 +68,16 @@ import { spendCoins, } from "./common.js"; import { - PeerCoinRepair, codecForExchangePurseStatus, getTotalPeerPaymentCost, queryCoinInfosForSelection, - selectPeerCoins, } from "./pay-peer-common.js"; import { constructTransactionIdentifier, notifyTransition, stopLongpolling, } from "./transactions.js"; +import { PeerCoinRepair, selectPeerCoins } from "../util/coinSelection.js"; const logger = new Logger("pay-peer-push-debit.ts"); diff --git a/packages/taler-wallet-core/src/util/coinSelection.test.ts b/packages/taler-wallet-core/src/util/coinSelection.test.ts index fddd217ea..b907eb160 100644 --- a/packages/taler-wallet-core/src/util/coinSelection.test.ts +++ b/packages/taler-wallet-core/src/util/coinSelection.test.ts @@ -22,746 +22,4 @@ import { TransactionAmountMode, } from "@gnu-taler/taler-util"; import test, { ExecutionContext } from "ava"; -import { - CoinInfo, - convertDepositAmountForAvailableCoins, - convertWithdrawalAmountFromAvailableCoins, - getMaxDepositAmountForAvailableCoins, -} from "./coinSelection.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/coinSelection.ts b/packages/taler-wallet-core/src/util/coinSelection.ts index d3c6ffc67..bb901fd75 100644 --- a/packages/taler-wallet-core/src/util/coinSelection.ts +++ b/packages/taler-wallet-core/src/util/coinSelection.ts @@ -31,6 +31,8 @@ import { AmountJson, AmountResponse, Amounts, + AmountString, + CoinPublicKeyString, CoinStatus, ConvertAmountRequest, DenominationInfo, @@ -40,28 +42,28 @@ import { ForcedCoinSel, ForcedDenomSel, GetAmountRequest, - GetPlanForOperationRequest, j2s, Logger, parsePaytoUri, PayCoinSelection, PayMerchantInsufficientBalanceDetails, + PayPeerInsufficientBalanceDetails, strcmp, TransactionAmountMode, TransactionType, + UnblindedSignature, } from "@gnu-taler/taler-util"; import { AllowedAuditorInfo, AllowedExchangeInfo, DenominationRecord, } from "../db.js"; -import { - CoinAvailabilityRecord, - getExchangeDetails, - isWithdrawableDenom, -} from "../index.js"; +import { getExchangeDetails, isWithdrawableDenom } from "../index.js"; import { InternalWalletState } from "../internal-wallet-state.js"; -import { getMerchantPaymentBalanceDetails } from "../operations/balance.js"; +import { + getMerchantPaymentBalanceDetails, + getPeerPaymentBalanceDetailsInTx, +} from "../operations/balance.js"; import { checkDbInvariant, checkLogicInvariant } from "./invariants.js"; const logger = new Logger("coinSelection.ts"); @@ -255,7 +257,7 @@ export async function selectPayCoinsNew( wireFeeAmortization, } = req; - const [candidateDenoms, wireFeesPerExchange] = await selectCandidates( + const [candidateDenoms, wireFeesPerExchange] = await selectPayMerchantCandidates( ws, req, ); @@ -549,7 +551,7 @@ export type AvailableDenom = DenominationInfo & { numAvailable: number; }; -async function selectCandidates( +async function selectPayMerchantCandidates( ws: InternalWalletState, req: SelectPayCoinRequestNg, ): Promise<[AvailableDenom[], Record]> { @@ -797,76 +799,6 @@ export function selectForcedWithdrawalDenominations( }; } -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, - }; - } - } -} - -/** - * If the operation going to be plan subtracts - * or adds amount in the wallet db - */ -export enum OperationType { - Credit = "credit", - Debit = "debit", -} - -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 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 AvailableCoins { - list: CoinInfo[]; - exchanges: Record; -} -interface SelectedCoins { - totalValue: AmountJson; - coins: { info: CoinInfo; size: number }[]; - refresh?: RefreshChoice; -} - export interface CoinInfo { id: string; value: AmountJson; @@ -880,739 +812,267 @@ export interface CoinInfo { exchangeBaseUrl: string; maxAge: number; } -interface ExchangeInfo { - wireFee: AmountJson | undefined; - purseFee: AmountJson | undefined; - creditDeadline: AbsoluteTime; - debitDeadline: AbsoluteTime; + + +export interface SelectedPeerCoin { + coinPub: string; + coinPriv: string; + contribution: AmountString; + denomPubHash: string; + denomSig: UnblindedSignature; + ageCommitmentProof: AgeCommitmentProof | undefined; } -interface CoinsFilter { - shouldCalculatePurseFee?: boolean; - exchanges?: string[]; - wireMethod?: string; - ageRestricted?: number; +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; } + /** - * 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 + * Information about a selected coin for peer to peer payments. */ -async function getAvailableDenoms( - ws: InternalWalletState, - op: TransactionType, - currency: string, - filters: CoinsFilter = {}, -): Promise { - const operationType = getOperationType(TransactionType.Deposit); +export interface PeerCoinInfo { + /** + * Public key of the coin. + */ + coinPub: string; + coinPriv: string; + + /** + * Deposit fee for the coin. + */ + feeDeposit: AmountJson; + + value: AmountJson; + + denomPubHash: string; + + denomSig: UnblindedSignature; + + maxAge: number; + + ageCommitmentProof?: AgeCommitmentProof; +} + +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; +} + +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 .mktx((x) => [ x.exchanges, - x.exchangeDetails, - x.denominations, + x.contractTerms, + x.coins, x.coinAvailability, + x.denominations, + x.refreshGroups, + x.peerPushPaymentInitiations, ]) - .runReadOnly(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 getExchangeDetails(tx, exchangeBaseUrl); - // 1.- exchange has same currency - if (exchangeDetails?.currency !== currency) { + .runReadWrite(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; } - - 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}`, - ); + // FIXME: Can't we do this faster by using coinAvailability? + const coins = ( + await tx.coins.indexes.byBaseUrl.getAll(exch.baseUrl) + ).filter((x) => x.status === CoinStatus.Fresh); + const coinInfos: PeerCoinInfo[] = []; + for (const coin of coins) { + const denom = await ws.getDenomInfo( + ws, + tx, + coin.exchangeBaseUrl, + coin.denomPubHash, + ); + if (!denom) { + throw Error("denom not found"); } - const wireMethodFee = wireMethodWithDates.find((x) => { - return AbsoluteTime.isBetween( - AbsoluteTime.now(), - AbsoluteTime.fromProtocolTimestamp(x.startStamp), - AbsoluteTime.fromProtocolTimestamp(x.endStamp), - ); + coinInfos.push({ + coinPub: coin.coinPub, + feeDeposit: Amounts.parseOrThrow(denom.feeDeposit), + value: Amounts.parseOrThrow(denom.value), + denomPubHash: denom.denomPubHash, + coinPriv: coin.coinPriv, + denomSig: coin.denomSig, + maxAge: coin.maxAge, + ageCommitmentProof: coin.ageCommitmentProof, }); - - 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), - ); + if (coinInfos.length === 0) { + continue; } + coinInfos.sort( + (o1, o2) => + -Amounts.cmp(o1.value, o2.value) || + strcmp(o1.denomPubHash, o2.denomPubHash), + ); + let amountAcc = Amounts.zeroOfCurrency(currency); + let depositFeesAcc = Amounts.zeroOfCurrency(currency); + const resCoins: { + coinPub: string; + coinPriv: string; + contribution: AmountString; + denomPubHash: string; + denomSig: UnblindedSignature; + ageCommitmentProof: AgeCommitmentProof | undefined; + }[] = []; + let lastDepositFee = Amounts.zeroOfCurrency(currency); - let creditDeadline = AbsoluteTime.never(); - let debitDeadline = AbsoluteTime.never(); - //4.- filter coins restricted by age - if (operationType === OperationType.Credit) { - const ds = await tx.denominations.indexes.byExchangeBaseUrl.getAll( - exchangeBaseUrl, - ); - for (const denom of ds) { - const expiresWithdraw = AbsoluteTime.fromProtocolTimestamp( - denom.stampExpireWithdraw, - ); - const expiresDeposit = AbsoluteTime.fromProtocolTimestamp( - 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; + if (req.repair) { + 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 expiresWithdraw = AbsoluteTime.fromProtocolTimestamp( - denom.stampExpireWithdraw, - ); - const expiresDeposit = AbsoluteTime.fromProtocolTimestamp( - denom.stampExpireDeposit, - ); - creditDeadline = AbsoluteTime.min(deadline, expiresWithdraw); - debitDeadline = AbsoluteTime.min(deadline, expiresDeposit); - list.push( - buildCoinInfoFromDenom( - denom, - purseFee, - wireFee, - coinAvail.maxAge, - coinAvail.freshCoinCount, - ), + 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); + lastDepositFee = depositFee; + amountAcc = Amounts.add( + amountAcc, + Amounts.sub(contrib, depositFee).amount, + ).amount; + depositFeesAcc = Amounts.add(depositFeesAcc, depositFee).amount; } } - exchanges[exchangeBaseUrl] = { - purseFee, - wireFee, - debitDeadline, - creditDeadline, - }; + for (const coin of coinInfos) { + if (Amounts.cmp(amountAcc, instructedAmount) >= 0) { + break; + } + const gap = Amounts.add( + coin.feeDeposit, + Amounts.sub(instructedAmount, amountAcc).amount, + ).amount; + const contrib = Amounts.min(gap, coin.value); + amountAcc = Amounts.add( + amountAcc, + Amounts.sub(contrib, coin.feeDeposit).amount, + ).amount; + depositFeesAcc = Amounts.add(depositFeesAcc, coin.feeDeposit).amount; + resCoins.push({ + coinPriv: coin.coinPriv, + coinPub: coin.coinPub, + contribution: Amounts.stringify(contrib), + denomPubHash: coin.denomPubHash, + denomSig: coin.denomSig, + ageCommitmentProof: coin.ageCommitmentProof, + }); + lastDepositFee = coin.feeDeposit; + } + if (Amounts.cmp(amountAcc, instructedAmount) >= 0) { + const res: PeerCoinSelectionDetails = { + exchangeBaseUrl: exch.baseUrl, + coins: resCoins, + depositFees: depositFeesAcc, + }; + return { type: "success", result: res }; + } + const diff = Amounts.sub(instructedAmount, amountAcc).amount; + exchangeFeeGap[exch.baseUrl] = Amounts.add(lastDepositFee, diff).amount; + + continue; } - return { list, exchanges }; - }); -} + // We were unable to select coins. + // Now we need to produce error details. -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(denom.stampExpireDeposit), - ), - totalAvailable: total, - value: DenominationRecord.getValue(denom), - 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}`; + const infoGeneral = await getPeerPaymentBalanceDetailsInTx(ws, tx, { + currency, }); - 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 + const perExchange: PayPeerInsufficientBalanceDetails["perExchange"] = {}; - //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) - ); - }); + let maxFeeGapEstimate = Amounts.zeroOfCurrency(currency); - 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, + 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); } - 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); + + 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/instructedAmountConversion.test.ts b/packages/taler-wallet-core/src/util/instructedAmountConversion.test.ts new file mode 100644 index 000000000..de8515d09 --- /dev/null +++ b/packages/taler-wallet-core/src/util/instructedAmountConversion.test.ts @@ -0,0 +1,763 @@ +/* + 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 new file mode 100644 index 000000000..bd02e7b22 --- /dev/null +++ b/packages/taler-wallet-core/src/util/instructedAmountConversion.ts @@ -0,0 +1,849 @@ +/* + 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 { + AbsoluteTime, + AgeRestriction, + AmountJson, + AmountResponse, + Amounts, + ConvertAmountRequest, + Duration, + GetAmountRequest, + GetPlanForOperationRequest, + TransactionAmountMode, + TransactionType, + parsePaytoUri, + strcmp, +} from "@gnu-taler/taler-util"; +import { checkDbInvariant } from "./invariants.js"; +import { + DenominationRecord, + InternalWalletState, + getExchangeDetails, +} from "../index.js"; +import { CoinInfo } from "./coinSelection.js"; +import { GlobalIDB } from "@gnu-taler/idb-bridge"; + +/** + * 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 + .mktx((x) => [ + x.exchanges, + x.exchangeDetails, + x.denominations, + x.coinAvailability, + ]) + .runReadOnly(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 getExchangeDetails(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) { + const ds = await tx.denominations.indexes.byExchangeBaseUrl.getAll( + exchangeBaseUrl, + ); + for (const denom of ds) { + const expiresWithdraw = AbsoluteTime.fromProtocolTimestamp( + denom.stampExpireWithdraw, + ); + const expiresDeposit = AbsoluteTime.fromProtocolTimestamp( + 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( + denom.stampExpireWithdraw, + ); + const expiresDeposit = AbsoluteTime.fromProtocolTimestamp( + 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(denom.stampExpireDeposit), + ), + totalAvailable: total, + value: DenominationRecord.getValue(denom), + 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/wallet.ts b/packages/taler-wallet-core/src/wallet.ts index b967571d0..bff4442b6 100644 --- a/packages/taler-wallet-core/src/wallet.ts +++ b/packages/taler-wallet-core/src/wallet.ts @@ -276,13 +276,6 @@ import { } from "./operations/withdraw.js"; import { PendingTaskInfo, PendingTaskType } from "./pending-types.js"; import { assertUnreachable } from "./util/assertUnreachable.js"; -import { - convertDepositAmount, - convertPeerPushAmount, - convertWithdrawalAmount, - getMaxDepositAmount, - getMaxPeerPushAmount, -} from "./util/coinSelection.js"; import { createTimeline, selectBestForOverlappingDenominations, @@ -313,6 +306,13 @@ import { WalletCoreApiClient, WalletCoreResponseType, } from "./wallet-api-types.js"; +import { + convertDepositAmount, + getMaxDepositAmount, + convertPeerPushAmount, + getMaxPeerPushAmount, + convertWithdrawalAmount, +} from "./util/instructedAmountConversion.js"; const logger = new Logger("wallet.ts");