summaryrefslogtreecommitdiff
path: root/packages/taler-wallet-core/src/instructedAmountConversion.ts
diff options
context:
space:
mode:
Diffstat (limited to 'packages/taler-wallet-core/src/instructedAmountConversion.ts')
-rw-r--r--packages/taler-wallet-core/src/instructedAmountConversion.ts872
1 files changed, 872 insertions, 0 deletions
diff --git a/packages/taler-wallet-core/src/instructedAmountConversion.ts b/packages/taler-wallet-core/src/instructedAmountConversion.ts
new file mode 100644
index 000000000..1f7d95959
--- /dev/null
+++ b/packages/taler-wallet-core/src/instructedAmountConversion.ts
@@ -0,0 +1,872 @@
+/*
+ This file is part of GNU Taler
+ (C) 2023 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+import { GlobalIDB } from "@gnu-taler/idb-bridge";
+import {
+ AbsoluteTime,
+ AgeRestriction,
+ AmountJson,
+ AmountResponse,
+ Amounts,
+ ConvertAmountRequest,
+ Duration,
+ GetAmountRequest,
+ GetPlanForOperationRequest,
+ TransactionAmountMode,
+ TransactionType,
+ checkDbInvariant,
+ parsePaytoUri,
+ strcmp,
+} from "@gnu-taler/taler-util";
+import { DenominationRecord, timestampProtocolFromDb } from "./db.js";
+import { getExchangeWireDetailsInTx } from "./exchanges.js";
+import { WalletExecutionContext } from "./wallet.js";
+
+export interface CoinInfo {
+ id: string;
+ value: AmountJson;
+ denomDeposit: AmountJson;
+ denomWithdraw: AmountJson;
+ denomRefresh: AmountJson;
+ totalAvailable: number | undefined;
+ exchangeWire: AmountJson | undefined;
+ exchangePurse: AmountJson | undefined;
+ duration: Duration;
+ exchangeBaseUrl: string;
+ maxAge: number;
+}
+
+/**
+ * If the operation going to be plan subtracts
+ * or adds amount in the wallet db
+ */
+export enum OperationType {
+ Credit = "credit",
+ Debit = "debit",
+}
+
+// FIXME: Name conflict ...
+interface ExchangeInfo {
+ wireFee: AmountJson | undefined;
+ purseFee: AmountJson | undefined;
+ creditDeadline: AbsoluteTime;
+ debitDeadline: AbsoluteTime;
+}
+
+function getOperationType(txType: TransactionType): OperationType {
+ const operationType =
+ txType === TransactionType.Withdrawal
+ ? OperationType.Credit
+ : txType === TransactionType.Deposit
+ ? OperationType.Debit
+ : undefined;
+ if (!operationType) {
+ throw Error(`operation type ${txType} not yet supported`);
+ }
+ return operationType;
+}
+
+interface SelectedCoins {
+ totalValue: AmountJson;
+ coins: { info: CoinInfo; size: number }[];
+ refresh?: RefreshChoice;
+}
+
+function getCoinsFilter(req: GetPlanForOperationRequest): CoinsFilter {
+ switch (req.type) {
+ case TransactionType.Withdrawal: {
+ return {
+ exchanges:
+ req.exchangeUrl === undefined ? undefined : [req.exchangeUrl],
+ };
+ }
+ case TransactionType.Deposit: {
+ const payto = parsePaytoUri(req.account);
+ if (!payto) {
+ throw Error(`wrong payto ${req.account}`);
+ }
+ return {
+ wireMethod: payto.targetType,
+ };
+ }
+ }
+}
+
+interface RefreshChoice {
+ /**
+ * Amount that need to be covered
+ */
+ gap: AmountJson;
+ totalFee: AmountJson;
+ selected: CoinInfo;
+ totalChangeValue: AmountJson;
+ refreshEffective: AmountJson;
+ coins: { info: CoinInfo; size: number }[];
+
+ // totalValue: AmountJson;
+ // totalDepositFee: AmountJson;
+ // totalRefreshFee: AmountJson;
+ // totalChangeContribution: AmountJson;
+ // totalChangeWithdrawalFee: AmountJson;
+}
+
+interface CoinsFilter {
+ shouldCalculatePurseFee?: boolean;
+ exchanges?: string[];
+ wireMethod?: string;
+ ageRestricted?: number;
+}
+
+interface AvailableCoins {
+ list: CoinInfo[];
+ exchanges: Record<string, ExchangeInfo>;
+}
+
+/**
+ * Get all the denoms that can be used for a operation that is limited
+ * by the following restrictions.
+ * This function is costly (by the database access) but with high chances
+ * of being cached
+ */
+async function getAvailableDenoms(
+ wex: WalletExecutionContext,
+ op: TransactionType,
+ currency: string,
+ filters: CoinsFilter = {},
+): Promise<AvailableCoins> {
+ const operationType = getOperationType(TransactionType.Deposit);
+
+ return await wex.db.runReadOnlyTx(
+ {
+ storeNames: [
+ "exchanges",
+ "exchangeDetails",
+ "denominations",
+ "coinAvailability",
+ ],
+ },
+ async (tx) => {
+ const list: CoinInfo[] = [];
+ const exchanges: Record<string, ExchangeInfo> = {};
+
+ const databaseExchanges = await tx.exchanges.iter().toArray();
+ const filteredExchanges =
+ filters.exchanges ?? databaseExchanges.map((e) => e.baseUrl);
+
+ for (const exchangeBaseUrl of filteredExchanges) {
+ const exchangeDetails = await getExchangeWireDetailsInTx(
+ tx,
+ exchangeBaseUrl,
+ );
+ // 1.- exchange has same currency
+ if (exchangeDetails?.currency !== currency) {
+ continue;
+ }
+
+ let deadline = AbsoluteTime.never();
+ // 2.- exchange supports wire method
+ let wireFee: AmountJson | undefined;
+ if (filters.wireMethod) {
+ const wireMethodWithDates =
+ exchangeDetails.wireInfo.feesForType[filters.wireMethod];
+
+ if (!wireMethodWithDates) {
+ throw Error(
+ `exchange ${exchangeBaseUrl} doesn't have wire method ${filters.wireMethod}`,
+ );
+ }
+ const wireMethodFee = wireMethodWithDates.find((x) => {
+ return AbsoluteTime.isBetween(
+ AbsoluteTime.now(),
+ AbsoluteTime.fromProtocolTimestamp(x.startStamp),
+ AbsoluteTime.fromProtocolTimestamp(x.endStamp),
+ );
+ });
+
+ if (!wireMethodFee) {
+ throw Error(
+ `exchange ${exchangeBaseUrl} doesn't have wire fee defined for this period`,
+ );
+ }
+ wireFee = Amounts.parseOrThrow(wireMethodFee.wireFee);
+ deadline = AbsoluteTime.min(
+ deadline,
+ AbsoluteTime.fromProtocolTimestamp(wireMethodFee.endStamp),
+ );
+ }
+ // exchanges[exchangeBaseUrl].wireFee = wireMethodFee;
+
+ // 3.- exchange supports wire method
+ let purseFee: AmountJson | undefined;
+ if (filters.shouldCalculatePurseFee) {
+ const purseFeeFound = exchangeDetails.globalFees.find((x) => {
+ return AbsoluteTime.isBetween(
+ AbsoluteTime.now(),
+ AbsoluteTime.fromProtocolTimestamp(x.startDate),
+ AbsoluteTime.fromProtocolTimestamp(x.endDate),
+ );
+ });
+ if (!purseFeeFound) {
+ throw Error(
+ `exchange ${exchangeBaseUrl} doesn't have purse fee defined for this period`,
+ );
+ }
+ purseFee = Amounts.parseOrThrow(purseFeeFound.purseFee);
+ deadline = AbsoluteTime.min(
+ deadline,
+ AbsoluteTime.fromProtocolTimestamp(purseFeeFound.endDate),
+ );
+ }
+
+ let creditDeadline = AbsoluteTime.never();
+ let debitDeadline = AbsoluteTime.never();
+ //4.- filter coins restricted by age
+ if (operationType === OperationType.Credit) {
+ // FIXME: Use denom groups instead of querying all denominations!
+ const ds =
+ await tx.denominations.indexes.byExchangeBaseUrl.getAll(
+ exchangeBaseUrl,
+ );
+ for (const denom of ds) {
+ const expiresWithdraw = AbsoluteTime.fromProtocolTimestamp(
+ timestampProtocolFromDb(denom.stampExpireWithdraw),
+ );
+ const expiresDeposit = AbsoluteTime.fromProtocolTimestamp(
+ timestampProtocolFromDb(denom.stampExpireDeposit),
+ );
+ creditDeadline = AbsoluteTime.min(deadline, expiresWithdraw);
+ debitDeadline = AbsoluteTime.min(deadline, expiresDeposit);
+ list.push(
+ buildCoinInfoFromDenom(
+ denom,
+ purseFee,
+ wireFee,
+ AgeRestriction.AGE_UNRESTRICTED,
+ Number.MAX_SAFE_INTEGER, // Max withdrawable from single denom
+ ),
+ );
+ }
+ } else {
+ const ageLower = filters.ageRestricted ?? 0;
+ const ageUpper = AgeRestriction.AGE_UNRESTRICTED;
+
+ const myExchangeCoins =
+ await tx.coinAvailability.indexes.byExchangeAgeAvailability.getAll(
+ GlobalIDB.KeyRange.bound(
+ [exchangeDetails.exchangeBaseUrl, ageLower, 1],
+ [
+ exchangeDetails.exchangeBaseUrl,
+ ageUpper,
+ Number.MAX_SAFE_INTEGER,
+ ],
+ ),
+ );
+ //5.- save denoms with how many coins are available
+ // FIXME: Check that the individual denomination is audited!
+ // FIXME: Should we exclude denominations that are
+ // not spendable anymore?
+ for (const coinAvail of myExchangeCoins) {
+ const denom = await tx.denominations.get([
+ coinAvail.exchangeBaseUrl,
+ coinAvail.denomPubHash,
+ ]);
+ checkDbInvariant(!!denom);
+ if (denom.isRevoked || !denom.isOffered) {
+ continue;
+ }
+ const expiresWithdraw = AbsoluteTime.fromProtocolTimestamp(
+ timestampProtocolFromDb(denom.stampExpireWithdraw),
+ );
+ const expiresDeposit = AbsoluteTime.fromProtocolTimestamp(
+ timestampProtocolFromDb(denom.stampExpireDeposit),
+ );
+ creditDeadline = AbsoluteTime.min(deadline, expiresWithdraw);
+ debitDeadline = AbsoluteTime.min(deadline, expiresDeposit);
+ list.push(
+ buildCoinInfoFromDenom(
+ denom,
+ purseFee,
+ wireFee,
+ coinAvail.maxAge,
+ coinAvail.freshCoinCount,
+ ),
+ );
+ }
+ }
+
+ exchanges[exchangeBaseUrl] = {
+ purseFee,
+ wireFee,
+ debitDeadline,
+ creditDeadline,
+ };
+ }
+
+ return { list, exchanges };
+ },
+ );
+}
+
+function buildCoinInfoFromDenom(
+ denom: DenominationRecord,
+ purseFee: AmountJson | undefined,
+ wireFee: AmountJson | undefined,
+ maxAge: number,
+ total: number,
+): CoinInfo {
+ return {
+ id: denom.denomPubHash,
+ denomWithdraw: Amounts.parseOrThrow(denom.fees.feeWithdraw),
+ denomDeposit: Amounts.parseOrThrow(denom.fees.feeDeposit),
+ denomRefresh: Amounts.parseOrThrow(denom.fees.feeRefresh),
+ exchangePurse: purseFee,
+ exchangeWire: wireFee,
+ exchangeBaseUrl: denom.exchangeBaseUrl,
+ duration: AbsoluteTime.difference(
+ AbsoluteTime.now(),
+ AbsoluteTime.fromProtocolTimestamp(
+ timestampProtocolFromDb(denom.stampExpireDeposit),
+ ),
+ ),
+ totalAvailable: total,
+ value: Amounts.parseOrThrow(denom.value),
+ maxAge,
+ };
+}
+
+export async function convertDepositAmount(
+ wex: WalletExecutionContext,
+ req: ConvertAmountRequest,
+): Promise<AmountResponse> {
+ const amount = Amounts.parseOrThrow(req.amount);
+ // const filter = getCoinsFilter(req);
+
+ const denoms = await getAvailableDenoms(
+ wex,
+ 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(
+ wex: WalletExecutionContext,
+ req: GetAmountRequest,
+): Promise<AmountResponse> {
+ // const filter = getCoinsFilter(req);
+
+ const denoms = await getAvailableDenoms(
+ wex,
+ 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(
+ wex: WalletExecutionContext,
+ req: ConvertAmountRequest,
+): Promise<AmountResponse> {
+ throw Error("to be implemented after 1.0");
+}
+
+export async function getMaxPeerPushAmount(
+ wex: WalletExecutionContext,
+ req: GetAmountRequest,
+): Promise<AmountResponse> {
+ throw Error("to be implemented after 1.0");
+}
+
+export async function convertWithdrawalAmount(
+ wex: WalletExecutionContext,
+ req: ConvertAmountRequest,
+): Promise<AmountResponse> {
+ const amount = Amounts.parseOrThrow(req.amount);
+
+ const denoms = await getAvailableDenoms(
+ wex,
+ 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);
+}