diff options
author | Sebastian <sebasjm@gmail.com> | 2023-06-20 14:30:02 -0300 |
---|---|---|
committer | Sebastian <sebasjm@gmail.com> | 2023-06-20 14:30:02 -0300 |
commit | 1e9f1fb7a9451ad8fae6474cc831596a9e9a3f2f (patch) | |
tree | c1d3eaaf7bf4faab622ca138c47fee7b4d6ec5a6 /packages/taler-wallet-core | |
parent | d79155b634b2bdca48faa6ac3b25e21c3c30a062 (diff) | |
download | wallet-core-1e9f1fb7a9451ad8fae6474cc831596a9e9a3f2f.tar.xz |
remove calculate plan (for now) implemented simpler API
Diffstat (limited to 'packages/taler-wallet-core')
-rw-r--r-- | packages/taler-wallet-core/src/util/coinSelection.test.ts | 614 | ||||
-rw-r--r-- | packages/taler-wallet-core/src/util/coinSelection.ts | 906 | ||||
-rw-r--r-- | packages/taler-wallet-core/src/wallet-api-types.ts | 39 | ||||
-rw-r--r-- | packages/taler-wallet-core/src/wallet.ts | 36 |
4 files changed, 1092 insertions, 503 deletions
diff --git a/packages/taler-wallet-core/src/util/coinSelection.test.ts b/packages/taler-wallet-core/src/util/coinSelection.test.ts index ab3b2c4f8..3073b69c7 100644 --- a/packages/taler-wallet-core/src/util/coinSelection.test.ts +++ b/packages/taler-wallet-core/src/util/coinSelection.test.ts @@ -13,13 +13,6 @@ You should have received a copy of the GNU General Public License along with GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> */ -import test, { ExecutionContext } from "ava"; -import { - AmountMode, - OperationType, - calculatePlanFormAvailableCoins, - selectCoinForOperation, -} from "./coinSelection.js"; import { AbsoluteTime, AgeRestriction, @@ -27,28 +20,31 @@ import { Amounts, Duration, TransactionAmountMode, - TransactionType, } from "@gnu-taler/taler-util"; +import test, { ExecutionContext } from "ava"; +import { + CoinInfo, + convertDepositAmountForAvailableCoins, + convertWithdrawalAmountFromAvailableCoins, + getMaxDepositAmountForAvailableCoins, +} from "./coinSelection.js"; -function expect(t: ExecutionContext, thing: any): any { - return { - deep: { - equal: (another: any) => t.deepEqual(thing, another), - equals: (another: any) => t.deepEqual(thing, another), - }, +function makeCurrencyHelper(currency: string) { + return (sx: TemplateStringsArray, ...vx: any[]) => { + const s = String.raw({ raw: sx }, ...vx); + return Amounts.parseOrThrow(`${currency}:${s}`); }; } -function kudos(v: number): AmountJson { - return Amounts.fromFloat(v, "KUDOS"); -} +const kudos = makeCurrencyHelper("kudos"); -function defaultFeeConfig(value: AmountJson, totalAvailable: number) { +function defaultFeeConfig(value: AmountJson, totalAvailable: number): CoinInfo { return { id: Amounts.stringify(value), - denomDeposit: kudos(0.01), - denomRefresh: kudos(0.01), - denomWithdraw: kudos(0.01), + denomDeposit: kudos`0.01`, + denomRefresh: kudos`0.01`, + denomWithdraw: kudos`0.01`, + exchangeBaseUrl: "1", duration: Duration.getForever(), exchangePurse: undefined, exchangeWire: undefined, @@ -60,242 +56,574 @@ function defaultFeeConfig(value: AmountJson, totalAvailable: number) { type Coin = [AmountJson, number]; /** - * selectCoinForOperation test + * Making a deposit with effective amount * - * Test here should check that the correct coins are selected */ -test("get effective 2", (t) => { +test("deposit effective 2", (t) => { const coinList: Coin[] = [ - [kudos(2), 5], - [kudos(5), 5], + [kudos`2`, 5], + [kudos`5`, 5], ]; - const result = selectCoinForOperation( - OperationType.Credit, - kudos(2), - AmountMode.Net, + const result = convertDepositAmountForAvailableCoins( { list: coinList.map(([v, t]) => defaultFeeConfig(v, t)), exchanges: {}, }, + kudos`2`, + TransactionAmountMode.Effective, ); - expect(t, result.coins).deep.equal(["KUDOS:2"]); - t.assert(result.refresh === undefined); + t.is(Amounts.stringifyValue(result.effective), "2"); + t.is(Amounts.stringifyValue(result.raw), "1.99"); }); -test("get raw 4", (t) => { +test("deposit effective 10", (t) => { const coinList: Coin[] = [ - [kudos(2), 5], - [kudos(5), 5], + [kudos`2`, 5], + [kudos`5`, 5], ]; - const result = selectCoinForOperation( - OperationType.Credit, - kudos(4), - AmountMode.Gross, + 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"); +}); - expect(t, result.coins).deep.equal(["KUDOS:2", "KUDOS:2"]); - t.assert(result.refresh === undefined); +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("get raw 25, diff with demo ", (t) => { +test("deposit effective 40", (t) => { const coinList: Coin[] = [ - [kudos(0.1), 0], - [kudos(1), 0], - [kudos(2), 0], - [kudos(5), 0], - [kudos(10), 0], + [kudos`2`, 5], + [kudos`5`, 5], ]; - const result = selectCoinForOperation( - OperationType.Credit, - kudos(25), - AmountMode.Gross, + 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"); +}); - expect(t, result.coins).deep.equal(["KUDOS:10", "KUDOS:10", "KUDOS:5"]); - t.assert(result.refresh === undefined); +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"); }); -test("send effective 6", (t) => { +/** + * 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], + [kudos`2`, 5], + [kudos`5`, 5], ]; - const result = selectCoinForOperation( - OperationType.Debit, - kudos(6), - AmountMode.Gross, + const result = convertDepositAmountForAvailableCoins( { list: coinList.map(([v, t]) => defaultFeeConfig(v, t)), exchanges: {}, }, + kudos`1.99`, + TransactionAmountMode.Raw, ); - - expect(t, result.coins).deep.equal(["KUDOS:5"]); - t.assert(result.refresh?.selected === "KUDOS:2"); + t.is(Amounts.stringifyValue(result.effective), "2"); + t.is(Amounts.stringifyValue(result.raw), "1.99"); }); -test("send raw 6", (t) => { +test("deposit raw 9.98 (effective 10)", (t) => { const coinList: Coin[] = [ - [kudos(2), 5], - [kudos(5), 5], + [kudos`2`, 5], + [kudos`5`, 5], ]; - const result = selectCoinForOperation( - OperationType.Debit, - kudos(6), - AmountMode.Gross, + 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"); +}); - expect(t, result.coins).deep.equal(["KUDOS:5"]); - t.assert(result.refresh?.selected === "KUDOS:2"); +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("send raw 20 (not enough)", (t) => { +test("deposit raw 34.9 (effective 40)", (t) => { const coinList: Coin[] = [ - [kudos(2), 1], - [kudos(5), 2], + [kudos`2`, 5], + [kudos`5`, 5], ]; - const result = selectCoinForOperation( - OperationType.Debit, - kudos(20), - AmountMode.Gross, + 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"); +}); - expect(t, result.coins).deep.equal(["KUDOS:5", "KUDOS:5", "KUDOS:2"]); - t.assert(result.refresh === undefined); +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"); }); /** - * calculatePlanFormAvailableCoins test + * Calculating the max amount possible to deposit * - * Test here should check that the plan summary for a transaction is correct - * * effective amount - * * raw amount */ -test("deposit effective 2 ", (t) => { +test("deposit max 35", (t) => { const coinList: Coin[] = [ - [kudos(2), 1], - [kudos(5), 2], + [kudos`2`, 5], + [kudos`5`, 5], ]; - const result = calculatePlanFormAvailableCoins( - TransactionType.Deposit, - kudos(2), - TransactionAmountMode.Effective, + 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(), - wireFee: kudos(0.01), - purseFee: kudos(0.01), }, }, }, + "KUDOS", ); - - t.deepEqual(result.rawAmount, "KUDOS:1.98"); - t.deepEqual(result.effectiveAmount, "KUDOS:2"); + t.is(Amounts.stringifyValue(result.raw), "34.9"); + t.is(Amounts.stringifyValue(result.effective), "35"); }); -test("deposit raw 2 ", (t) => { +test("deposit max 35 with wirefee", (t) => { const coinList: Coin[] = [ - [kudos(2), 1], - [kudos(5), 2], + [kudos`2`, 5], + [kudos`5`, 5], ]; - const result = calculatePlanFormAvailableCoins( - TransactionType.Deposit, - kudos(2), - TransactionAmountMode.Raw, + 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(), - wireFee: kudos(0.01), - purseFee: kudos(0.01), }, }, }, + "KUDOS", ); - - t.deepEqual(result.rawAmount, "KUDOS:2"); - t.deepEqual(result.effectiveAmount, "KUDOS:2.04"); + t.is(Amounts.stringifyValue(result.raw), "33.9"); + t.is(Amounts.stringifyValue(result.effective), "35"); }); -test("withdraw raw 21 ", (t) => { +test("deposit max repeated denom", (t) => { const coinList: Coin[] = [ - [kudos(2), 1], - [kudos(5), 2], + [kudos`2`, 1], + [kudos`2`, 1], + [kudos`5`, 1], ]; - const result = calculatePlanFormAvailableCoins( - TransactionType.Withdrawal, - kudos(21), - TransactionAmountMode.Raw, + 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(), - wireFee: kudos(0.01), - purseFee: kudos(0.01), }, }, }, + "KUDOS", ); + t.is(Amounts.stringifyValue(result.raw), "8.97"); + t.is(Amounts.stringifyValue(result.effective), "9"); +}); - // denominations configuration is not suitable - // for greedy algorithm - t.deepEqual(result.rawAmount, "KUDOS:20"); - t.deepEqual(result.effectiveAmount, "KUDOS:19.96"); +/** + * 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 raw 25, diff with demo ", (t) => { +test("withdraw effective 40", (t) => { const coinList: Coin[] = [ - [kudos(0.1), 0], - [kudos(1), 0], - [kudos(2), 0], - [kudos(5), 0], - [kudos(10), 0], + [kudos`2`, 5], + [kudos`5`, 5], ]; - const result = calculatePlanFormAvailableCoins( - TransactionType.Withdrawal, - kudos(25), + 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: { - "2": { - creditDeadline: AbsoluteTime.never(), - debitDeadline: AbsoluteTime.never(), - wireFee: kudos(0.01), - purseFee: kudos(0.01), - }, - }, + 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 - t.deepEqual(result.rawAmount, "KUDOS:25"); - // here demo report KUDOS:0.2 fee - // t.deepEqual(result.effectiveAmount, "KUDOS:24.80"); - t.deepEqual(result.effectiveAmount, "KUDOS:24.97"); + //coins sent 2 x 2.0 + //coins recv 9 x 0.1 + //------------------- + //effective 3.10 + //raw 2.98 + t.is(Amounts.stringifyValue(result.refresh.selected.id), "2"); + t.deepEqual(asCoinList(result.refresh.coins), [[kudos`0.1`, 9]]); }); + +test("deposit with refresh: raw 2.98 (effective 3)", (t) => { + const coinList: Coin[] = [ + [kudos`0.1`, 0], + [kudos`1`, 0], + [kudos`2`, 5], + [kudos`5`, 5], + ]; + const result = convertDepositAmountForAvailableCoins( + { + list: coinList.map(([v, t]) => defaultFeeConfig(v, t)), + exchanges: {}, + }, + kudos`2.98`, + TransactionAmountMode.Raw, + ); + t.is(Amounts.stringifyValue(result.effective), "3.2"); + t.is(Amounts.stringifyValue(result.raw), "3.09"); + expectDefined(t, result.refresh); + //FEES + //deposit 1 x 0.01 + //refresh 1 x 0.01 + //withdraw 8 x 0.01 + //----------------- + //op 0.10 + + //coins sent 1 x 2.0 + //coins recv 8 x 0.1 + //------------------- + //effective 3.20 + //raw 3.09 + t.is(Amounts.stringifyValue(result.refresh.selected.id), "2"); + t.deepEqual(asCoinList(result.refresh.coins), [[kudos`0.1`, 8]]); +}); + +test("deposit with refresh: effective 3.2 (raw 2.98)", (t) => { + const coinList: Coin[] = [ + [kudos`0.1`, 0], + [kudos`1`, 0], + [kudos`2`, 5], + [kudos`5`, 5], + ]; + const result = convertDepositAmountForAvailableCoins( + { + list: coinList.map(([v, t]) => defaultFeeConfig(v, t)), + exchanges: {}, + }, + kudos`3.2`, + TransactionAmountMode.Effective, + ); + t.is(Amounts.stringifyValue(result.effective), "3.3"); + t.is(Amounts.stringifyValue(result.raw), "3.2"); + expectDefined(t, result.refresh); + //FEES + //deposit 2 x 0.01 + //refresh 1 x 0.01 + //withdraw 7 x 0.01 + //----------------- + //op 0.10 + + //coins sent 2 x 2.0 + //coins recv 7 x 0.1 + //------------------- + //effective 3.30 + //raw 3.20 + t.is(Amounts.stringifyValue(result.refresh.selected.id), "2"); + t.deepEqual(asCoinList(result.refresh.coins), [[kudos`0.1`, 7]]); +}); + +function expectDefined<T>( + t: ExecutionContext, + v: T | undefined, +): asserts v is T { + t.assert(v !== undefined); +} + +function asCoinList(v: { info: CoinInfo; size: number }[]): any { + return v.map((c) => { + return [c.info.value, c.size]; + }); +} diff --git a/packages/taler-wallet-core/src/util/coinSelection.ts b/packages/taler-wallet-core/src/util/coinSelection.ts index c5a810c4f..26dc0dedc 100644 --- a/packages/taler-wallet-core/src/util/coinSelection.ts +++ b/packages/taler-wallet-core/src/util/coinSelection.ts @@ -29,15 +29,18 @@ import { AgeCommitmentProof, AgeRestriction, AmountJson, + AmountResponse, Amounts, AmountString, CoinStatus, + ConvertAmountRequest, DenominationInfo, DenominationPubKey, DenomSelectionState, Duration, ForcedCoinSel, ForcedDenomSel, + GetAmountRequest, GetPlanForOperationRequest, GetPlanForOperationResponse, j2s, @@ -816,106 +819,6 @@ function getCoinsFilter(req: GetPlanForOperationRequest): CoinsFilter { } } -export function calculatePlanFormAvailableCoins( - transactionType: TransactionType, - amount: AmountJson, - mode: TransactionAmountMode, - availableCoins: AvailableCoins, -) { - const operationType = getOperationType(transactionType); - let usableCoins; - switch (transactionType) { - case TransactionType.Withdrawal: { - usableCoins = selectCoinForOperation( - operationType, - amount, - mode === TransactionAmountMode.Effective - ? AmountMode.Net - : AmountMode.Gross, - availableCoins, - ); - break; - } - case TransactionType.Deposit: { - //FIXME: just doing for 1 exchange now - //assuming that the wallet has one exchange and all the coins available - //are from that exchange - const wireFee = Object.values(availableCoins.exchanges)[0].wireFee!; - - if (mode === TransactionAmountMode.Effective) { - usableCoins = selectCoinForOperation( - operationType, - amount, - AmountMode.Gross, - availableCoins, - ); - - usableCoins.totalContribution = Amounts.sub( - usableCoins.totalContribution, - wireFee, - ).amount; - } else { - const adjustedAmount = Amounts.add(amount, wireFee).amount; - - usableCoins = selectCoinForOperation( - operationType, - adjustedAmount, - AmountMode.Net, - availableCoins, - ); - - usableCoins.totalContribution = Amounts.sub( - usableCoins.totalContribution, - wireFee, - ).amount; - } - break; - } - default: { - throw Error("operation not supported"); - } - } - - return getAmountsWithFee( - operationType, - usableCoins!.totalValue, - usableCoins!.totalContribution, - usableCoins, - ); -} - -/** - * simulate a coin selection and return the amount - * that will effectively change the wallet balance and - * the raw amount of the operation - * - * @param ws - * @param br - * @returns - */ -export async function getPlanForOperation( - ws: InternalWalletState, - req: GetPlanForOperationRequest, -): Promise<GetPlanForOperationResponse> { - const amount = Amounts.parseOrThrow(req.instructedAmount); - const operationType = getOperationType(req.type); - const filter = getCoinsFilter(req); - - const availableCoins = await getAvailableCoins( - ws, - operationType, - amount.currency, - filter, - ); - - return calculatePlanFormAvailableCoins( - req.type, - amount, - req.mode, - availableCoins, - ); -} - /** * If the operation going to be plan subtracts * or adds amount in the wallet db @@ -925,225 +828,6 @@ export enum OperationType { Debit = "debit", } -/** - * How the amount should be interpreted - * net = without fee - * gross = with fee - * - * Net value is always lower than gross - */ -export enum AmountMode { - Net = "net", - Gross = "gross", -} - -/** - * - * @param op defined which fee are we taking into consideration: deposits or withdraw - * @param limit the total amount limit of the operation - * @param mode if the total amount is includes the fees or just the contribution - * @param denoms list of available denomination for the operation - * @returns - */ -export function selectCoinForOperation( - op: OperationType, - limit: AmountJson, - mode: AmountMode, - coins: AvailableCoins, -): SelectedCoins { - const result: SelectedCoins = { - totalValue: Amounts.zeroOfCurrency(limit.currency), - totalWithdrawalFee: Amounts.zeroOfCurrency(limit.currency), - totalDepositFee: Amounts.zeroOfCurrency(limit.currency), - totalContribution: Amounts.zeroOfCurrency(limit.currency), - coins: [], - }; - if (!coins.list.length) return result; - /** - * We can make this faster. We should prevent sorting and - * keep the information ready for multiple calls since this - * function is expected to work on embedded devices and - * create a response on key press - */ - - //rank coins - coins.list.sort(buildRankingForCoins(op)); - - //take coins in order until amount - let selectedCoinsAreEnough = false; - let denomIdx = 0; - iterateDenoms: while (denomIdx < coins.list.length) { - const denom = coins.list[denomIdx]; - let total = - op === OperationType.Credit - ? Number.MAX_SAFE_INTEGER - : denom.totalAvailable ?? 0; - const opFee = - op === OperationType.Credit ? denom.denomWithdraw : denom.denomDeposit; - const contribution = Amounts.sub(denom.value, opFee).amount; - - if (Amounts.isZero(contribution)) { - // 0 contribution denoms should be the last - break iterateDenoms; - } - - //use Amounts.divmod instead of iterate - iterateCoins: while (total > 0) { - const nextValue = Amounts.add(result.totalValue, denom.value).amount; - - const nextContribution = Amounts.add( - result.totalContribution, - contribution, - ).amount; - - const progress = mode === AmountMode.Gross ? nextValue : nextContribution; - - if (Amounts.cmp(progress, limit) === 1) { - //the current coin is more than we need, try next denom - break iterateCoins; - } - - result.totalValue = nextValue; - result.totalContribution = nextContribution; - - result.totalDepositFee = Amounts.add( - result.totalDepositFee, - denom.denomDeposit, - ).amount; - - result.totalWithdrawalFee = Amounts.add( - result.totalWithdrawalFee, - denom.denomWithdraw, - ).amount; - - result.coins.push(denom.id); - - if (Amounts.cmp(progress, limit) === 0) { - selectedCoinsAreEnough = true; - // we have just enough coins, complete - break iterateDenoms; - } - - //go next coin - total--; - } - //go next denom - denomIdx++; - } - - if (selectedCoinsAreEnough) { - // we made it - return result; - } - if (op === OperationType.Credit) { - //doing withdraw there is no way to cover the gap - return result; - } - //tried all the coins but there is a gap - //doing deposit we can try refreshing coins - - const total = - mode === AmountMode.Gross ? result.totalValue : result.totalContribution; - const gap = Amounts.sub(limit, total).amount; - - //about recursive calls - //the only way to get here is by doing a deposit (that will do a refresh) - //and now we are calculating fee for credit (which does not need to calculate refresh) - - let refreshIdx = 0; - let choice: RefreshChoice | undefined = undefined; - refreshIteration: while (refreshIdx < coins.list.length) { - const d = coins.list[refreshIdx]; - const denomContribution = - mode === AmountMode.Gross - ? Amounts.sub(d.value, d.denomRefresh).amount - : Amounts.sub(d.value, d.denomDeposit, d.denomRefresh).amount; - - const changeAfterDeposit = Amounts.sub(denomContribution, gap).amount; - if (Amounts.isZero(changeAfterDeposit)) { - //the rest of the coins are very small - break refreshIteration; - } - - const changeCost = selectCoinForOperation( - OperationType.Credit, - changeAfterDeposit, - mode, - coins, - ); - const totalFee = Amounts.add( - d.denomDeposit, - d.denomRefresh, - changeCost.totalWithdrawalFee, - ).amount; - - if (!choice || Amounts.cmp(totalFee, choice.totalFee) === -1) { - //found cheaper change - choice = { - gap: gap, - totalFee: totalFee, - selected: d.id, - totalValue: d.value, - totalRefreshFee: d.denomRefresh, - totalDepositFee: d.denomDeposit, - totalChangeValue: changeCost.totalValue, - totalChangeContribution: changeCost.totalContribution, - totalChangeWithdrawalFee: changeCost.totalWithdrawalFee, - change: changeCost.coins, - }; - } - refreshIdx++; - } - if (choice) { - if (mode === AmountMode.Gross) { - result.totalValue = Amounts.add(result.totalValue, gap).amount; - result.totalContribution = Amounts.add( - result.totalContribution, - gap, - ).amount; - result.totalContribution = Amounts.sub( - result.totalContribution, - choice.totalFee, - ).amount; - } else { - result.totalContribution = Amounts.add( - result.totalContribution, - gap, - ).amount; - result.totalValue = Amounts.add( - result.totalValue, - gap, - choice.totalFee, - ).amount; - } - } - - // console.log("gap", Amounts.stringify(limit), Amounts.stringify(gap), choice); - result.refresh = choice; - return result; -} - -type CompareCoinsFunction = (d1: CoinInfo, d2: CoinInfo) => -1 | 0 | 1; -function buildRankingForCoins(op: OperationType): CompareCoinsFunction { - function getFee(d: CoinInfo) { - return op === OperationType.Credit ? d.denomWithdraw : d.denomDeposit; - } - //different exchanges may have different wireFee - //ranking should take the relative contribution in the exchange - //which is (value - denomFee / fixedFee) - // where denomFee is withdraw or deposit - // and fixedFee can be purse or wire - return function rank(d1: CoinInfo, d2: CoinInfo) { - const contrib1 = Amounts.sub(d1.value, getFee(d1)).amount; - const contrib2 = Amounts.sub(d2.value, getFee(d2)).amount; - return ( - Amounts.cmp(contrib2, contrib1) || - Duration.cmp(d1.duration, d2.duration) || - strcmp(d1.id, d2.id) - ); - }; -} - function getOperationType(txType: TransactionType): OperationType { const operationType = txType === TransactionType.Withdrawal @@ -1157,51 +841,35 @@ function getOperationType(txType: TransactionType): OperationType { return operationType; } -function getAmountsWithFee( - op: OperationType, - value: AmountJson, - contribution: AmountJson, - details: any, -): GetPlanForOperationResponse { - return { - rawAmount: Amounts.stringify( - op === OperationType.Credit ? value : contribution, - ), - effectiveAmount: Amounts.stringify( - op === OperationType.Credit ? contribution : value, - ), - details, - }; -} - interface RefreshChoice { + /** + * Amount that need to be covered + */ gap: AmountJson; totalFee: AmountJson; - selected: string; - - totalValue: AmountJson; - totalDepositFee: AmountJson; - totalRefreshFee: AmountJson; + selected: CoinInfo; totalChangeValue: AmountJson; - totalChangeContribution: AmountJson; - totalChangeWithdrawalFee: AmountJson; - change: string[]; + refreshEffective: AmountJson; + coins: { info: CoinInfo; size: number }[]; + + // totalValue: AmountJson; + // totalDepositFee: AmountJson; + // totalRefreshFee: AmountJson; + // totalChangeContribution: AmountJson; + // totalChangeWithdrawalFee: AmountJson; } +interface AvailableCoins { + list: CoinInfo[]; + exchanges: Record<string, ExchangeInfo>; +} interface SelectedCoins { totalValue: AmountJson; - totalContribution: AmountJson; - totalWithdrawalFee: AmountJson; - totalDepositFee: AmountJson; - coins: string[]; + coins: { info: CoinInfo; size: number }[]; refresh?: RefreshChoice; } -interface AvailableCoins { - list: CoinInfo[]; - exchanges: Record<string, ExchangeInfo>; -} -interface CoinInfo { +export interface CoinInfo { id: string; value: AmountJson; denomDeposit: AmountJson; @@ -1211,6 +879,7 @@ interface CoinInfo { exchangeWire: AmountJson | undefined; exchangePurse: AmountJson | undefined; duration: Duration; + exchangeBaseUrl: string; maxAge: number; } interface ExchangeInfo { @@ -1232,12 +901,14 @@ interface CoinsFilter { * This function is costly (by the database access) but with high chances * of being cached */ -async function getAvailableCoins( +async function getAvailableDenoms( ws: InternalWalletState, - op: OperationType, + op: TransactionType, currency: string, filters: CoinsFilter = {}, ): Promise<AvailableCoins> { + const operationType = getOperationType(TransactionType.Deposit); + return await ws.db .mktx((x) => [ x.exchanges, @@ -1318,7 +989,7 @@ async function getAvailableCoins( let creditDeadline = AbsoluteTime.never(); let debitDeadline = AbsoluteTime.never(); //4.- filter coins restricted by age - if (op === OperationType.Credit) { + if (operationType === OperationType.Credit) { const ds = await tx.denominations.indexes.byExchangeBaseUrl.getAll( exchangeBaseUrl, ); @@ -1415,6 +1086,7 @@ function buildCoinInfoFromDenom( denomRefresh: Amounts.parseOrThrow(denom.fees.feeRefresh), exchangePurse: purseFee, exchangeWire: wireFee, + exchangeBaseUrl: denom.exchangeBaseUrl, duration: AbsoluteTime.difference( AbsoluteTime.now(), AbsoluteTime.fromProtocolTimestamp(denom.stampExpireDeposit), @@ -1424,3 +1096,525 @@ function buildCoinInfoFromDenom( maxAge, }; } + +export async function convertDepositAmount( + ws: InternalWalletState, + req: ConvertAmountRequest, +): Promise<AmountResponse> { + const amount = Amounts.parseOrThrow(req.amount); + // const filter = getCoinsFilter(req); + + const denoms = await getAvailableDenoms( + ws, + TransactionType.Deposit, + amount.currency, + {}, + ); + const result = convertDepositAmountForAvailableCoins( + denoms, + amount, + req.type, + ); + + return { + effectiveAmount: Amounts.stringify(result.effective), + rawAmount: Amounts.stringify(result.raw), + }; +} + +const LOG_REFRESH = false; +const LOG_DEPOSIT = false; +export function convertDepositAmountForAvailableCoins( + denoms: AvailableCoins, + amount: AmountJson, + mode: TransactionAmountMode, +): AmountAndRefresh { + const zero = Amounts.zeroOfCurrency(amount.currency); + if (!denoms.list.length) { + // no coins in the database + return { effective: zero, raw: zero }; + } + const depositDenoms = rankDenominationForDeposit(denoms.list, mode); + + //FIXME: we are not taking into account + // * exchanges with multiple accounts + // * wallet with multiple exchanges + const wireFee = Object.values(denoms.exchanges)[0]?.wireFee ?? zero; + const adjustedAmount = Amounts.add(amount, wireFee).amount; + + const selected = selectGreedyCoins(depositDenoms, adjustedAmount); + + const gap = Amounts.sub(amount, selected.totalValue).amount; + + const result = getTotalEffectiveAndRawForDeposit( + selected.coins, + amount.currency, + ); + result.raw = Amounts.sub(result.raw, wireFee).amount; + + if (Amounts.isZero(gap)) { + // exact amount founds + return result; + } + + if (LOG_DEPOSIT) { + const logInfo = selected.coins.map((c) => { + return `${Amounts.stringifyValue(c.info.id)} x ${c.size}`; + }); + console.log( + "deposit used:", + logInfo.join(", "), + "gap:", + Amounts.stringifyValue(gap), + ); + } + + const refreshDenoms = rankDenominationForRefresh(denoms.list); + /** + * FIXME: looking for refresh AFTER selecting greedy is not optimal + */ + const refreshCoin = searchBestRefreshCoin( + depositDenoms, + refreshDenoms, + gap, + mode, + ); + + if (refreshCoin) { + const fee = Amounts.sub(result.effective, result.raw).amount; + const effective = Amounts.add( + result.effective, + refreshCoin.refreshEffective, + ).amount; + const raw = Amounts.sub(effective, fee, refreshCoin.totalFee).amount; + //found with change + return { + effective, + raw, + refresh: refreshCoin, + }; + } + + // there is a gap, but no refresh coin was found + return result; +} + +export async function getMaxDepositAmount( + ws: InternalWalletState, + req: GetAmountRequest, +): Promise<AmountResponse> { + // const filter = getCoinsFilter(req); + + const denoms = await getAvailableDenoms( + ws, + TransactionType.Deposit, + req.currency, + {}, + ); + + const result = getMaxDepositAmountForAvailableCoins(denoms, req.currency); + return { + effectiveAmount: Amounts.stringify(result.effective), + rawAmount: Amounts.stringify(result.raw), + }; +} + +export function getMaxDepositAmountForAvailableCoins( + denoms: AvailableCoins, + currency: string, +) { + const zero = Amounts.zeroOfCurrency(currency); + if (!denoms.list.length) { + // no coins in the database + return { effective: zero, raw: zero }; + } + + const result = getTotalEffectiveAndRawForDeposit( + denoms.list.map((info) => { + return { info, size: info.totalAvailable ?? 0 }; + }), + currency, + ); + + const wireFee = Object.values(denoms.exchanges)[0]?.wireFee ?? zero; + result.raw = Amounts.sub(result.raw, wireFee).amount; + + return result; +} + +export async function convertPeerPushAmount( + ws: InternalWalletState, + req: ConvertAmountRequest, +): Promise<AmountResponse> { + throw Error("to be implemented after 1.0"); +} +export async function getMaxPeerPushAmount( + ws: InternalWalletState, + req: GetAmountRequest, +): Promise<AmountResponse> { + throw Error("to be implemented after 1.0"); +} +export async function convertWithdrawalAmount( + ws: InternalWalletState, + req: ConvertAmountRequest, +): Promise<AmountResponse> { + const amount = Amounts.parseOrThrow(req.amount); + + const denoms = await getAvailableDenoms( + ws, + TransactionType.Withdrawal, + amount.currency, + {}, + ); + + const result = convertWithdrawalAmountFromAvailableCoins( + denoms, + amount, + req.type, + ); + + return { + effectiveAmount: Amounts.stringify(result.effective), + rawAmount: Amounts.stringify(result.raw), + }; +} + +export function convertWithdrawalAmountFromAvailableCoins( + denoms: AvailableCoins, + amount: AmountJson, + mode: TransactionAmountMode, +) { + const zero = Amounts.zeroOfCurrency(amount.currency); + if (!denoms.list.length) { + // no coins in the database + return { effective: zero, raw: zero }; + } + const withdrawDenoms = rankDenominationForWithdrawals(denoms.list, mode); + + const selected = selectGreedyCoins(withdrawDenoms, amount); + + return getTotalEffectiveAndRawForWithdrawal(selected.coins, amount.currency); +} + +/** ***************************************************** + * HELPERS + * ***************************************************** + */ + +/** + * + * @param depositDenoms + * @param refreshDenoms + * @param amount + * @param mode + * @returns + */ +function searchBestRefreshCoin( + depositDenoms: SelectableElement[], + refreshDenoms: Record<string, SelectableElement[]>, + amount: AmountJson, + mode: TransactionAmountMode, +): RefreshChoice | undefined { + let choice: RefreshChoice | undefined = undefined; + let refreshIdx = 0; + refreshIteration: while (refreshIdx < depositDenoms.length) { + const d = depositDenoms[refreshIdx]; + + const denomContribution = + mode === TransactionAmountMode.Effective + ? d.value + : Amounts.sub(d.value, d.info.denomRefresh, d.info.denomDeposit).amount; + + const changeAfterDeposit = Amounts.sub(denomContribution, amount).amount; + if (Amounts.isZero(changeAfterDeposit)) { + //this coin is not big enough to use for refresh + //since the list is sorted, we can break here + break refreshIteration; + } + + const withdrawDenoms = refreshDenoms[d.info.exchangeBaseUrl]; + const change = selectGreedyCoins(withdrawDenoms, changeAfterDeposit); + + const zero = Amounts.zeroOfCurrency(amount.currency); + const withdrawChangeFee = change.coins.reduce((cur, prev) => { + return Amounts.add( + cur, + Amounts.mult(prev.info.denomWithdraw, prev.size).amount, + ).amount; + }, zero); + + const withdrawChangeValue = change.coins.reduce((cur, prev) => { + return Amounts.add(cur, Amounts.mult(prev.info.value, prev.size).amount) + .amount; + }, zero); + + const totalFee = Amounts.add( + d.info.denomDeposit, + d.info.denomRefresh, + withdrawChangeFee, + ).amount; + + if (!choice || Amounts.cmp(totalFee, choice.totalFee) === -1) { + //found cheaper change + choice = { + gap: amount, + totalFee: totalFee, + totalChangeValue: change.totalValue, //change after refresh + refreshEffective: Amounts.sub(d.info.value, withdrawChangeValue).amount, // what of the denom used is not recovered + selected: d.info, + coins: change.coins, + }; + } + refreshIdx++; + } + if (choice) { + if (LOG_REFRESH) { + const logInfo = choice.coins.map((c) => { + return `${Amounts.stringifyValue(c.info.id)} x ${c.size}`; + }); + console.log( + "refresh used:", + Amounts.stringifyValue(choice.selected.value), + "change:", + logInfo.join(", "), + "fee:", + Amounts.stringifyValue(choice.totalFee), + "refreshEffective:", + Amounts.stringifyValue(choice.refreshEffective), + "totalChangeValue:", + Amounts.stringifyValue(choice.totalChangeValue), + ); + } + } + return choice; +} + +/** + * Returns a copy of the list sorted for the best denom to withdraw first + * + * @param denoms + * @returns + */ +function rankDenominationForWithdrawals( + denoms: CoinInfo[], + mode: TransactionAmountMode, +): SelectableElement[] { + const copyList = [...denoms]; + /** + * Rank coins + */ + copyList.sort((d1, d2) => { + // the best coin to use is + // 1.- the one that contrib more and pay less fee + // 2.- it takes more time before expires + + //different exchanges may have different wireFee + //ranking should take the relative contribution in the exchange + //which is (value - denomFee / fixedFee) + const rate1 = Amounts.divmod(d1.value, d1.denomWithdraw).quotient; + const rate2 = Amounts.divmod(d2.value, d2.denomWithdraw).quotient; + const contribCmp = rate1 === rate2 ? 0 : rate1 < rate2 ? 1 : -1; + return ( + contribCmp || + Duration.cmp(d1.duration, d2.duration) || + strcmp(d1.id, d2.id) + ); + }); + + return copyList.map((info) => { + switch (mode) { + case TransactionAmountMode.Effective: { + //if the user instructed "effective" then we need to selected + //greedy total coin value + return { + info, + value: info.value, + total: Number.MAX_SAFE_INTEGER, + }; + } + case TransactionAmountMode.Raw: { + //if the user instructed "raw" then we need to selected + //greedy total coin raw amount (without fee) + return { + info, + value: Amounts.add(info.value, info.denomWithdraw).amount, + total: Number.MAX_SAFE_INTEGER, + }; + } + } + }); +} + +/** + * Returns a copy of the list sorted for the best denom to deposit first + * + * @param denoms + * @returns + */ +function rankDenominationForDeposit( + denoms: CoinInfo[], + mode: TransactionAmountMode, +): SelectableElement[] { + const copyList = [...denoms]; + /** + * Rank coins + */ + copyList.sort((d1, d2) => { + // the best coin to use is + // 1.- the one that contrib more and pay less fee + // 2.- it takes more time before expires + + //different exchanges may have different wireFee + //ranking should take the relative contribution in the exchange + //which is (value - denomFee / fixedFee) + const rate1 = Amounts.divmod(d1.value, d1.denomDeposit).quotient; + const rate2 = Amounts.divmod(d2.value, d2.denomDeposit).quotient; + const contribCmp = rate1 === rate2 ? 0 : rate1 < rate2 ? 1 : -1; + return ( + contribCmp || + Duration.cmp(d1.duration, d2.duration) || + strcmp(d1.id, d2.id) + ); + }); + + return copyList.map((info) => { + switch (mode) { + case TransactionAmountMode.Effective: { + //if the user instructed "effective" then we need to selected + //greedy total coin value + return { + info, + value: info.value, + total: info.totalAvailable ?? 0, + }; + } + case TransactionAmountMode.Raw: { + //if the user instructed "raw" then we need to selected + //greedy total coin raw amount (without fee) + return { + info, + value: Amounts.sub(info.value, info.denomDeposit).amount, + total: info.totalAvailable ?? 0, + }; + } + } + }); +} + +/** + * Returns a copy of the list sorted for the best denom to withdraw first + * + * @param denoms + * @returns + */ +function rankDenominationForRefresh( + denoms: CoinInfo[], +): Record<string, SelectableElement[]> { + const groupByExchange: Record<string, CoinInfo[]> = {}; + for (const d of denoms) { + if (!groupByExchange[d.exchangeBaseUrl]) { + groupByExchange[d.exchangeBaseUrl] = []; + } + groupByExchange[d.exchangeBaseUrl].push(d); + } + + const result: Record<string, SelectableElement[]> = {}; + for (const d of denoms) { + result[d.exchangeBaseUrl] = rankDenominationForWithdrawals( + groupByExchange[d.exchangeBaseUrl], + TransactionAmountMode.Raw, + ); + } + return result; +} + +interface SelectableElement { + total: number; + value: AmountJson; + info: CoinInfo; +} + +function selectGreedyCoins( + coins: SelectableElement[], + limit: AmountJson, +): SelectedCoins { + const result: SelectedCoins = { + totalValue: Amounts.zeroOfCurrency(limit.currency), + coins: [], + }; + if (!coins.length) return result; + + let denomIdx = 0; + iterateDenoms: while (denomIdx < coins.length) { + const denom = coins[denomIdx]; + // let total = denom.total; + const left = Amounts.sub(limit, result.totalValue).amount; + + if (Amounts.isZero(denom.value)) { + // 0 contribution denoms should be the last + break iterateDenoms; + } + + //use Amounts.divmod instead of iterate + const div = Amounts.divmod(left, denom.value); + const size = Math.min(div.quotient, denom.total); + if (size > 0) { + const mul = Amounts.mult(denom.value, size).amount; + const progress = Amounts.add(result.totalValue, mul).amount; + + result.totalValue = progress; + result.coins.push({ info: denom.info, size }); + denom.total = denom.total - size; + } + + //go next denom + denomIdx++; + } + + return result; +} + +type AmountWithFee = { raw: AmountJson; effective: AmountJson }; +type AmountAndRefresh = AmountWithFee & { refresh?: RefreshChoice }; + +export function getTotalEffectiveAndRawForDeposit( + list: { info: CoinInfo; size: number }[], + currency: string, +): AmountWithFee { + const init = { + raw: Amounts.zeroOfCurrency(currency), + effective: Amounts.zeroOfCurrency(currency), + }; + return list.reduce((prev, cur) => { + const ef = Amounts.mult(cur.info.value, cur.size).amount; + const rw = Amounts.mult( + Amounts.sub(cur.info.value, cur.info.denomDeposit).amount, + cur.size, + ).amount; + + prev.effective = Amounts.add(prev.effective, ef).amount; + prev.raw = Amounts.add(prev.raw, rw).amount; + return prev; + }, init); +} + +function getTotalEffectiveAndRawForWithdrawal( + list: { info: CoinInfo; size: number }[], + currency: string, +): AmountWithFee { + const init = { + raw: Amounts.zeroOfCurrency(currency), + effective: Amounts.zeroOfCurrency(currency), + }; + return list.reduce((prev, cur) => { + const ef = Amounts.mult(cur.info.value, cur.size).amount; + const rw = Amounts.mult( + Amounts.add(cur.info.value, cur.info.denomWithdraw).amount, + cur.size, + ).amount; + + prev.effective = Amounts.add(prev.effective, ef).amount; + prev.raw = Amounts.add(prev.raw, rw).amount; + return prev; + }, init); +} diff --git a/packages/taler-wallet-core/src/wallet-api-types.ts b/packages/taler-wallet-core/src/wallet-api-types.ts index 3b0d11039..c58ced045 100644 --- a/packages/taler-wallet-core/src/wallet-api-types.ts +++ b/packages/taler-wallet-core/src/wallet-api-types.ts @@ -34,6 +34,7 @@ import { AcceptWithdrawalResponse, AddExchangeRequest, AddKnownBankAccountsRequest, + AmountResponse, ApplyDevExperimentRequest, BackupRecovery, BalancesResponse, @@ -47,6 +48,7 @@ import { ConfirmPayResult, ConfirmPeerPullDebitRequest, ConfirmPeerPushCreditRequest, + ConvertAmountRequest, CreateDepositGroupRequest, CreateDepositGroupResponse, DeleteTransactionRequest, @@ -54,6 +56,7 @@ import { ExchangesListResponse, ForceRefreshRequest, ForgetKnownBankAccountsRequest, + GetAmountRequest, GetBalanceDetailRequest, GetContractTermsDetailsRequest, GetExchangeTosRequest, @@ -146,6 +149,11 @@ export enum WalletApiOperation { GetBalances = "getBalances", GetBalanceDetail = "getBalanceDetail", GetPlanForOperation = "getPlanForOperation", + ConvertDepositAmount = "ConvertDepositAmount", + GetMaxDepositAmount = "GetMaxDepositAmount", + ConvertPeerPushAmount = "ConvertPeerPushAmount", + GetMaxPeerPushAmount = "GetMaxPeerPushAmount", + ConvertWithdrawalAmount = "ConvertWithdrawalAmount", GetUserAttentionRequests = "getUserAttentionRequests", GetUserAttentionUnreadCount = "getUserAttentionUnreadCount", MarkAttentionRequestAsRead = "markAttentionRequestAsRead", @@ -284,6 +292,32 @@ export type GetPlanForOperationOp = { response: GetPlanForOperationResponse; }; +export type ConvertDepositAmountOp = { + op: WalletApiOperation.ConvertDepositAmount; + request: ConvertAmountRequest; + response: AmountResponse; +}; +export type GetMaxDepositAmountOp = { + op: WalletApiOperation.GetMaxDepositAmount; + request: GetAmountRequest; + response: AmountResponse; +}; +export type ConvertPeerPushAmountOp = { + op: WalletApiOperation.ConvertPeerPushAmount; + request: ConvertAmountRequest; + response: AmountResponse; +}; +export type GetMaxPeerPushAmountOp = { + op: WalletApiOperation.GetMaxPeerPushAmount; + request: GetAmountRequest; + response: AmountResponse; +}; +export type ConvertWithdrawalAmountOp = { + op: WalletApiOperation.ConvertWithdrawalAmount; + request: ConvertAmountRequest; + response: AmountResponse; +}; + // group: Managing Transactions /** @@ -949,6 +983,11 @@ export type WalletOperations = { [WalletApiOperation.SuspendTransaction]: SuspendTransactionOp; [WalletApiOperation.ResumeTransaction]: ResumeTransactionOp; [WalletApiOperation.GetBalances]: GetBalancesOp; + [WalletApiOperation.ConvertDepositAmount]: ConvertDepositAmountOp; + [WalletApiOperation.GetMaxDepositAmount]: GetMaxDepositAmountOp; + [WalletApiOperation.ConvertPeerPushAmount]: ConvertPeerPushAmountOp; + [WalletApiOperation.GetMaxPeerPushAmount]: GetMaxPeerPushAmountOp; + [WalletApiOperation.ConvertWithdrawalAmount]: ConvertWithdrawalAmountOp; [WalletApiOperation.GetPlanForOperation]: GetPlanForOperationOp; [WalletApiOperation.GetBalanceDetail]: GetBalancesDetailOp; [WalletApiOperation.GetTransactions]: GetTransactionsOp; diff --git a/packages/taler-wallet-core/src/wallet.ts b/packages/taler-wallet-core/src/wallet.ts index e5cd713b8..af6bb4d62 100644 --- a/packages/taler-wallet-core/src/wallet.ts +++ b/packages/taler-wallet-core/src/wallet.ts @@ -69,10 +69,12 @@ import { codecForCheckPeerPushDebitRequest, codecForConfirmPayRequest, codecForConfirmPeerPushPaymentRequest, + codecForConvertAmountRequest, codecForCreateDepositGroupRequest, codecForDeleteTransactionRequest, codecForForceRefreshRequest, codecForForgetKnownBankAccounts, + codecForGetAmountRequest, codecForGetBalanceDetailRequest, codecForGetContractTermsDetails, codecForGetExchangeTosRequest, @@ -293,7 +295,13 @@ import { WalletCoreApiClient, WalletCoreResponseType, } from "./wallet-api-types.js"; -import { getPlanForOperation } from "./util/coinSelection.js"; +import { + convertDepositAmount, + convertPeerPushAmount, + convertWithdrawalAmount, + getMaxDepositAmount, + getMaxPeerPushAmount, +} from "./util/coinSelection.js"; const logger = new Logger("wallet.ts"); @@ -1345,9 +1353,29 @@ async function dispatchRequestInternal<Op extends WalletApiOperation>( await loadBackupRecovery(ws, req); return {}; } - case WalletApiOperation.GetPlanForOperation: { - const req = codecForGetPlanForOperationRequest().decode(payload); - return await getPlanForOperation(ws, req); + // case WalletApiOperation.GetPlanForOperation: { + // const req = codecForGetPlanForOperationRequest().decode(payload); + // return await getPlanForOperation(ws, req); + // } + case WalletApiOperation.ConvertDepositAmount: { + const req = codecForConvertAmountRequest.decode(payload); + return await convertDepositAmount(ws, req); + } + case WalletApiOperation.GetMaxDepositAmount: { + const req = codecForGetAmountRequest.decode(payload); + return await getMaxDepositAmount(ws, req); + } + case WalletApiOperation.ConvertPeerPushAmount: { + const req = codecForConvertAmountRequest.decode(payload); + return await convertPeerPushAmount(ws, req); + } + case WalletApiOperation.GetMaxPeerPushAmount: { + const req = codecForGetAmountRequest.decode(payload); + return await getMaxPeerPushAmount(ws, req); + } + case WalletApiOperation.ConvertWithdrawalAmount: { + const req = codecForConvertAmountRequest.decode(payload); + return await convertWithdrawalAmount(ws, req); } case WalletApiOperation.GetBackupInfo: { const resp = await getBackupInfo(ws); |