diff options
Diffstat (limited to 'packages/taler-wallet-core/src/util')
| -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 | 
2 files changed, 1021 insertions, 499 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); +}  | 
