summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorSebastian <sebasjm@gmail.com>2023-06-15 13:07:31 -0300
committerSebastian <sebasjm@gmail.com>2023-06-15 13:07:31 -0300
commitd0d7685f169ecad5ba29210973a9e59834c979c7 (patch)
treef99393e6b6711343e4ee6fcbf1c0f815725b4787
parentf7058a86c9f9c6a727c58be80dbc0e2b3f7218f8 (diff)
downloadwallet-core-d0d7685f169ecad5ba29210973a9e59834c979c7.tar.gz
wallet-core-d0d7685f169ecad5ba29210973a9e59834c979c7.tar.bz2
wallet-core-d0d7685f169ecad5ba29210973a9e59834c979c7.zip
add test to coin selection algorithm
-rw-r--r--packages/taler-wallet-core/src/util/coinSelection.test.ts195
-rw-r--r--packages/taler-wallet-core/src/util/coinSelection.ts585
2 files changed, 538 insertions, 242 deletions
diff --git a/packages/taler-wallet-core/src/util/coinSelection.test.ts b/packages/taler-wallet-core/src/util/coinSelection.test.ts
index c0edc4cc1..7f4164aa9 100644
--- a/packages/taler-wallet-core/src/util/coinSelection.test.ts
+++ b/packages/taler-wallet-core/src/util/coinSelection.test.ts
@@ -14,6 +14,18 @@
GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
*/
import test, { ExecutionContext } from "ava";
+import {
+ calculatePlanFormAvailableCoins,
+ selectCoinForOperation,
+} from "./coinSelection.js";
+import {
+ AbsoluteTime,
+ AgeRestriction,
+ AmountJson,
+ Amounts,
+ Duration,
+ TransactionType,
+} from "@gnu-taler/taler-util";
function expect(t: ExecutionContext, thing: any): any {
return {
@@ -24,6 +36,185 @@ function expect(t: ExecutionContext, thing: any): any {
};
}
-test("should have a test", (t) => {
- expect(t, true).deep.equal(true);
+function kudos(v: number): AmountJson {
+ return Amounts.fromFloat(v, "KUDOS");
+}
+
+function defaultFeeConfig(value: AmountJson, totalAvailable: number) {
+ return {
+ id: Amounts.stringify(value),
+ denomDeposit: kudos(0.01),
+ denomRefresh: kudos(0.01),
+ denomWithdraw: kudos(0.01),
+ duration: Duration.getForever(),
+ exchangePurse: undefined,
+ exchangeWire: undefined,
+ maxAge: AgeRestriction.AGE_UNRESTRICTED,
+ totalAvailable,
+ value,
+ };
+}
+type Coin = [AmountJson, number];
+
+/**
+ * selectCoinForOperation test
+ *
+ * Test here should check that the correct coins are selected
+ */
+
+test("get effective 2", (t) => {
+ const coinList: Coin[] = [
+ [kudos(2), 5],
+ [kudos(5), 5],
+ ];
+ const result = selectCoinForOperation("credit", kudos(2), "net", {
+ list: coinList.map(([v, t]) => defaultFeeConfig(v, t)),
+ exchanges: {},
+ });
+ expect(t, result.coins).deep.equal(["KUDOS:2"]);
+ t.assert(result.refresh === undefined);
+});
+
+test("get raw 4", (t) => {
+ const coinList: Coin[] = [
+ [kudos(2), 5],
+ [kudos(5), 5],
+ ];
+ const result = selectCoinForOperation("credit", kudos(4), "gross", {
+ list: coinList.map(([v, t]) => defaultFeeConfig(v, t)),
+ exchanges: {},
+ });
+
+ expect(t, result.coins).deep.equal(["KUDOS:2", "KUDOS:2"]);
+ t.assert(result.refresh === undefined);
+});
+
+test("send effective 6", (t) => {
+ const coinList: Coin[] = [
+ [kudos(2), 5],
+ [kudos(5), 5],
+ ];
+ const result = selectCoinForOperation("debit", kudos(6), "gross", {
+ list: coinList.map(([v, t]) => defaultFeeConfig(v, t)),
+ exchanges: {},
+ });
+
+ expect(t, result.coins).deep.equal(["KUDOS:5"]);
+ t.assert(result.refresh?.selected === "KUDOS:2");
+});
+
+test("send raw 6", (t) => {
+ const coinList: Coin[] = [
+ [kudos(2), 5],
+ [kudos(5), 5],
+ ];
+ const result = selectCoinForOperation("debit", kudos(6), "gross", {
+ list: coinList.map(([v, t]) => defaultFeeConfig(v, t)),
+ exchanges: {},
+ });
+
+ expect(t, result.coins).deep.equal(["KUDOS:5"]);
+ t.assert(result.refresh?.selected === "KUDOS:2");
+});
+
+test("send raw 20 (not enough)", (t) => {
+ const coinList: Coin[] = [
+ [kudos(2), 1],
+ [kudos(5), 2],
+ ];
+ const result = selectCoinForOperation("debit", kudos(20), "gross", {
+ list: coinList.map(([v, t]) => defaultFeeConfig(v, t)),
+ exchanges: {},
+ });
+
+ expect(t, result.coins).deep.equal(["KUDOS:5", "KUDOS:5", "KUDOS:2"]);
+ t.assert(result.refresh === undefined);
+});
+
+/**
+ * calculatePlanFormAvailableCoins test
+ *
+ * Test here should check that the plan summary for a transaction is correct
+ * * effective amount
+ * * raw amount
+ */
+
+test("deposit effective 2 ", (t) => {
+ const coinList: Coin[] = [
+ [kudos(2), 1],
+ [kudos(5), 2],
+ ];
+ const result = calculatePlanFormAvailableCoins(
+ TransactionType.Deposit,
+ kudos(2),
+ "effective",
+ {
+ list: coinList.map(([v, t]) => defaultFeeConfig(v, t)),
+ exchanges: {
+ "2": {
+ creditDeadline: AbsoluteTime.never(),
+ debitDeadline: AbsoluteTime.never(),
+ wireFee: kudos(0.01),
+ purseFee: kudos(0.01),
+ },
+ },
+ },
+ );
+
+ t.deepEqual(result.rawAmount, "KUDOS:1.98");
+ t.deepEqual(result.effectiveAmount, "KUDOS:2");
+});
+
+test("deposit raw 2 ", (t) => {
+ const coinList: Coin[] = [
+ [kudos(2), 1],
+ [kudos(5), 2],
+ ];
+ const result = calculatePlanFormAvailableCoins(
+ TransactionType.Deposit,
+ kudos(2),
+ "raw",
+ {
+ list: coinList.map(([v, t]) => defaultFeeConfig(v, t)),
+ exchanges: {
+ "2": {
+ creditDeadline: AbsoluteTime.never(),
+ debitDeadline: AbsoluteTime.never(),
+ wireFee: kudos(0.01),
+ purseFee: kudos(0.01),
+ },
+ },
+ },
+ );
+
+ t.deepEqual(result.rawAmount, "KUDOS:2");
+ t.deepEqual(result.effectiveAmount, "KUDOS:2.04");
+});
+
+test("withdraw raw 21 ", (t) => {
+ const coinList: Coin[] = [
+ [kudos(2), 1],
+ [kudos(5), 2],
+ ];
+ const result = calculatePlanFormAvailableCoins(
+ TransactionType.Withdrawal,
+ kudos(21),
+ "raw",
+ {
+ list: coinList.map(([v, t]) => defaultFeeConfig(v, t)),
+ exchanges: {
+ "2": {
+ creditDeadline: AbsoluteTime.never(),
+ debitDeadline: AbsoluteTime.never(),
+ wireFee: kudos(0.01),
+ purseFee: kudos(0.01),
+ },
+ },
+ },
+ );
+
+ // denominations configuration is not suitable
+ // for greedy algorithm
+ t.deepEqual(result.rawAmount, "KUDOS:20");
+ t.deepEqual(result.effectiveAmount, "KUDOS:19.96");
});
diff --git a/packages/taler-wallet-core/src/util/coinSelection.ts b/packages/taler-wallet-core/src/util/coinSelection.ts
index 8fd09ea2b..f6d8abcd4 100644
--- a/packages/taler-wallet-core/src/util/coinSelection.ts
+++ b/packages/taler-wallet-core/src/util/coinSelection.ts
@@ -35,6 +35,7 @@ import {
DenominationInfo,
DenominationPubKey,
DenomSelectionState,
+ Duration,
ForcedCoinSel,
ForcedDenomSel,
GetPlanForOperationRequest,
@@ -52,7 +53,11 @@ import {
AllowedExchangeInfo,
DenominationRecord,
} from "../db.js";
-import { getExchangeDetails, isWithdrawableDenom } from "../index.js";
+import {
+ CoinAvailabilityRecord,
+ getExchangeDetails,
+ isWithdrawableDenom,
+} from "../index.js";
import { InternalWalletState } from "../internal-wallet-state.js";
import { getMerchantPaymentBalanceDetails } from "../operations/balance.js";
import { checkDbInvariant, checkLogicInvariant } from "./invariants.js";
@@ -790,120 +795,122 @@ export function selectForcedWithdrawalDenominations(
};
}
-/**
- * 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);
-
+function getCoinsFilter(req: GetPlanForOperationRequest): CoinsFilter {
switch (req.type) {
case TransactionType.Withdrawal: {
- const availableCoins = await getAvailableCoins(
- ws,
- "credit",
- amount.currency,
- false,
- false,
- undefined,
- undefined,
- undefined,
- );
- const usableCoins = selectCoinForOperation(
- "credit",
- amount,
- req.mode === "effective" ? "net" : "gross",
- availableCoins.denoms,
- );
+ 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,
+ };
+ }
+ }
+}
- return getAmountsWithFee(
- "credit",
- usableCoins.totalValue,
- usableCoins.totalContribution,
- usableCoins,
+export function calculatePlanFormAvailableCoins(
+ transactionType: TransactionType,
+ amount: AmountJson,
+ mode: "effective" | "raw",
+ availableCoins: AvailableCoins,
+) {
+ const operationType = getOperationType(transactionType);
+ let usableCoins;
+ switch (transactionType) {
+ case TransactionType.Withdrawal: {
+ usableCoins = selectCoinForOperation(
+ operationType,
+ amount,
+ mode === "effective" ? "net" : "gross",
+ availableCoins,
);
+ break;
}
case TransactionType.Deposit: {
- const payto = parsePaytoUri(req.account)!;
- const availableCoins = await getAvailableCoins(
- ws,
- "debit",
- amount.currency,
- true,
- false,
- undefined,
- [payto.targetType],
- undefined,
- );
//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!;
- const wireFee = Object.entries(availableCoins.wfPerExchange)[0][1][
- payto.targetType
- ];
-
- let usableCoins;
-
- if (req.mode === "effective") {
+ if (mode === "effective") {
usableCoins = selectCoinForOperation(
- "debit",
+ operationType,
amount,
"gross",
- availableCoins.denoms,
+ availableCoins,
);
- usableCoins.totalContribution = Amounts.stringify(
- Amounts.sub(usableCoins.totalContribution, wireFee).amount,
- );
+ usableCoins.totalContribution = Amounts.sub(
+ usableCoins.totalContribution,
+ wireFee,
+ ).amount;
} else {
const adjustedAmount = Amounts.add(amount, wireFee).amount;
usableCoins = selectCoinForOperation(
- "debit",
+ operationType,
adjustedAmount,
- // amount,
"net",
- availableCoins.denoms,
+ availableCoins,
);
- usableCoins.totalContribution = Amounts.stringify(
- Amounts.sub(usableCoins.totalContribution, wireFee).amount,
- );
+ usableCoins.totalContribution = Amounts.sub(
+ usableCoins.totalContribution,
+ wireFee,
+ ).amount;
}
-
- return getAmountsWithFee(
- "debit",
- usableCoins.totalValue,
- usableCoins.totalContribution,
- usableCoins,
- );
+ break;
}
default: {
throw Error("operation not supported");
}
}
+
+ return getAmountsWithFee(
+ operationType,
+ usableCoins!.totalValue,
+ usableCoins!.totalContribution,
+ usableCoins,
+ );
}
-function getAmountsWithFee(
- op: "debit" | "credit",
- value: AmountString,
- contribution: AmountString,
- details: any,
-): GetPlanForOperationResponse {
- return {
- rawAmount: op === "credit" ? value : contribution,
- effectiveAmount: op === "credit" ? contribution : value,
- details,
- };
+/**
+ * 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,
+ );
}
/**
@@ -914,24 +921,20 @@ function getAmountsWithFee(
* @param denoms list of available denomination for the operation
* @returns
*/
-function selectCoinForOperation(
+export function selectCoinForOperation(
op: "debit" | "credit",
limit: AmountJson,
mode: "net" | "gross",
- denoms: AvailableDenom[],
+ coins: AvailableCoins,
): SelectedCoins {
const result: SelectedCoins = {
- totalValue: Amounts.stringify(Amounts.zeroOfCurrency(limit.currency)),
- totalWithdrawalFee: Amounts.stringify(
- Amounts.zeroOfCurrency(limit.currency),
- ),
- totalDepositFee: Amounts.stringify(Amounts.zeroOfCurrency(limit.currency)),
- totalContribution: Amounts.stringify(
- Amounts.zeroOfCurrency(limit.currency),
- ),
+ totalValue: Amounts.zeroOfCurrency(limit.currency),
+ totalWithdrawalFee: Amounts.zeroOfCurrency(limit.currency),
+ totalDepositFee: Amounts.zeroOfCurrency(limit.currency),
+ totalContribution: Amounts.zeroOfCurrency(limit.currency),
coins: [],
};
- if (!denoms.length) return result;
+ 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
@@ -940,28 +943,26 @@ function selectCoinForOperation(
*/
//rank coins
- denoms.sort(
- op === "credit"
- ? denomsByDescendingWithdrawContribution
- : denomsByDescendingDepositContribution,
- );
+ coins.list.sort(buildRankingForCoins(op));
//take coins in order until amount
let selectedCoinsAreEnough = false;
let denomIdx = 0;
- iterateDenoms: while (denomIdx < denoms.length) {
- const cur = denoms[denomIdx];
- // for (const cur of denoms) {
- let total = op === "credit" ? Number.MAX_SAFE_INTEGER : cur.numAvailable;
- const opFee = op === "credit" ? cur.feeWithdraw : cur.feeDeposit;
- const contribution = Amounts.sub(cur.value, opFee).amount;
+ iterateDenoms: while (denomIdx < coins.list.length) {
+ const denom = coins.list[denomIdx];
+ let total =
+ op === "credit" ? Number.MAX_SAFE_INTEGER : denom.totalAvailable ?? 0;
+ const opFee = op === "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, cur.value).amount;
+ const nextValue = Amounts.add(result.totalValue, denom.value).amount;
const nextContribution = Amounts.add(
result.totalContribution,
@@ -975,18 +976,20 @@ function selectCoinForOperation(
break iterateCoins;
}
- result.totalValue = Amounts.stringify(nextValue);
- result.totalContribution = Amounts.stringify(nextContribution);
+ result.totalValue = nextValue;
+ result.totalContribution = nextContribution;
- result.totalDepositFee = Amounts.stringify(
- Amounts.add(result.totalDepositFee, cur.feeDeposit).amount,
- );
+ result.totalDepositFee = Amounts.add(
+ result.totalDepositFee,
+ denom.denomDeposit,
+ ).amount;
- result.totalWithdrawalFee = Amounts.stringify(
- Amounts.add(result.totalWithdrawalFee, cur.feeWithdraw).amount,
- );
+ result.totalWithdrawalFee = Amounts.add(
+ result.totalWithdrawalFee,
+ denom.denomWithdraw,
+ ).amount;
- result.coins.push(cur.denomPubHash);
+ result.coins.push(denom.id);
if (Amounts.cmp(progress, limit) === 0) {
selectedCoinsAreEnough = true;
@@ -1021,12 +1024,12 @@ function selectCoinForOperation(
let refreshIdx = 0;
let choice: RefreshChoice | undefined = undefined;
- refreshIteration: while (refreshIdx < denoms.length) {
- const d = denoms[refreshIdx];
+ refreshIteration: while (refreshIdx < coins.list.length) {
+ const d = coins.list[refreshIdx];
const denomContribution =
mode === "gross"
- ? Amounts.sub(d.value, d.feeRefresh).amount
- : Amounts.sub(d.value, d.feeDeposit, d.feeRefresh).amount;
+ ? 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)) {
@@ -1038,30 +1041,26 @@ function selectCoinForOperation(
"credit",
changeAfterDeposit,
mode,
- denoms,
+ coins,
);
const totalFee = Amounts.add(
- d.feeDeposit,
- d.feeRefresh,
+ d.denomDeposit,
+ d.denomRefresh,
changeCost.totalWithdrawalFee,
).amount;
if (!choice || Amounts.cmp(totalFee, choice.totalFee) === -1) {
//found cheaper change
choice = {
- gap: Amounts.stringify(gap),
- totalFee: Amounts.stringify(totalFee),
- selected: d.denomPubHash,
+ gap: gap,
+ totalFee: totalFee,
+ selected: d.id,
totalValue: d.value,
- totalRefreshFee: Amounts.stringify(d.feeRefresh),
- totalDepositFee: d.feeDeposit,
- totalChangeValue: Amounts.stringify(changeCost.totalValue),
- totalChangeContribution: Amounts.stringify(
- changeCost.totalContribution,
- ),
- totalChangeWithdrawalFee: Amounts.stringify(
- changeCost.totalWithdrawalFee,
- ),
+ totalRefreshFee: d.denomRefresh,
+ totalDepositFee: d.denomDeposit,
+ totalChangeValue: changeCost.totalValue,
+ totalChangeContribution: changeCost.totalContribution,
+ totalChangeWithdrawalFee: changeCost.totalWithdrawalFee,
change: changeCost.coins,
};
}
@@ -1069,22 +1068,25 @@ function selectCoinForOperation(
}
if (choice) {
if (mode === "gross") {
- result.totalValue = Amounts.stringify(
- Amounts.add(result.totalValue, gap).amount,
- );
- result.totalContribution = Amounts.stringify(
- Amounts.add(result.totalContribution, gap).amount,
- );
- result.totalContribution = Amounts.stringify(
- Amounts.sub(result.totalContribution, choice.totalFee).amount,
- );
+ 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.stringify(
- Amounts.add(result.totalContribution, gap).amount,
- );
- result.totalValue = Amounts.stringify(
- Amounts.add(result.totalValue, gap, choice.totalFee).amount,
- );
+ result.totalContribution = Amounts.add(
+ result.totalContribution,
+ gap,
+ ).amount;
+ result.totalValue = Amounts.add(
+ result.totalValue,
+ gap,
+ choice.totalFee,
+ ).amount;
}
}
@@ -1093,50 +1095,105 @@ function selectCoinForOperation(
return result;
}
-function denomsByDescendingDepositContribution(
- d1: AvailableDenom,
- d2: AvailableDenom,
-) {
- const contrib1 = Amounts.sub(d1.value, d1.feeDeposit).amount;
- const contrib2 = Amounts.sub(d2.value, d2.feeDeposit).amount;
- return (
- Amounts.cmp(contrib2, contrib1) || strcmp(d1.denomPubHash, d2.denomPubHash)
- );
+type CompareCoinsFunction = (d1: CoinInfo, d2: CoinInfo) => -1 | 0 | 1;
+function buildRankingForCoins(op: "debit" | "credit"): CompareCoinsFunction {
+ function getFee(d: CoinInfo) {
+ return op === "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 denomsByDescendingWithdrawContribution(
- d1: AvailableDenom,
- d2: AvailableDenom,
-) {
- const contrib1 = Amounts.sub(d1.value, d1.feeWithdraw).amount;
- const contrib2 = Amounts.sub(d2.value, d2.feeWithdraw).amount;
- return (
- Amounts.cmp(contrib2, contrib1) || strcmp(d1.denomPubHash, d2.denomPubHash)
- );
+
+function getOperationType(txType: TransactionType): "credit" | "debit" {
+ const operationType =
+ txType === TransactionType.Withdrawal
+ ? ("credit" as const)
+ : txType === TransactionType.Deposit
+ ? ("debit" as const)
+ : undefined;
+ if (!operationType) {
+ throw Error(`operation type ${txType} not supported`);
+ }
+ return operationType;
+}
+
+function getAmountsWithFee(
+ op: "debit" | "credit",
+ value: AmountJson,
+ contribution: AmountJson,
+ details: any,
+): GetPlanForOperationResponse {
+ return {
+ rawAmount: Amounts.stringify(op === "credit" ? value : contribution),
+ effectiveAmount: Amounts.stringify(op === "credit" ? contribution : value),
+ details,
+ };
}
interface RefreshChoice {
- gap: AmountString;
- totalFee: AmountString;
+ gap: AmountJson;
+ totalFee: AmountJson;
selected: string;
- totalValue: AmountString;
- totalDepositFee: AmountString;
- totalRefreshFee: AmountString;
- totalChangeValue: AmountString;
- totalChangeContribution: AmountString;
- totalChangeWithdrawalFee: AmountString;
+ totalValue: AmountJson;
+ totalDepositFee: AmountJson;
+ totalRefreshFee: AmountJson;
+ totalChangeValue: AmountJson;
+ totalChangeContribution: AmountJson;
+ totalChangeWithdrawalFee: AmountJson;
change: string[];
}
interface SelectedCoins {
- totalValue: AmountString;
- totalContribution: AmountString;
- totalWithdrawalFee: AmountString;
- totalDepositFee: AmountString;
+ totalValue: AmountJson;
+ totalContribution: AmountJson;
+ totalWithdrawalFee: AmountJson;
+ totalDepositFee: AmountJson;
coins: string[];
refresh?: RefreshChoice;
}
+interface AvailableCoins {
+ list: CoinInfo[];
+ exchanges: Record<string, ExchangeInfo>;
+}
+interface CoinInfo {
+ id: string;
+ value: AmountJson;
+ denomDeposit: AmountJson;
+ denomWithdraw: AmountJson;
+ denomRefresh: AmountJson;
+ totalAvailable: number | undefined;
+ exchangeWire: AmountJson | undefined;
+ exchangePurse: AmountJson | undefined;
+ duration: Duration;
+ maxAge: number;
+}
+interface ExchangeInfo {
+ wireFee: AmountJson | undefined;
+ purseFee: AmountJson | undefined;
+ creditDeadline: AbsoluteTime;
+ debitDeadline: AbsoluteTime;
+}
+
+interface CoinsFilter {
+ shouldCalculatePurseFee?: boolean;
+ exchanges?: string[];
+ wireMethod?: string;
+ ageRestricted?: number;
+}
/**
* Get all the denoms that can be used for a operation that is limited
* by the following restrictions.
@@ -1147,12 +1204,8 @@ async function getAvailableCoins(
ws: InternalWalletState,
op: "credit" | "debit",
currency: string,
- shouldCalculateWireFee: boolean,
- shouldCalculatePurseFee: boolean,
- exchangeFilter: string[] | undefined,
- wireMethodFilter: string[] | undefined,
- ageRestrictedFilter: number | undefined,
-) {
+ filters: CoinsFilter = {},
+): Promise<AvailableCoins> {
return await ws.db
.mktx((x) => [
x.exchanges,
@@ -1161,90 +1214,103 @@ async function getAvailableCoins(
x.coinAvailability,
])
.runReadOnly(async (tx) => {
- const denoms: AvailableDenom[] = [];
- const wfPerExchange: Record<string, Record<string, AmountJson>> = {};
- const pfPerExchange: Record<string, AmountJson> = {};
+ const list: CoinInfo[] = [];
+ const exchanges: Record<string, ExchangeInfo> = {};
const databaseExchanges = await tx.exchanges.iter().toArray();
- const exchanges =
- exchangeFilter === undefined
- ? databaseExchanges.map((e) => e.baseUrl)
- : exchangeFilter;
+ const filteredExchanges =
+ filters.exchanges ?? databaseExchanges.map((e) => e.baseUrl);
- for (const exchangeBaseUrl of exchanges) {
+ for (const exchangeBaseUrl of filteredExchanges) {
const exchangeDetails = await getExchangeDetails(tx, exchangeBaseUrl);
// 1.- exchange has same currency
if (exchangeDetails?.currency !== currency) {
continue;
}
- const wireMethodFee: Record<string, AmountJson> = {};
+ let deadline = AbsoluteTime.never();
// 2.- exchange supports wire method
- if (shouldCalculateWireFee) {
- for (const acc of exchangeDetails.wireInfo.accounts) {
- const pp = parsePaytoUri(acc.payto_uri);
- checkLogicInvariant(!!pp);
- // also check that wire method is supported now
- if (wireMethodFilter !== undefined) {
- if (wireMethodFilter.indexOf(pp.targetType) === -1) {
- continue;
- }
- }
- const wireFeeStr = exchangeDetails.wireInfo.feesForType[
- pp.targetType
- ]?.find((x) => {
- return AbsoluteTime.isBetween(
- AbsoluteTime.now(),
- AbsoluteTime.fromProtocolTimestamp(x.startStamp),
- AbsoluteTime.fromProtocolTimestamp(x.endStamp),
- );
- })?.wireFee;
+ let wireFee: AmountJson | undefined;
+ if (filters.wireMethod) {
+ const wireMethodWithDates =
+ exchangeDetails.wireInfo.feesForType[filters.wireMethod];
- if (wireFeeStr) {
- wireMethodFee[pp.targetType] = Amounts.parseOrThrow(wireFeeStr);
- }
- break;
+ if (!wireMethodWithDates) {
+ throw Error(
+ `exchange ${exchangeBaseUrl} doesn't have wire method ${filters.wireMethod}`,
+ );
}
- if (Object.keys(wireMethodFee).length === 0) {
+ 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),
+ );
}
- wfPerExchange[exchangeBaseUrl] = wireMethodFee;
+ // exchanges[exchangeBaseUrl].wireFee = wireMethodFee;
// 3.- exchange supports wire method
- if (shouldCalculatePurseFee) {
+ 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),
);
- })?.purseFee;
+ });
if (!purseFeeFound) {
throw Error(
`exchange ${exchangeBaseUrl} doesn't have purse fee defined for this period`,
);
}
- const purseFee = Amounts.parseOrThrow(purseFeeFound);
- pfPerExchange[exchangeBaseUrl] = purseFee;
+ 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 (op === "credit") {
const ds = await tx.denominations.indexes.byExchangeBaseUrl.getAll(
exchangeBaseUrl,
);
for (const denom of ds) {
- denoms.push({
- ...DenominationRecord.toDenomInfo(denom),
- numAvailable: Number.MAX_SAFE_INTEGER,
- maxAge: AgeRestriction.AGE_UNRESTRICTED,
- });
+ const expiresWithdraw = AbsoluteTime.fromProtocolTimestamp(
+ denom.stampExpireWithdraw,
+ );
+ const expiresDeposit = AbsoluteTime.fromProtocolTimestamp(
+ denom.stampExpireDeposit,
+ );
+ creditDeadline = AbsoluteTime.min(deadline, expiresWithdraw);
+ debitDeadline = AbsoluteTime.min(deadline, expiresDeposit);
+ list.push(
+ buildCoinInfoFromDenom(
+ denom,
+ purseFee,
+ wireFee,
+ AgeRestriction.AGE_UNRESTRICTED,
+ Number.MAX_SAFE_INTEGER, // Max withdrawable from single denom
+ ),
+ );
}
} else {
- const ageLower = !ageRestrictedFilter ? 0 : ageRestrictedFilter;
+ const ageLower = filters.ageRestricted ?? 0;
const ageUpper = AgeRestriction.AGE_UNRESTRICTED;
const myExchangeCoins =
@@ -1271,19 +1337,58 @@ async function getAvailableCoins(
if (denom.isRevoked || !denom.isOffered) {
continue;
}
- denoms.push({
- ...DenominationRecord.toDenomInfo(denom),
- numAvailable: coinAvail.freshCoinCount ?? 0,
- maxAge: coinAvail.maxAge,
- });
+ const expiresWithdraw = AbsoluteTime.fromProtocolTimestamp(
+ denom.stampExpireWithdraw,
+ );
+ const expiresDeposit = AbsoluteTime.fromProtocolTimestamp(
+ denom.stampExpireDeposit,
+ );
+ creditDeadline = AbsoluteTime.min(deadline, expiresWithdraw);
+ debitDeadline = AbsoluteTime.min(deadline, expiresDeposit);
+ list.push(
+ buildCoinInfoFromDenom(
+ denom,
+ purseFee,
+ wireFee,
+ coinAvail.maxAge,
+ coinAvail.freshCoinCount,
+ ),
+ );
}
}
+
+ exchanges[exchangeBaseUrl] = {
+ purseFee,
+ wireFee,
+ debitDeadline,
+ creditDeadline,
+ };
}
- return {
- denoms,
- wfPerExchange,
- pfPerExchange,
- };
+ 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,
+ duration: AbsoluteTime.difference(
+ AbsoluteTime.now(),
+ AbsoluteTime.fromProtocolTimestamp(denom.stampExpireDeposit),
+ ),
+ totalAvailable: total,
+ value: DenominationRecord.getValue(denom),
+ maxAge,
+ };
+}