diff options
author | Sebastian <sebasjm@gmail.com> | 2023-06-15 13:07:31 -0300 |
---|---|---|
committer | Sebastian <sebasjm@gmail.com> | 2023-06-15 13:07:31 -0300 |
commit | d0d7685f169ecad5ba29210973a9e59834c979c7 (patch) | |
tree | f99393e6b6711343e4ee6fcbf1c0f815725b4787 | |
parent | f7058a86c9f9c6a727c58be80dbc0e2b3f7218f8 (diff) |
add test to coin selection algorithm
-rw-r--r-- | packages/taler-wallet-core/src/util/coinSelection.test.ts | 195 | ||||
-rw-r--r-- | packages/taler-wallet-core/src/util/coinSelection.ts | 585 |
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, + }; +} |