add test to coin selection algorithm

This commit is contained in:
Sebastian 2023-06-15 13:07:31 -03:00
parent f7058a86c9
commit d0d7685f16
No known key found for this signature in database
GPG Key ID: 173909D1A5F66069
2 changed files with 553 additions and 257 deletions

View File

@ -14,6 +14,18 @@
GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
*/ */
import test, { ExecutionContext } from "ava"; 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 { function expect(t: ExecutionContext, thing: any): any {
return { return {
@ -24,6 +36,185 @@ function expect(t: ExecutionContext, thing: any): any {
}; };
} }
test("should have a test", (t) => { function kudos(v: number): AmountJson {
expect(t, true).deep.equal(true); 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");
}); });

View File

@ -35,6 +35,7 @@ import {
DenominationInfo, DenominationInfo,
DenominationPubKey, DenominationPubKey,
DenomSelectionState, DenomSelectionState,
Duration,
ForcedCoinSel, ForcedCoinSel,
ForcedDenomSel, ForcedDenomSel,
GetPlanForOperationRequest, GetPlanForOperationRequest,
@ -52,7 +53,11 @@ import {
AllowedExchangeInfo, AllowedExchangeInfo,
DenominationRecord, DenominationRecord,
} from "../db.js"; } from "../db.js";
import { getExchangeDetails, isWithdrawableDenom } from "../index.js"; import {
CoinAvailabilityRecord,
getExchangeDetails,
isWithdrawableDenom,
} from "../index.js";
import { InternalWalletState } from "../internal-wallet-state.js"; import { InternalWalletState } from "../internal-wallet-state.js";
import { getMerchantPaymentBalanceDetails } from "../operations/balance.js"; import { getMerchantPaymentBalanceDetails } from "../operations/balance.js";
import { checkDbInvariant, checkLogicInvariant } from "./invariants.js"; import { checkDbInvariant, checkLogicInvariant } from "./invariants.js";
@ -790,6 +795,92 @@ export function selectForcedWithdrawalDenominations(
}; };
} }
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,
};
}
}
}
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: {
//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 === "effective") {
usableCoins = selectCoinForOperation(
operationType,
amount,
"gross",
availableCoins,
);
usableCoins.totalContribution = Amounts.sub(
usableCoins.totalContribution,
wireFee,
).amount;
} else {
const adjustedAmount = Amounts.add(amount, wireFee).amount;
usableCoins = selectCoinForOperation(
operationType,
adjustedAmount,
"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 * simulate a coin selection and return the amount
* that will effectively change the wallet balance and * that will effectively change the wallet balance and
@ -804,106 +895,22 @@ export async function getPlanForOperation(
req: GetPlanForOperationRequest, req: GetPlanForOperationRequest,
): Promise<GetPlanForOperationResponse> { ): Promise<GetPlanForOperationResponse> {
const amount = Amounts.parseOrThrow(req.instructedAmount); const amount = Amounts.parseOrThrow(req.instructedAmount);
const operationType = getOperationType(req.type);
const filter = getCoinsFilter(req);
switch (req.type) {
case TransactionType.Withdrawal: {
const availableCoins = await getAvailableCoins( const availableCoins = await getAvailableCoins(
ws, ws,
"credit", operationType,
amount.currency, amount.currency,
false, filter,
false,
undefined,
undefined,
undefined,
); );
const usableCoins = selectCoinForOperation(
"credit", return calculatePlanFormAvailableCoins(
req.type,
amount, amount,
req.mode === "effective" ? "net" : "gross", req.mode,
availableCoins.denoms, availableCoins,
); );
return getAmountsWithFee(
"credit",
usableCoins.totalValue,
usableCoins.totalContribution,
usableCoins,
);
}
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.entries(availableCoins.wfPerExchange)[0][1][
payto.targetType
];
let usableCoins;
if (req.mode === "effective") {
usableCoins = selectCoinForOperation(
"debit",
amount,
"gross",
availableCoins.denoms,
);
usableCoins.totalContribution = Amounts.stringify(
Amounts.sub(usableCoins.totalContribution, wireFee).amount,
);
} else {
const adjustedAmount = Amounts.add(amount, wireFee).amount;
usableCoins = selectCoinForOperation(
"debit",
adjustedAmount,
// amount,
"net",
availableCoins.denoms,
);
usableCoins.totalContribution = Amounts.stringify(
Amounts.sub(usableCoins.totalContribution, wireFee).amount,
);
}
return getAmountsWithFee(
"debit",
usableCoins.totalValue,
usableCoins.totalContribution,
usableCoins,
);
}
default: {
throw Error("operation not supported");
}
}
}
function getAmountsWithFee(
op: "debit" | "credit",
value: AmountString,
contribution: AmountString,
details: any,
): GetPlanForOperationResponse {
return {
rawAmount: op === "credit" ? value : contribution,
effectiveAmount: op === "credit" ? contribution : value,
details,
};
} }
/** /**
@ -914,24 +921,20 @@ function getAmountsWithFee(
* @param denoms list of available denomination for the operation * @param denoms list of available denomination for the operation
* @returns * @returns
*/ */
function selectCoinForOperation( export function selectCoinForOperation(
op: "debit" | "credit", op: "debit" | "credit",
limit: AmountJson, limit: AmountJson,
mode: "net" | "gross", mode: "net" | "gross",
denoms: AvailableDenom[], coins: AvailableCoins,
): SelectedCoins { ): SelectedCoins {
const result: SelectedCoins = { const result: SelectedCoins = {
totalValue: Amounts.stringify(Amounts.zeroOfCurrency(limit.currency)), totalValue: Amounts.zeroOfCurrency(limit.currency),
totalWithdrawalFee: Amounts.stringify( totalWithdrawalFee: Amounts.zeroOfCurrency(limit.currency),
Amounts.zeroOfCurrency(limit.currency), totalDepositFee: Amounts.zeroOfCurrency(limit.currency),
), totalContribution: Amounts.zeroOfCurrency(limit.currency),
totalDepositFee: Amounts.stringify(Amounts.zeroOfCurrency(limit.currency)),
totalContribution: Amounts.stringify(
Amounts.zeroOfCurrency(limit.currency),
),
coins: [], coins: [],
}; };
if (!denoms.length) return result; if (!coins.list.length) return result;
/** /**
* We can make this faster. We should prevent sorting and * We can make this faster. We should prevent sorting and
* keep the information ready for multiple calls since this * keep the information ready for multiple calls since this
@ -940,28 +943,26 @@ function selectCoinForOperation(
*/ */
//rank coins //rank coins
denoms.sort( coins.list.sort(buildRankingForCoins(op));
op === "credit"
? denomsByDescendingWithdrawContribution
: denomsByDescendingDepositContribution,
);
//take coins in order until amount //take coins in order until amount
let selectedCoinsAreEnough = false; let selectedCoinsAreEnough = false;
let denomIdx = 0; let denomIdx = 0;
iterateDenoms: while (denomIdx < denoms.length) { iterateDenoms: while (denomIdx < coins.list.length) {
const cur = denoms[denomIdx]; const denom = coins.list[denomIdx];
// for (const cur of denoms) { let total =
let total = op === "credit" ? Number.MAX_SAFE_INTEGER : cur.numAvailable; op === "credit" ? Number.MAX_SAFE_INTEGER : denom.totalAvailable ?? 0;
const opFee = op === "credit" ? cur.feeWithdraw : cur.feeDeposit; const opFee = op === "credit" ? denom.denomWithdraw : denom.denomDeposit;
const contribution = Amounts.sub(cur.value, opFee).amount; const contribution = Amounts.sub(denom.value, opFee).amount;
if (Amounts.isZero(contribution)) { if (Amounts.isZero(contribution)) {
// 0 contribution denoms should be the last // 0 contribution denoms should be the last
break iterateDenoms; break iterateDenoms;
} }
//use Amounts.divmod instead of iterate
iterateCoins: while (total > 0) { 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( const nextContribution = Amounts.add(
result.totalContribution, result.totalContribution,
@ -975,18 +976,20 @@ function selectCoinForOperation(
break iterateCoins; break iterateCoins;
} }
result.totalValue = Amounts.stringify(nextValue); result.totalValue = nextValue;
result.totalContribution = Amounts.stringify(nextContribution); result.totalContribution = nextContribution;
result.totalDepositFee = Amounts.stringify( result.totalDepositFee = Amounts.add(
Amounts.add(result.totalDepositFee, cur.feeDeposit).amount, result.totalDepositFee,
); denom.denomDeposit,
).amount;
result.totalWithdrawalFee = Amounts.stringify( result.totalWithdrawalFee = Amounts.add(
Amounts.add(result.totalWithdrawalFee, cur.feeWithdraw).amount, result.totalWithdrawalFee,
); denom.denomWithdraw,
).amount;
result.coins.push(cur.denomPubHash); result.coins.push(denom.id);
if (Amounts.cmp(progress, limit) === 0) { if (Amounts.cmp(progress, limit) === 0) {
selectedCoinsAreEnough = true; selectedCoinsAreEnough = true;
@ -1021,12 +1024,12 @@ function selectCoinForOperation(
let refreshIdx = 0; let refreshIdx = 0;
let choice: RefreshChoice | undefined = undefined; let choice: RefreshChoice | undefined = undefined;
refreshIteration: while (refreshIdx < denoms.length) { refreshIteration: while (refreshIdx < coins.list.length) {
const d = denoms[refreshIdx]; const d = coins.list[refreshIdx];
const denomContribution = const denomContribution =
mode === "gross" mode === "gross"
? Amounts.sub(d.value, d.feeRefresh).amount ? Amounts.sub(d.value, d.denomRefresh).amount
: Amounts.sub(d.value, d.feeDeposit, d.feeRefresh).amount; : Amounts.sub(d.value, d.denomDeposit, d.denomRefresh).amount;
const changeAfterDeposit = Amounts.sub(denomContribution, gap).amount; const changeAfterDeposit = Amounts.sub(denomContribution, gap).amount;
if (Amounts.isZero(changeAfterDeposit)) { if (Amounts.isZero(changeAfterDeposit)) {
@ -1038,30 +1041,26 @@ function selectCoinForOperation(
"credit", "credit",
changeAfterDeposit, changeAfterDeposit,
mode, mode,
denoms, coins,
); );
const totalFee = Amounts.add( const totalFee = Amounts.add(
d.feeDeposit, d.denomDeposit,
d.feeRefresh, d.denomRefresh,
changeCost.totalWithdrawalFee, changeCost.totalWithdrawalFee,
).amount; ).amount;
if (!choice || Amounts.cmp(totalFee, choice.totalFee) === -1) { if (!choice || Amounts.cmp(totalFee, choice.totalFee) === -1) {
//found cheaper change //found cheaper change
choice = { choice = {
gap: Amounts.stringify(gap), gap: gap,
totalFee: Amounts.stringify(totalFee), totalFee: totalFee,
selected: d.denomPubHash, selected: d.id,
totalValue: d.value, totalValue: d.value,
totalRefreshFee: Amounts.stringify(d.feeRefresh), totalRefreshFee: d.denomRefresh,
totalDepositFee: d.feeDeposit, totalDepositFee: d.denomDeposit,
totalChangeValue: Amounts.stringify(changeCost.totalValue), totalChangeValue: changeCost.totalValue,
totalChangeContribution: Amounts.stringify( totalChangeContribution: changeCost.totalContribution,
changeCost.totalContribution, totalChangeWithdrawalFee: changeCost.totalWithdrawalFee,
),
totalChangeWithdrawalFee: Amounts.stringify(
changeCost.totalWithdrawalFee,
),
change: changeCost.coins, change: changeCost.coins,
}; };
} }
@ -1069,22 +1068,25 @@ function selectCoinForOperation(
} }
if (choice) { if (choice) {
if (mode === "gross") { if (mode === "gross") {
result.totalValue = Amounts.stringify( result.totalValue = Amounts.add(result.totalValue, gap).amount;
Amounts.add(result.totalValue, gap).amount, result.totalContribution = Amounts.add(
); result.totalContribution,
result.totalContribution = Amounts.stringify( gap,
Amounts.add(result.totalContribution, gap).amount, ).amount;
); result.totalContribution = Amounts.sub(
result.totalContribution = Amounts.stringify( result.totalContribution,
Amounts.sub(result.totalContribution, choice.totalFee).amount, choice.totalFee,
); ).amount;
} else { } else {
result.totalContribution = Amounts.stringify( result.totalContribution = Amounts.add(
Amounts.add(result.totalContribution, gap).amount, result.totalContribution,
); gap,
result.totalValue = Amounts.stringify( ).amount;
Amounts.add(result.totalValue, gap, choice.totalFee).amount, result.totalValue = Amounts.add(
); result.totalValue,
gap,
choice.totalFee,
).amount;
} }
} }
@ -1093,50 +1095,105 @@ function selectCoinForOperation(
return result; return result;
} }
function denomsByDescendingDepositContribution( type CompareCoinsFunction = (d1: CoinInfo, d2: CoinInfo) => -1 | 0 | 1;
d1: AvailableDenom, function buildRankingForCoins(op: "debit" | "credit"): CompareCoinsFunction {
d2: AvailableDenom, function getFee(d: CoinInfo) {
) { return op === "credit" ? d.denomWithdraw : d.denomDeposit;
const contrib1 = Amounts.sub(d1.value, d1.feeDeposit).amount; }
const contrib2 = Amounts.sub(d2.value, d2.feeDeposit).amount; //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 ( return (
Amounts.cmp(contrib2, contrib1) || strcmp(d1.denomPubHash, d2.denomPubHash) Amounts.cmp(contrib2, contrib1) ||
Duration.cmp(d1.duration, d2.duration) ||
strcmp(d1.id, d2.id)
); );
};
} }
function denomsByDescendingWithdrawContribution(
d1: AvailableDenom, function getOperationType(txType: TransactionType): "credit" | "debit" {
d2: AvailableDenom, const operationType =
) { txType === TransactionType.Withdrawal
const contrib1 = Amounts.sub(d1.value, d1.feeWithdraw).amount; ? ("credit" as const)
const contrib2 = Amounts.sub(d2.value, d2.feeWithdraw).amount; : txType === TransactionType.Deposit
return ( ? ("debit" as const)
Amounts.cmp(contrib2, contrib1) || strcmp(d1.denomPubHash, d2.denomPubHash) : 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 { interface RefreshChoice {
gap: AmountString; gap: AmountJson;
totalFee: AmountString; totalFee: AmountJson;
selected: string; selected: string;
totalValue: AmountString; totalValue: AmountJson;
totalDepositFee: AmountString; totalDepositFee: AmountJson;
totalRefreshFee: AmountString; totalRefreshFee: AmountJson;
totalChangeValue: AmountString; totalChangeValue: AmountJson;
totalChangeContribution: AmountString; totalChangeContribution: AmountJson;
totalChangeWithdrawalFee: AmountString; totalChangeWithdrawalFee: AmountJson;
change: string[]; change: string[];
} }
interface SelectedCoins { interface SelectedCoins {
totalValue: AmountString; totalValue: AmountJson;
totalContribution: AmountString; totalContribution: AmountJson;
totalWithdrawalFee: AmountString; totalWithdrawalFee: AmountJson;
totalDepositFee: AmountString; totalDepositFee: AmountJson;
coins: string[]; coins: string[];
refresh?: RefreshChoice; 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 * Get all the denoms that can be used for a operation that is limited
* by the following restrictions. * by the following restrictions.
@ -1147,12 +1204,8 @@ async function getAvailableCoins(
ws: InternalWalletState, ws: InternalWalletState,
op: "credit" | "debit", op: "credit" | "debit",
currency: string, currency: string,
shouldCalculateWireFee: boolean, filters: CoinsFilter = {},
shouldCalculatePurseFee: boolean, ): Promise<AvailableCoins> {
exchangeFilter: string[] | undefined,
wireMethodFilter: string[] | undefined,
ageRestrictedFilter: number | undefined,
) {
return await ws.db return await ws.db
.mktx((x) => [ .mktx((x) => [
x.exchanges, x.exchanges,
@ -1161,90 +1214,103 @@ async function getAvailableCoins(
x.coinAvailability, x.coinAvailability,
]) ])
.runReadOnly(async (tx) => { .runReadOnly(async (tx) => {
const denoms: AvailableDenom[] = []; const list: CoinInfo[] = [];
const wfPerExchange: Record<string, Record<string, AmountJson>> = {}; const exchanges: Record<string, ExchangeInfo> = {};
const pfPerExchange: Record<string, AmountJson> = {};
const databaseExchanges = await tx.exchanges.iter().toArray(); const databaseExchanges = await tx.exchanges.iter().toArray();
const exchanges = const filteredExchanges =
exchangeFilter === undefined filters.exchanges ?? databaseExchanges.map((e) => e.baseUrl);
? databaseExchanges.map((e) => e.baseUrl)
: exchangeFilter;
for (const exchangeBaseUrl of exchanges) { for (const exchangeBaseUrl of filteredExchanges) {
const exchangeDetails = await getExchangeDetails(tx, exchangeBaseUrl); const exchangeDetails = await getExchangeDetails(tx, exchangeBaseUrl);
// 1.- exchange has same currency // 1.- exchange has same currency
if (exchangeDetails?.currency !== currency) { if (exchangeDetails?.currency !== currency) {
continue; continue;
} }
const wireMethodFee: Record<string, AmountJson> = {}; let deadline = AbsoluteTime.never();
// 2.- exchange supports wire method // 2.- exchange supports wire method
if (shouldCalculateWireFee) { let wireFee: AmountJson | undefined;
for (const acc of exchangeDetails.wireInfo.accounts) { if (filters.wireMethod) {
const pp = parsePaytoUri(acc.payto_uri); const wireMethodWithDates =
checkLogicInvariant(!!pp); exchangeDetails.wireInfo.feesForType[filters.wireMethod];
// also check that wire method is supported now
if (wireMethodFilter !== undefined) { if (!wireMethodWithDates) {
if (wireMethodFilter.indexOf(pp.targetType) === -1) { throw Error(
continue; `exchange ${exchangeBaseUrl} doesn't have wire method ${filters.wireMethod}`,
);
} }
} const wireMethodFee = wireMethodWithDates.find((x) => {
const wireFeeStr = exchangeDetails.wireInfo.feesForType[
pp.targetType
]?.find((x) => {
return AbsoluteTime.isBetween( return AbsoluteTime.isBetween(
AbsoluteTime.now(), AbsoluteTime.now(),
AbsoluteTime.fromProtocolTimestamp(x.startStamp), AbsoluteTime.fromProtocolTimestamp(x.startStamp),
AbsoluteTime.fromProtocolTimestamp(x.endStamp), AbsoluteTime.fromProtocolTimestamp(x.endStamp),
); );
})?.wireFee; });
if (wireFeeStr) { if (!wireMethodFee) {
wireMethodFee[pp.targetType] = Amounts.parseOrThrow(wireFeeStr);
}
break;
}
if (Object.keys(wireMethodFee).length === 0) {
throw Error( throw Error(
`exchange ${exchangeBaseUrl} doesn't have wire fee defined for this period`, `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 // 3.- exchange supports wire method
if (shouldCalculatePurseFee) { let purseFee: AmountJson | undefined;
if (filters.shouldCalculatePurseFee) {
const purseFeeFound = exchangeDetails.globalFees.find((x) => { const purseFeeFound = exchangeDetails.globalFees.find((x) => {
return AbsoluteTime.isBetween( return AbsoluteTime.isBetween(
AbsoluteTime.now(), AbsoluteTime.now(),
AbsoluteTime.fromProtocolTimestamp(x.startDate), AbsoluteTime.fromProtocolTimestamp(x.startDate),
AbsoluteTime.fromProtocolTimestamp(x.endDate), AbsoluteTime.fromProtocolTimestamp(x.endDate),
); );
})?.purseFee; });
if (!purseFeeFound) { if (!purseFeeFound) {
throw Error( throw Error(
`exchange ${exchangeBaseUrl} doesn't have purse fee defined for this period`, `exchange ${exchangeBaseUrl} doesn't have purse fee defined for this period`,
); );
} }
const purseFee = Amounts.parseOrThrow(purseFeeFound); purseFee = Amounts.parseOrThrow(purseFeeFound.purseFee);
pfPerExchange[exchangeBaseUrl] = purseFee; deadline = AbsoluteTime.min(
deadline,
AbsoluteTime.fromProtocolTimestamp(purseFeeFound.endDate),
);
} }
let creditDeadline = AbsoluteTime.never();
let debitDeadline = AbsoluteTime.never();
//4.- filter coins restricted by age //4.- filter coins restricted by age
if (op === "credit") { if (op === "credit") {
const ds = await tx.denominations.indexes.byExchangeBaseUrl.getAll( const ds = await tx.denominations.indexes.byExchangeBaseUrl.getAll(
exchangeBaseUrl, exchangeBaseUrl,
); );
for (const denom of ds) { for (const denom of ds) {
denoms.push({ const expiresWithdraw = AbsoluteTime.fromProtocolTimestamp(
...DenominationRecord.toDenomInfo(denom), denom.stampExpireWithdraw,
numAvailable: Number.MAX_SAFE_INTEGER, );
maxAge: AgeRestriction.AGE_UNRESTRICTED, 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 { } else {
const ageLower = !ageRestrictedFilter ? 0 : ageRestrictedFilter; const ageLower = filters.ageRestricted ?? 0;
const ageUpper = AgeRestriction.AGE_UNRESTRICTED; const ageUpper = AgeRestriction.AGE_UNRESTRICTED;
const myExchangeCoins = const myExchangeCoins =
@ -1271,19 +1337,58 @@ async function getAvailableCoins(
if (denom.isRevoked || !denom.isOffered) { if (denom.isRevoked || !denom.isOffered) {
continue; continue;
} }
denoms.push({ const expiresWithdraw = AbsoluteTime.fromProtocolTimestamp(
...DenominationRecord.toDenomInfo(denom), denom.stampExpireWithdraw,
numAvailable: coinAvail.freshCoinCount ?? 0, );
maxAge: coinAvail.maxAge, 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,
),
);
} }
} }
return { exchanges[exchangeBaseUrl] = {
denoms, purseFee,
wfPerExchange, wireFee,
pfPerExchange, 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,
duration: AbsoluteTime.difference(
AbsoluteTime.now(),
AbsoluteTime.fromProtocolTimestamp(denom.stampExpireDeposit),
),
totalAvailable: total,
value: DenominationRecord.getValue(denom),
maxAge,
};
}