From 917a0e4f28669e16b66233d3cd705f9a6afc58a4 Mon Sep 17 00:00:00 2001 From: jamaljsr <1356600+jamaljsr@users.noreply.github.com> Date: Wed, 30 Jun 2021 09:50:32 -0400 Subject: [PATCH] pool+order: display fee estimates in the order form --- app/src/api/pool.ts | 21 +++++++ app/src/components/pool/OrderFormSection.tsx | 34 +++++++++++ app/src/i18n/locales/en-US.json | 4 ++ app/src/store/stores/orderStore.ts | 44 +++++++++++++++ app/src/store/views/orderFormView.ts | 59 ++++++++++++++++++++ app/src/util/tests/sampleData.ts | 9 +++ 6 files changed, 171 insertions(+) diff --git a/app/src/api/pool.ts b/app/src/api/pool.ts index 020dbd280..6ca472093 100644 --- a/app/src/api/pool.ts +++ b/app/src/api/pool.ts @@ -161,6 +161,27 @@ class PoolApi extends BaseApi { return res.toObject(); } + /** + * call the pool `QuoteOrder` RPC and return the response + */ + async quoteOrder( + amount: number, + rateFixed: number, + duration: number, + minUnitsMatch: number, + feeRateSatPerKw: number, + ): Promise { + const req = new POOL.QuoteOrderRequest(); + req.setAmt(amount); + req.setRateFixed(rateFixed); + req.setLeaseDurationBlocks(duration); + req.setMinUnitsMatch(minUnitsMatch); + req.setMaxBatchFeeRateSatPerKw(feeRateSatPerKw); + + const res = await this._grpc.request(Trader.QuoteOrder, req, this._meta); + return res.toObject(); + } + /** * call the pool `SubmitOrder` RPC and return the response */ diff --git a/app/src/components/pool/OrderFormSection.tsx b/app/src/components/pool/OrderFormSection.tsx index 9d06f90c7..afd156c30 100644 --- a/app/src/components/pool/OrderFormSection.tsx +++ b/app/src/components/pool/OrderFormSection.tsx @@ -1,6 +1,7 @@ import React from 'react'; import { observer } from 'mobx-react-lite'; import { LeaseDuration } from 'types/state'; +import Big from 'big.js'; import { usePrefixedTranslation } from 'hooks'; import { Unit, Units } from 'util/constants'; import { useStore } from 'store'; @@ -18,8 +19,11 @@ import BlockTime from 'components/common/BlockTime'; import FormField from 'components/common/FormField'; import FormInputNumber from 'components/common/FormInputNumber'; import FormSelect from 'components/common/FormSelect'; +import LoaderLines from 'components/common/LoaderLines'; import StatusDot from 'components/common/StatusDot'; +import Tip from 'components/common/Tip'; import Toggle from 'components/common/Toggle'; +import UnitCmp from 'components/common/Unit'; import { styled } from 'components/theme'; const Styled = { @@ -62,6 +66,11 @@ const Styled = { margin: 30px auto; text-align: center; `, + LoaderLines: styled(LoaderLines)` + .line { + margin: 0 1px; + } + `, }; const OrderFormSection: React.FC = () => { @@ -80,6 +89,7 @@ const OrderFormSection: React.FC = () => { OptionsStatus, Divider, Actions, + LoaderLines, } = Styled; return (
@@ -177,6 +187,30 @@ const OrderFormSection: React.FC = () => { /> + + + {l('executionFeeLabel')} + + {orderFormView.quoteLoading ? ( + + ) : ( + + )} + + + + + + {l('chainFeeLabel')} + + + {orderFormView.quoteLoading ? ( + + ) : ( + + )} + + {l('durationLabel')} diff --git a/app/src/i18n/locales/en-US.json b/app/src/i18n/locales/en-US.json index 218bbdf3f..ae9d50427 100644 --- a/app/src/i18n/locales/en-US.json +++ b/app/src/i18n/locales/en-US.json @@ -232,6 +232,10 @@ "cmps.pool.OrderFormSection.feeLabel": "Max Batch Fee Rate", "cmps.pool.OrderFormSection.feePlaceholder": "100", "cmps.pool.OrderFormSection.tierLabel": "Min Node Tier", + "cmps.pool.OrderFormSection.executionFeeLabel": "Execution Fee", + "cmps.pool.OrderFormSection.executionFeeTip": "Total fee paid to the auctioneer for executing this order", + "cmps.pool.OrderFormSection.chainFeeLabel": "Worst Case Chain Fee", + "cmps.pool.OrderFormSection.chainFeeTip": "Assumes chain fees for the footprint of (amount / min_chan_size) channel openings using the max_batch_fee_rate", "cmps.pool.OrderFormSection.fixedRateLabel": "Per Block Fixed Rate", "cmps.pool.OrderFormSection.interestLabel": "Interest Rate", "cmps.pool.OrderFormSection.aprLabel": "Annual Rate (APR)", diff --git a/app/src/store/stores/orderStore.ts b/app/src/store/stores/orderStore.ts index 618362f85..92dd73da9 100644 --- a/app/src/store/stores/orderStore.ts +++ b/app/src/store/stores/orderStore.ts @@ -165,6 +165,50 @@ export default class OrderStore { /** fetch leases at most once every 2 seconds when using this func */ fetchLeasesThrottled = debounce(this.fetchLeases, 2000); + /** + * Requests a fee quote for an order + * @param amount the amount of the order + * @param rateFixed the per block fixed rate + * @param duration the number of blocks to keep the channel open for + * @param minUnitsMatch the minimum number of units required to match this order + * @param maxBatchFeeRate the maximum batch fee rate to allowed as sats per vByte + */ + async quoteOrder( + amount: number, + rateFixed: number, + duration: number, + minUnitsMatch: number, + maxBatchFeeRate: number, + ): Promise { + try { + this._store.log.info(`quoting an order for ${amount}sats`, { + rateFixed, + duration, + minUnitsMatch, + maxBatchFeeRate, + }); + + const res = await this._store.api.pool.quoteOrder( + amount, + rateFixed, + duration, + minUnitsMatch, + maxBatchFeeRate, + ); + + return res; + } catch (error) { + this._store.appView.handleError(error, 'Unable to estimate order fees'); + return { + ratePerBlock: rateFixed, + ratePercent: 0, + totalExecutionFeeSat: 0, + totalPremiumSat: 0, + worstCaseChainFeeSat: 0, + }; + } + } + /** * Submits an order to the market * @param type the type of order (bid or ask) diff --git a/app/src/store/views/orderFormView.ts b/app/src/store/views/orderFormView.ts index 89c2f03ea..63ef85179 100644 --- a/app/src/store/views/orderFormView.ts +++ b/app/src/store/views/orderFormView.ts @@ -4,6 +4,7 @@ import { NodeTier, } from 'types/generated/auctioneerrpc/auctioneer_pb'; import { LeaseDuration } from 'types/state'; +import debounce from 'lodash/debounce'; import { annualPercentRate, toBasisPoints, toPercent } from 'util/bigmath'; import { BLOCKS_PER_DAY } from 'util/constants'; import { prefixTranslation } from 'util/translate'; @@ -31,6 +32,11 @@ export default class OrderFormView { /** toggle to show or hide the additional options */ addlOptionsVisible = false; + /** quoted fees */ + executionFee = 0; + worstChainFee = 0; + quoteLoading = false; + constructor(store: Store) { makeAutoObservable(this, {}, { deep: false, autoBind: true }); @@ -184,22 +190,27 @@ export default class OrderFormView { setAmount(amount: number) { this.amount = amount; + this.fetchQuote(); } setPremium(premium: number) { this.premium = premium; + this.fetchQuote(); } setDuration(duration: LeaseDuration) { this.duration = duration; + this.fetchQuote(); } setMinChanSize(minChanSize: number) { this.minChanSize = minChanSize; + this.fetchQuote(); } setMaxBatchFeeRate(feeRate: number) { this.maxBatchFeeRate = feeRate; + this.fetchQuote(); } setMinNodeTier(minNodeTier: Tier) { @@ -220,6 +231,7 @@ export default class OrderFormView { const suggested = this.amount * prevPctRate; // round to the nearest 10 to offset lose of precision in calculating percentages this.premium = Math.round(suggested / 10) * 10; + this.fetchQuote(); } catch (error) { this._store.appView.handleError(error, 'Unable to suggest premium'); } @@ -229,6 +241,51 @@ export default class OrderFormView { this.addlOptionsVisible = !this.addlOptionsVisible; } + /** requests a quote for an order to obtain accurate fees */ + async quoteOrder() { + const minUnitsMatch = Math.floor(this.minChanSize / ONE_UNIT); + const satsPerKWeight = this._store.api.pool.satsPerVByteToKWeight( + this.maxBatchFeeRate, + ); + + const { + totalExecutionFeeSat, + worstCaseChainFeeSat, + } = await this._store.orderStore.quoteOrder( + this.amount, + this.perBlockFixedRate, + this.derivedDuration, + minUnitsMatch, + satsPerKWeight, + ); + + runInAction(() => { + this.executionFee = totalExecutionFeeSat; + this.worstChainFee = worstCaseChainFeeSat; + this.quoteLoading = false; + }); + } + + /** quote order at most once every second when using this func */ + quoteOrderThrottled = debounce(this.quoteOrder, 1000); + + /** + * sets the quoteLoading flag before while waiting for the throttled quote + * request to complete + */ + fetchQuote() { + if (!this.isValid) { + runInAction(() => { + this.executionFee = 0; + this.worstChainFee = 0; + this.quoteLoading = false; + }); + return; + } + this.quoteLoading = true; + this.quoteOrderThrottled(); + } + /** submits the order to the API and resets the form values if successful */ async placeOrder() { const minUnitsMatch = Math.floor(this.minChanSize / ONE_UNIT); @@ -249,6 +306,8 @@ export default class OrderFormView { this.amount = 0; this.premium = 0; this.duration = 0; + this.executionFee = 0; + this.worstChainFee = 0; // persist the additional options so they can be used for future orders this._store.settingsStore.setOrderSettings( this.minChanSize, diff --git a/app/src/util/tests/sampleData.ts b/app/src/util/tests/sampleData.ts index 51eb19d00..ea2a7033c 100644 --- a/app/src/util/tests/sampleData.ts +++ b/app/src/util/tests/sampleData.ts @@ -542,6 +542,14 @@ export const poolListOrders: POOL.ListOrdersResponse.AsObject = { ], }; +export const poolQuoteOrder: POOL.QuoteOrderResponse.AsObject = { + ratePerBlock: 0.00000248, + ratePercent: 0.000248, + totalExecutionFeeSat: 5001, + totalPremiumSat: 24998, + worstCaseChainFeeSat: 40810, +}; + export const poolSubmitOrder: POOL.SubmitOrderResponse.AsObject = { acceptedOrderNonce: 'W4XLkXhEKMcKfzV+Ex+jXQJeaVXoCoKQzptMRi6g+ZA=', }; @@ -811,6 +819,7 @@ export const sampleApiResponses: Record = { 'poolrpc.Trader.DepositAccount': poolDepositAccount, 'poolrpc.Trader.WithdrawAccount': poolWithdrawAccount, 'poolrpc.Trader.ListOrders': poolListOrders, + 'poolrpc.Trader.QuoteOrder': poolQuoteOrder, 'poolrpc.Trader.SubmitOrder': poolSubmitOrder, 'poolrpc.Trader.CancelOrder': poolCancelOrder, 'poolrpc.Trader.BatchSnapshot': poolBatchSnapshot,