diff options
Diffstat (limited to 'packages/taler-wallet-core/src/util/coinSelection.ts')
-rw-r--r-- | packages/taler-wallet-core/src/util/coinSelection.ts | 906 |
1 files changed, 550 insertions, 356 deletions
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); +} |