From 699f741b8a84ae16b19465c5814bc388986c9c80 Mon Sep 17 00:00:00 2001 From: "Jason M. Hasperhoven" Date: Mon, 16 Dec 2024 20:23:02 +0400 Subject: [PATCH] Limit Orders (#181) * Add limit order form * Construct limit order position * Refactor order form stores & introduce pnum util * Update pnum description * Use remote pnum * Fix lint issues * Fix handling of input fields numbers * tmp commit * Convert to base units * Start fleshing out shared position logic * Add tests for position math * Integrate position logic into range liquidity form * Define limit order position function * Add some documentation to position math * Wire up limit position form * Remove slider from limit order form * Remove key error * Apply same p,q,r1,r2 values as rust code * Construct positions taking into account asset order * Have position plans take in display units for reserves * Fix limit order amount handling + gas fee calc * Fix display amounts for range liquidity form * Bug: don't display weird text in order input * Define PriceLinkedInputs store This store will be very useful for market and limit orders, and already addresses some yet unreported sources of UX oddities, like the inputs not updating with the price. * Define new store for MarketOrderForm * Move AssetInfo to separate file * Add store for limit order position * Add RangeOrderFormStore * Basic wiring of form refactor * Implement order submission * Fix range liquidity fee tier selection * Correct range liquidity * Gas fee calculation, remove unused stuff * Fix remaining lints * Correct order of p and q in position construction * Use a more robust method of getting p and q in range This should handle cases where they're out of range by losing only the necessary precision, and it should handle cases where the price gets flattened to 0 * Remove wheel input changes * Fix market slider input change * Refactor setter methods for mobx strict mode, fix rerenders * Fix hydration issue caused by connectionStore.connected * Fix inputs disappearing * Add active states to select groups * Remove setup connection hoc * Wrapp app with observer --------- Co-authored-by: Lucas Meier --- app/app.tsx | 13 +- src/pages/trade/model/AssetInfo.ts | 57 ++ src/pages/trade/model/useMarketPrice.ts | 9 + src/pages/trade/ui/form-tabs.tsx | 39 +- .../trade/ui/order-form/info-row-gas-fee.tsx | 20 + .../ui/order-form/info-row-trading-fee.tsx | 12 + src/pages/trade/ui/order-form/info-row.tsx | 2 +- .../trade/ui/order-form/order-form-limit.tsx | 89 ++++ .../trade/ui/order-form/order-form-market.tsx | 121 +++-- .../order-form/order-form-range-liquidity.tsx | 284 +++++----- src/pages/trade/ui/order-form/order-input.tsx | 14 +- .../trade/ui/order-form/segmented-control.tsx | 13 +- src/pages/trade/ui/order-form/slider.tsx | 40 -- .../order-form/store/LimitOrderFormStore.ts | 154 ++++++ .../order-form/store/MarketOrderFormStore.ts | 256 +++++++++ .../ui/order-form/store/OrderFormStore.ts | 345 ++++++++++++ .../store/PriceLinkedInputs.test.ts | 38 ++ .../ui/order-form/store/PriceLinkedInputs.ts | 75 +++ .../order-form/store/RangeOrderFormStore.ts | 242 +++++++++ src/pages/trade/ui/order-form/store/asset.ts | 98 ---- src/pages/trade/ui/order-form/store/index.ts | 504 ------------------ .../ui/order-form/store/range-liquidity.ts | 131 ----- src/pages/trade/ui/page.tsx | 42 +- src/pages/trade/ui/pair-selector.tsx | 10 +- src/pages/trade/ui/trade-row.tsx | 2 +- src/shared/api/balances.ts | 9 +- src/shared/math/position.test.ts | 77 +++ src/shared/math/position.ts | 223 ++++++++ src/shared/model/connection/index.ts | 4 - src/shared/utils/num.ts | 5 + 30 files changed, 1921 insertions(+), 1007 deletions(-) create mode 100644 src/pages/trade/model/AssetInfo.ts create mode 100644 src/pages/trade/model/useMarketPrice.ts create mode 100644 src/pages/trade/ui/order-form/info-row-gas-fee.tsx create mode 100644 src/pages/trade/ui/order-form/info-row-trading-fee.tsx create mode 100644 src/pages/trade/ui/order-form/order-form-limit.tsx delete mode 100644 src/pages/trade/ui/order-form/slider.tsx create mode 100644 src/pages/trade/ui/order-form/store/LimitOrderFormStore.ts create mode 100644 src/pages/trade/ui/order-form/store/MarketOrderFormStore.ts create mode 100644 src/pages/trade/ui/order-form/store/OrderFormStore.ts create mode 100644 src/pages/trade/ui/order-form/store/PriceLinkedInputs.test.ts create mode 100644 src/pages/trade/ui/order-form/store/PriceLinkedInputs.ts create mode 100644 src/pages/trade/ui/order-form/store/RangeOrderFormStore.ts delete mode 100644 src/pages/trade/ui/order-form/store/asset.ts delete mode 100644 src/pages/trade/ui/order-form/store/index.ts delete mode 100644 src/pages/trade/ui/order-form/store/range-liquidity.ts create mode 100644 src/shared/math/position.test.ts create mode 100644 src/shared/math/position.ts create mode 100644 src/shared/utils/num.ts diff --git a/app/app.tsx b/app/app.tsx index 4dcf1834..2a0b6dbe 100644 --- a/app/app.tsx +++ b/app/app.tsx @@ -1,18 +1,23 @@ 'use client'; -import { ReactNode } from 'react'; -import { enableStaticRendering } from 'mobx-react-lite'; +import { ReactNode, useEffect } from 'react'; +import { enableStaticRendering, observer } from 'mobx-react-lite'; import { QueryClientProvider } from '@tanstack/react-query'; import { ToastProvider } from '@penumbra-zone/ui/Toast'; import { TooltipProvider } from '@penumbra-zone/ui/Tooltip'; import { Header, SyncBar } from '@/widgets/header'; import { queryClient } from '@/shared/const/queryClient'; +import { connectionStore } from '@/shared/model/connection'; // Used so that observer() won't subscribe to any observables used in an SSR environment // and no garbage collection problems are introduced. enableStaticRendering(typeof window === 'undefined'); -export const App = ({ children }: { children: ReactNode }) => { +export const App = observer(({ children }: { children: ReactNode }) => { + useEffect(() => { + connectionStore.setup(); + }, []); + return ( @@ -25,4 +30,4 @@ export const App = ({ children }: { children: ReactNode }) => { ); -}; +}); diff --git a/src/pages/trade/model/AssetInfo.ts b/src/pages/trade/model/AssetInfo.ts new file mode 100644 index 00000000..22e93b7a --- /dev/null +++ b/src/pages/trade/model/AssetInfo.ts @@ -0,0 +1,57 @@ +import { AssetId, Metadata, Value } from '@penumbra-zone/protobuf/penumbra/core/asset/v1/asset_pb'; +import { Amount } from '@penumbra-zone/protobuf/penumbra/core/num/v1/num_pb'; +import { pnum } from '@penumbra-zone/types/pnum'; + +/** A basic utility class containing information we need about an asset. + * + * This extracts out the useful components we might need for the current + * asset. + */ +export class AssetInfo { + /** + * @param balance the balance, in display units. + * @param the exponent to convert from base units to display units. + */ + constructor( + public id: AssetId, + public exponent: number, + public symbol: string, + public balance?: number, + ) {} + + static fromMetadata(metadata: Metadata, balance?: Amount): undefined | AssetInfo { + const displayDenom = metadata.denomUnits.find(x => x.denom === metadata.display); + if (!displayDenom || !metadata.penumbraAssetId) { + return undefined; + } + return new AssetInfo( + metadata.penumbraAssetId, + displayDenom.exponent, + metadata.symbol, + balance && pnum(balance, displayDenom.exponent).toNumber(), + ); + } + + /** Convert an amount, in display units, into a Value (of this asset). */ + value(display: number): Value { + return new Value({ + amount: pnum(display, this.exponent).toAmount(), + assetId: this.id, + }); + } + + /** Format an amount (in display units) as a simple string. */ + formatDisplayAmount(amount: number): string { + const amountString = pnum(amount, this.exponent).toFormattedString({ + commas: true, + decimals: 4, + trailingZeros: false, + }); + return `${amountString} ${this.symbol}`; + } + + /** Format the balance of this asset as a simple string. */ + formatBalance(): undefined | string { + return this.balance !== undefined ? this.formatDisplayAmount(this.balance) : undefined; + } +} diff --git a/src/pages/trade/model/useMarketPrice.ts b/src/pages/trade/model/useMarketPrice.ts new file mode 100644 index 00000000..450949a2 --- /dev/null +++ b/src/pages/trade/model/useMarketPrice.ts @@ -0,0 +1,9 @@ +import { useSummary } from './useSummary'; + +export const useMarketPrice = () => { + const { data: summary } = useSummary('1d'); + if (!summary || 'noData' in summary) { + return undefined; + } + return summary.price; +}; diff --git a/src/pages/trade/ui/form-tabs.tsx b/src/pages/trade/ui/form-tabs.tsx index a4573d45..104993ca 100644 --- a/src/pages/trade/ui/form-tabs.tsx +++ b/src/pages/trade/ui/form-tabs.tsx @@ -1,44 +1,41 @@ -import { useState } from 'react'; import { useAutoAnimate } from '@formkit/auto-animate/react'; import { Tabs } from '@penumbra-zone/ui/Tabs'; import { Density } from '@penumbra-zone/ui/Density'; import { MarketOrderForm } from './order-form/order-form-market'; +import { LimitOrderForm } from './order-form/order-form-limit'; import { RangeLiquidityOrderForm } from './order-form/order-form-range-liquidity'; +import { isWhichForm, useOrderFormStore } from './order-form/store/OrderFormStore'; +import { observer } from 'mobx-react-lite'; -enum FormTabsType { - Market = 'market', - Limit = 'limit', - Range = 'range', -} - -export const FormTabs = () => { +export const FormTabs = observer(() => { const [parent] = useAutoAnimate(); - const [tab, setTab] = useState(FormTabsType.Market); + const store = useOrderFormStore(); return (
setTab(value as FormTabsType)} + onChange={value => { + if (isWhichForm(value)) { + store.setWhichForm(value); + } + }} options={[ - { value: FormTabsType.Market, label: 'Market' }, - { value: FormTabsType.Limit, label: 'Limit' }, - { value: FormTabsType.Range, label: 'Range Liquidity' }, + { value: 'Market', label: 'Market' }, + { value: 'Limit', label: 'Limit' }, + { value: 'Range', label: 'Range Liquidity' }, ]} />
-
- {tab === FormTabsType.Market && } - {tab === FormTabsType.Limit && ( -
Limit order form
- )} - {tab === FormTabsType.Range && } + {store.whichForm === 'Market' && } + {store.whichForm === 'Limit' && } + {store.whichForm === 'Range' && }
); -}; +}); diff --git a/src/pages/trade/ui/order-form/info-row-gas-fee.tsx b/src/pages/trade/ui/order-form/info-row-gas-fee.tsx new file mode 100644 index 00000000..cb06a330 --- /dev/null +++ b/src/pages/trade/ui/order-form/info-row-gas-fee.tsx @@ -0,0 +1,20 @@ +import { InfoRow } from './info-row'; + +export const InfoRowGasFee = ({ + gasFee, + symbol, + isLoading, +}: { + gasFee: string; + symbol: string; + isLoading: boolean; +}) => { + return ( + + ); +}; diff --git a/src/pages/trade/ui/order-form/info-row-trading-fee.tsx b/src/pages/trade/ui/order-form/info-row-trading-fee.tsx new file mode 100644 index 00000000..d1f4f8a4 --- /dev/null +++ b/src/pages/trade/ui/order-form/info-row-trading-fee.tsx @@ -0,0 +1,12 @@ +import { InfoRow } from './info-row'; + +export const InfoRowTradingFee = () => { + return ( + + ); +}; diff --git a/src/pages/trade/ui/order-form/info-row.tsx b/src/pages/trade/ui/order-form/info-row.tsx index 0a1f8068..b3038453 100644 --- a/src/pages/trade/ui/order-form/info-row.tsx +++ b/src/pages/trade/ui/order-form/info-row.tsx @@ -20,7 +20,7 @@ const getValueColor = (valueColor: InfoRowProps['valueColor']) => { if (valueColor === 'error') { return 'destructive.main'; } - return 'text.secondary'; + return 'text.primary'; }; export const InfoRow = observer( diff --git a/src/pages/trade/ui/order-form/order-form-limit.tsx b/src/pages/trade/ui/order-form/order-form-limit.tsx new file mode 100644 index 00000000..f518486c --- /dev/null +++ b/src/pages/trade/ui/order-form/order-form-limit.tsx @@ -0,0 +1,89 @@ +import { observer } from 'mobx-react-lite'; +import { Button } from '@penumbra-zone/ui/Button'; +import { Text } from '@penumbra-zone/ui/Text'; +import { connectionStore } from '@/shared/model/connection'; +import { OrderInput } from './order-input'; +import { SegmentedControl } from './segmented-control'; +import { ConnectButton } from '@/features/connect/connect-button'; +import { InfoRowTradingFee } from './info-row-trading-fee'; +import { InfoRowGasFee } from './info-row-gas-fee'; +import { SelectGroup } from './select-group'; +import { OrderFormStore } from './store/OrderFormStore'; +import { BuyLimitOrderOptions, SellLimitOrderOptions } from './store/LimitOrderFormStore'; + +export const LimitOrderForm = observer(({ parentStore }: { parentStore: OrderFormStore }) => { + const { connected } = connectionStore; + const store = parentStore.limitForm; + + const isBuy = store.direction === 'buy'; + + return ( +
+ +
+
+ store.setPriceInput(price)} + denominator={store.quoteAsset?.symbol} + /> +
+ + store.setPriceInputOption(option as BuyLimitOrderOptions | SellLimitOrderOptions) + } + /> +
+
+ +
+
+ +
+
+ + +
+
+ {connected ? ( + + ) : ( + + )} +
+ {parentStore.marketPrice && ( +
+ + 1 {store.baseAsset?.symbol} ={' '} + + {store.quoteAsset?.formatDisplayAmount(parentStore.marketPrice)} + + +
+ )} +
+ ); +}); diff --git a/src/pages/trade/ui/order-form/order-form-market.tsx b/src/pages/trade/ui/order-form/order-form-market.tsx index 1401eb51..2536a7e6 100644 --- a/src/pages/trade/ui/order-form/order-form-market.tsx +++ b/src/pages/trade/ui/order-form/order-form-market.tsx @@ -5,85 +5,114 @@ import { connectionStore } from '@/shared/model/connection'; import { OrderInput } from './order-input'; import { SegmentedControl } from './segmented-control'; import { ConnectButton } from '@/features/connect/connect-button'; -import { Slider } from './slider'; -import { InfoRow } from './info-row'; -import { useOrderFormStore, FormType, Direction } from './store'; +import { InfoRowGasFee } from './info-row-gas-fee'; +import { InfoRowTradingFee } from './info-row-trading-fee'; +import { OrderFormStore } from './store/OrderFormStore'; +import { Slider as PenumbraSlider } from '@penumbra-zone/ui/Slider'; -export const MarketOrderForm = observer(() => { +interface SliderProps { + inputValue: string; + balance?: number; + balanceDisplay?: string; + setBalanceFraction: (fraction: number) => void; +} +const Slider = observer( + ({ inputValue, balance, balanceDisplay, setBalanceFraction }: SliderProps) => { + const value = + inputValue && balance ? Math.round((Number(inputValue) / Number(balance)) * 10) : 0; + + return ( +
+
+ setBalanceFraction(x / 10)} + showTrackGaps={true} + trackGapBackground='base.black' + showFill={true} + /> +
+
+ + Available Balance + + +
+
+ ); + }, +); + +export const MarketOrderForm = observer(({ parentStore }: { parentStore: OrderFormStore }) => { const { connected } = connectionStore; - const { - baseAsset, - quoteAsset, - direction, - setDirection, - submitOrder, - isLoading, - gasFee, - exchangeRate, - } = useOrderFormStore(FormType.Market); + const store = parentStore.marketForm; - const isBuy = direction === Direction.Buy; + const isBuy = store.direction === 'buy'; return (
- +
baseAsset.setAmount(amount)} - min={0} - max={1000} - isEstimating={isBuy ? baseAsset.isEstimating : false} + label={isBuy ? 'Buy' : 'Sell'} + value={store.baseInput} + onChange={store.setBaseInput} + isEstimating={store.baseEstimating} isApproximately={isBuy} - denominator={baseAsset.symbol} + denominator={store.baseAsset?.symbol} />
quoteAsset.setAmount(amount)} - isEstimating={isBuy ? false : quoteAsset.isEstimating} + value={store.quoteInput} + onChange={store.setQuoteInput} + isEstimating={store.quoteEstimating} isApproximately={!isBuy} - denominator={quoteAsset.symbol} + denominator={store.quoteAsset?.symbol} />
- + store.setBalanceFraction(x)} + />
- - +
{connected ? ( ) : ( )}
- {exchangeRate !== null && ( + {parentStore.marketPrice && (
- 1 {baseAsset.symbol} ={' '} + 1 {store.baseAsset?.symbol} ={' '} - {exchangeRate} {quoteAsset.symbol} + {store.quoteAsset?.formatDisplayAmount(parentStore.marketPrice)}
diff --git a/src/pages/trade/ui/order-form/order-form-range-liquidity.tsx b/src/pages/trade/ui/order-form/order-form-range-liquidity.tsx index ba5512ad..b3b9a55f 100644 --- a/src/pages/trade/ui/order-form/order-form-range-liquidity.tsx +++ b/src/pages/trade/ui/order-form/order-form-range-liquidity.tsx @@ -1,154 +1,176 @@ import { observer } from 'mobx-react-lite'; -import { useEffect } from 'react'; import { Button } from '@penumbra-zone/ui/Button'; import { Text } from '@penumbra-zone/ui/Text'; import { Slider as PenumbraSlider } from '@penumbra-zone/ui/Slider'; import { connectionStore } from '@/shared/model/connection'; import { ConnectButton } from '@/features/connect/connect-button'; -import { useSummary } from '../../model/useSummary'; import { OrderInput } from './order-input'; import { SelectGroup } from './select-group'; import { InfoRow } from './info-row'; -import { useOrderFormStore, FormType } from './store'; -import { UpperBoundOptions, LowerBoundOptions, FeeTierOptions } from './store/range-liquidity'; +import { InfoRowGasFee } from './info-row-gas-fee'; +import { OrderFormStore } from './store/OrderFormStore'; +import { + MAX_POSITION_COUNT, + MIN_POSITION_COUNT, + UpperBoundOptions, + LowerBoundOptions, + FeeTierOptions, +} from './store/RangeOrderFormStore'; -export const RangeLiquidityOrderForm = observer(() => { - const { connected } = connectionStore; - const { baseAsset, quoteAsset, rangeLiquidity, submitOrder, isLoading, gasFee, exchangeRate } = - useOrderFormStore(FormType.RangeLiquidity); - const { data } = useSummary('1d'); - const price = data && 'price' in data ? data.price : undefined; +export const RangeLiquidityOrderForm = observer( + ({ parentStore }: { parentStore: OrderFormStore }) => { + const { connected } = connectionStore; + const store = parentStore.rangeForm; - useEffect(() => { - if (price) { - rangeLiquidity.setMarketPrice(price); - } - }, [price, rangeLiquidity]); - - useEffect(() => { - if (quoteAsset.exponent) { - rangeLiquidity.setExponent(quoteAsset.exponent); - } - }, [quoteAsset.exponent, rangeLiquidity]); - - return ( -
-
-
- quoteAsset.setAmount(amount)} - denominator={quoteAsset.symbol} + return ( +
+
+
+ +
+
+
+ + Available Balances + +
+
+
+ + {store.baseAsset?.formatBalance() ?? `-- ${store.baseAsset?.symbol}`} + +
+ +
+
+
+
+
+ store.setUpperPriceInput(price)} + denominator={store.quoteAsset?.symbol} + /> +
+ store.setUpperPriceInputOption(option as UpperBoundOptions)} />
-
- - Available Balance - - +
+
+ store.setLowerPriceInput(price)} + denominator={store.quoteAsset?.symbol} + /> +
+ store.setLowerPriceInputOption(option as LowerBoundOptions)} + />
-
-
-
- +
+ store.setFeeTierPercentInput(amount)} + denominator='%' + /> +
+ store.setFeeTierPercentInputOption(option as FeeTierOptions)} />
- rangeLiquidity.setUpperBoundOption(option as UpperBoundOptions)} - /> -
-
-
+
+
- rangeLiquidity.setLowerBoundOption(option as LowerBoundOptions)} - /> -
-
-
- + + + +
- void} - /> -
-
- - -
-
- - - - -
-
- {connected ? ( - - ) : ( - +
+ {connected ? ( + + ) : ( + + )} +
+ {parentStore.marketPrice && ( +
+ + 1 {store.baseAsset?.symbol} ={' '} + + {store.quoteAsset?.formatDisplayAmount(parentStore.marketPrice)} + + +
)}
- {exchangeRate !== null && ( -
- - 1 {baseAsset.symbol} ={' '} - - {exchangeRate} {quoteAsset.symbol} - - -
- )} -
- ); -}); + ); + }, +); diff --git a/src/pages/trade/ui/order-form/order-input.tsx b/src/pages/trade/ui/order-form/order-input.tsx index e6bd3441..3b50dc2d 100644 --- a/src/pages/trade/ui/order-form/order-input.tsx +++ b/src/pages/trade/ui/order-form/order-input.tsx @@ -9,7 +9,7 @@ import cn from 'clsx'; export interface OrderInputProps { id?: string; label: string; - value?: number; + value: string; placeholder?: string; isEstimating?: boolean; isApproximately?: boolean; @@ -72,7 +72,7 @@ export const OrderInput = forwardRef( <>
{value}
@@ -81,15 +81,19 @@ export const OrderInput = forwardRef( 'w-full appearance-none border-none bg-transparent', 'rounded-sm text-text-primary transition-colors duration-150', 'p-2 pt-7', - isApproximately ? 'pl-7' : 'pl-3', + isApproximately && value ? 'pl-7' : 'pl-3', 'font-default text-textLg font-medium leading-textLg', 'hover:bg-other-tonalFill5 focus:outline-none focus:bg-other-tonalFill10', '[&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none', "[&[type='number']]:[-moz-appearance:textfield]", )} style={{ paddingRight: denomWidth + 20 }} - value={value ?? ''} + value={value} onChange={e => onChange?.(e.target.value)} + onWheel={e => { + // Remove focus to prevent scroll changes + (e.target as HTMLInputElement).blur(); + }} placeholder={placeholder} type='number' max={max} @@ -103,7 +107,7 @@ export const OrderInput = forwardRef( ≈
- +
diff --git a/src/pages/trade/ui/order-form/segmented-control.tsx b/src/pages/trade/ui/order-form/segmented-control.tsx index f2b4696a..53e9392c 100644 --- a/src/pages/trade/ui/order-form/segmented-control.tsx +++ b/src/pages/trade/ui/order-form/segmented-control.tsx @@ -1,10 +1,9 @@ import React from 'react'; -import { Direction } from './store'; import cn from 'clsx'; export const SegmentedControl: React.FC<{ - direction: Direction; - setDirection: (direction: Direction) => void; + direction: 'buy' | 'sell'; + setDirection: (direction: 'buy' | 'sell') => void; }> = ({ direction, setDirection }) => { return (
@@ -12,11 +11,11 @@ export const SegmentedControl: React.FC<{ className={cn( 'flex-1 border transition-colors duration-300 rounded-l-2xl focus:outline-none', 'border-r-0 border-other-tonalStroke', - direction === Direction.Buy + direction === 'buy' ? 'bg-success-main border-success-main text-text-primary' : 'bg-transparent text-text-secondary', )} - onClick={() => setDirection(Direction.Buy)} + onClick={() => setDirection('buy')} > Buy @@ -24,11 +23,11 @@ export const SegmentedControl: React.FC<{ className={cn( 'flex-1 border transition-colors duration-300 rounded-r-2xl focus:outline-none', 'border-l-0 border-other-tonalStroke', - direction === Direction.Sell + direction === 'sell' ? 'bg-destructive-main border-destructive-main text-text-primary' : 'bg-transparent text-text-secondary', )} - onClick={() => setDirection(Direction.Sell)} + onClick={() => setDirection('sell')} > Sell diff --git a/src/pages/trade/ui/order-form/slider.tsx b/src/pages/trade/ui/order-form/slider.tsx deleted file mode 100644 index d63b731b..00000000 --- a/src/pages/trade/ui/order-form/slider.tsx +++ /dev/null @@ -1,40 +0,0 @@ -import { observer } from 'mobx-react-lite'; -import { connectionStore } from '@/shared/model/connection'; -import { Slider as PenumbraSlider } from '@penumbra-zone/ui/Slider'; -import { Text } from '@penumbra-zone/ui/Text'; -import { OrderFormAsset } from './store/asset'; - -export const Slider = observer(({ asset, steps }: { asset: OrderFormAsset; steps: number }) => { - const { connected } = connectionStore; - return ( -
-
- -
-
- - Available Balance - - -
-
- ); -}); diff --git a/src/pages/trade/ui/order-form/store/LimitOrderFormStore.ts b/src/pages/trade/ui/order-form/store/LimitOrderFormStore.ts new file mode 100644 index 00000000..bdb79104 --- /dev/null +++ b/src/pages/trade/ui/order-form/store/LimitOrderFormStore.ts @@ -0,0 +1,154 @@ +import { Position } from '@penumbra-zone/protobuf/penumbra/core/component/dex/v1/dex_pb'; +import { PriceLinkedInputs } from './PriceLinkedInputs'; +import { limitOrderPosition } from '@/shared/math/position'; +import { makeAutoObservable, reaction } from 'mobx'; +import { AssetInfo } from '@/pages/trade/model/AssetInfo'; +import { parseNumber } from '@/shared/utils/num'; + +export type Direction = 'buy' | 'sell'; + +export enum SellLimitOrderOptions { + Market = 'Market', + Plus2Percent = '+2%', + Plus5Percent = '+5%', + Plus10Percent = '+10%', + Plus15Percent = '+15%', +} + +export enum BuyLimitOrderOptions { + Market = 'Market', + Minus2Percent = '-2%', + Minus5Percent = '-5%', + Minus10Percent = '-10%', + Minus15Percent = '-15%', +} + +export const BuyLimitOrderMultipliers = { + [BuyLimitOrderOptions.Market]: 1, + [BuyLimitOrderOptions.Minus2Percent]: 0.98, + [BuyLimitOrderOptions.Minus5Percent]: 0.95, + [BuyLimitOrderOptions.Minus10Percent]: 0.9, + [BuyLimitOrderOptions.Minus15Percent]: 0.85, +}; + +export const SellLimitOrderMultipliers = { + [SellLimitOrderOptions.Market]: 1, + [SellLimitOrderOptions.Plus2Percent]: 1.02, + [SellLimitOrderOptions.Plus5Percent]: 1.05, + [SellLimitOrderOptions.Plus10Percent]: 1.1, + [SellLimitOrderOptions.Plus15Percent]: 1.15, +}; + +export class LimitOrderFormStore { + private _baseAsset?: AssetInfo; + private _quoteAsset?: AssetInfo; + private _input = new PriceLinkedInputs(); + direction: Direction = 'buy'; + marketPrice = 1.0; + private _priceInput = ''; + private _priceInputOption: SellLimitOrderOptions | BuyLimitOrderOptions | undefined; + + constructor() { + makeAutoObservable(this); + + reaction(() => [this.direction], this._resetInputs); + } + + private _resetInputs = () => { + this._input.inputA = ''; + this._input.inputB = ''; + this._priceInput = ''; + }; + + setDirection = (x: Direction) => { + this.direction = x; + }; + + get baseAsset(): undefined | AssetInfo { + return this._baseAsset; + } + + get quoteAsset(): undefined | AssetInfo { + return this._quoteAsset; + } + + get baseInput(): string { + return this._input.inputA; + } + + setBaseInput = (x: string) => { + this._input.inputA = x; + }; + + get quoteInput(): string { + return this._input.inputB; + } + + setQuoteInput = (x: string) => { + this._input.inputB = x; + }; + + get priceInput(): string { + return this._priceInput; + } + + get priceInputOption(): SellLimitOrderOptions | BuyLimitOrderOptions | undefined { + return this._priceInputOption; + } + + setPriceInput = (x: string, fromOption = false) => { + this._priceInput = x; + const price = this.price; + if (price !== undefined) { + this._input.price = price; + } + if (!fromOption) { + this._priceInputOption = undefined; + } + }; + + setPriceInputOption = (option: SellLimitOrderOptions | BuyLimitOrderOptions) => { + this._priceInputOption = option; + const multiplier = + this.direction === 'buy' + ? BuyLimitOrderMultipliers[option as BuyLimitOrderOptions] + : SellLimitOrderMultipliers[option as SellLimitOrderOptions]; + + if (!multiplier) { + return; + } + + const price = multiplier * this.marketPrice; + this.setPriceInput(price.toString(), true); + }; + + get price(): number | undefined { + return parseNumber(this._priceInput); + } + + get plan(): Position | undefined { + const input = + this.direction === 'buy' ? parseNumber(this.quoteInput) : parseNumber(this.baseInput); + if (!input || !this._baseAsset || !this._quoteAsset || !this.price) { + return undefined; + } + return limitOrderPosition({ + buy: this.direction, + price: this.price, + input, + baseAsset: this._baseAsset, + quoteAsset: this._quoteAsset, + }); + } + + setAssets(base: AssetInfo, quote: AssetInfo, resetInputs = false) { + this._baseAsset = base; + this._quoteAsset = quote; + if (resetInputs) { + this._input.inputA = ''; + this._input.inputB = ''; + this._priceInput = ''; + this._priceInputOption = undefined; + } + } +} diff --git a/src/pages/trade/ui/order-form/store/MarketOrderFormStore.ts b/src/pages/trade/ui/order-form/store/MarketOrderFormStore.ts new file mode 100644 index 00000000..2a862da9 --- /dev/null +++ b/src/pages/trade/ui/order-form/store/MarketOrderFormStore.ts @@ -0,0 +1,256 @@ +import { makeAutoObservable, reaction, runInAction } from 'mobx'; +import { AssetId, Value } from '@penumbra-zone/protobuf/penumbra/core/asset/v1/asset_pb'; +import { pnum } from '@penumbra-zone/types/pnum'; +import debounce from 'lodash/debounce'; +import { parseNumber } from '@/shared/utils/num'; +import { penumbra } from '@/shared/const/penumbra'; +import { SimulationService } from '@penumbra-zone/protobuf'; +import { SimulateTradeRequest } from '@penumbra-zone/protobuf/penumbra/core/component/dex/v1/dex_pb'; +import { openToast } from '@penumbra-zone/ui/Toast'; +import { AssetInfo } from '@/pages/trade/model/AssetInfo'; + +const estimateAmount = async ( + from: AssetInfo, + to: AssetInfo, + input: number, +): Promise => { + try { + const req = new SimulateTradeRequest({ + input: from.value(input), + output: to.id, + }); + + const res = await penumbra.service(SimulationService).simulateTrade(req); + + const amount = res.output?.output?.amount; + if (amount === undefined) { + throw new Error('Amount returned from swap simulation was undefined'); + } + return pnum(amount, to.exponent).toNumber(); + } catch (e) { + if ( + e instanceof Error && + ![ + 'ConnectError', + 'PenumbraNotInstalledError', + 'PenumbraProviderNotAvailableError', + 'PenumbraProviderNotConnectedError', + ].includes(e.name) + ) { + openToast({ + type: 'error', + message: e.name, + description: e.message, + }); + } + return undefined; + } +}; + +export type Direction = 'buy' | 'sell'; + +export type LastEdited = 'Base' | 'Quote'; + +// When we need to use an estimate call, avoid triggering it for this many milliseconds +// to avoid jitter as the user types. +const ESTIMATE_DEBOUNCE_MS = 160; + +export interface MarketOrderPlan { + targetAsset: AssetId; + value: Value; +} + +export class MarketOrderFormStore { + private _baseAsset?: AssetInfo; + private _quoteAsset?: AssetInfo; + private _baseAssetInput = ''; + private _quoteAssetInput = ''; + private _baseEstimating = false; + private _quoteEstimating = false; + direction: Direction = 'buy'; + private _lastEdited: LastEdited = 'Base'; + + constructor() { + makeAutoObservable(this); + + // Two reactions to avoid a double trigger. + reaction( + () => [this._lastEdited, this._baseAssetInput, this._baseAsset, this._quoteAsset], + debounce(() => { + void this.estimateQuote(); + }, ESTIMATE_DEBOUNCE_MS), + ); + reaction( + () => [this._lastEdited, this._quoteAssetInput, this._baseAsset, this._quoteAsset], + debounce(() => { + void this.estimateBase(); + }, ESTIMATE_DEBOUNCE_MS), + ); + } + + private estimateQuote = async (): Promise => { + if (!this._baseAsset || !this._quoteAsset || this._lastEdited !== 'Base') { + return; + } + const input = this.baseInputAmount; + if (input === undefined) { + return; + } + runInAction(() => { + this._quoteEstimating = true; + }); + try { + const res = await estimateAmount(this._quoteAsset, this._baseAsset, input); + if (res === undefined) { + return; + } + runInAction(() => { + this._quoteAssetInput = res.toString(); + }); + } finally { + runInAction(() => { + this._quoteEstimating = false; + }); + } + }; + + private estimateBase = async (): Promise => { + if (!this._baseAsset || !this._quoteAsset || this._lastEdited !== 'Quote') { + return; + } + const input = this.quoteInputAmount; + if (input === undefined) { + return; + } + runInAction(() => { + this._baseEstimating = true; + }); + try { + const res = await estimateAmount(this._baseAsset, this._quoteAsset, input); + if (res === undefined) { + return; + } + runInAction(() => { + this._baseAssetInput = res.toString(); + }); + } finally { + runInAction(() => { + this._baseEstimating = false; + }); + } + }; + + setDirection = (x: Direction) => { + this.direction = x; + }; + + get baseInput(): string { + return this._baseAssetInput; + } + + setBaseInput = (x: string) => { + this._lastEdited = 'Base'; + this._baseAssetInput = x; + }; + + get quoteInput(): string { + return this._quoteAssetInput; + } + + setQuoteInput = (x: string) => { + this._lastEdited = 'Quote'; + this._quoteAssetInput = x; + }; + + get baseInputAmount(): undefined | number { + return parseNumber(this._baseAssetInput); + } + + get quoteInputAmount(): undefined | number { + return parseNumber(this._quoteAssetInput); + } + + get baseEstimating(): boolean { + return this._baseEstimating; + } + + get quoteEstimating(): boolean { + return this._quoteEstimating; + } + + get balance(): undefined | string { + if (this.direction === 'buy') { + if (!this._quoteAsset?.balance) { + return undefined; + } + return this._quoteAsset.formatDisplayAmount(this._quoteAsset.balance); + } + if (!this._baseAsset?.balance) { + return undefined; + } + return this._baseAsset.formatDisplayAmount(this._baseAsset.balance); + } + + get quoteBalance(): undefined | number { + if (!this._quoteAsset?.balance) { + return undefined; + } + return pnum(this._quoteAsset.balance, this._quoteAsset.exponent).toNumber(); + } + + setBalanceFraction(x: number) { + const clamped = Math.max(0.0, Math.min(1.0, x)); + if (this.direction === 'buy' && this._quoteAsset?.balance) { + this.setQuoteInput((clamped * this._quoteAsset.balance).toString()); + } + if (this.direction === 'sell' && this._baseAsset?.balance) { + this.setBaseInput((clamped * this._baseAsset.balance).toString()); + } + } + + get lastEdited(): LastEdited { + return this._lastEdited; + } + + setAssets(base: AssetInfo, quote: AssetInfo, resetInputs = false) { + this._baseAsset = base; + this._quoteAsset = quote; + if (resetInputs) { + this._baseAssetInput = ''; + this._quoteAssetInput = ''; + } + } + + get baseAsset(): undefined | AssetInfo { + return this._baseAsset; + } + + get quoteAsset(): undefined | AssetInfo { + return this._quoteAsset; + } + + get plan(): undefined | MarketOrderPlan { + if (!this._baseAsset || !this._quoteAsset) { + return; + } + const { inputAsset, inputAmount, output } = + this.direction === 'buy' + ? { + inputAsset: this._quoteAsset, + inputAmount: this.quoteInputAmount, + output: this._baseAsset, + } + : { + inputAsset: this._baseAsset, + inputAmount: this.baseInputAmount, + output: this._quoteAsset, + }; + if (inputAmount === undefined) { + return; + } + return { + targetAsset: output.id, + value: inputAsset.value(inputAmount), + }; + } +} diff --git a/src/pages/trade/ui/order-form/store/OrderFormStore.ts b/src/pages/trade/ui/order-form/store/OrderFormStore.ts new file mode 100644 index 00000000..39cfe171 --- /dev/null +++ b/src/pages/trade/ui/order-form/store/OrderFormStore.ts @@ -0,0 +1,345 @@ +import { useEffect } from 'react'; +import { makeAutoObservable, reaction, runInAction } from 'mobx'; +import { LimitOrderFormStore } from './LimitOrderFormStore'; +import { MarketOrderFormStore } from './MarketOrderFormStore'; +import { RangeOrderFormStore } from './RangeOrderFormStore'; +import { AssetInfo } from '@/pages/trade/model/AssetInfo'; +import { + BalancesResponse, + TransactionPlannerRequest, +} from '@penumbra-zone/protobuf/penumbra/view/v1/view_pb'; +import { + Address, + AddressIndex, + AddressView, +} from '@penumbra-zone/protobuf/penumbra/core/keys/v1/keys_pb'; +import { usePathToMetadata } from '@/pages/trade/model/use-path'; +import { useBalances } from '@/shared/api/balances'; +import { Amount } from '@penumbra-zone/protobuf/penumbra/core/num/v1/num_pb'; +import { connectionStore } from '@/shared/model/connection'; +import { useSubaccounts } from '@/widgets/header/api/subaccounts'; +import { useMarketPrice } from '@/pages/trade/model/useMarketPrice'; +import { getSwapCommitmentFromTx } from '@penumbra-zone/getters/transaction'; +import { pnum } from '@penumbra-zone/types/pnum'; +import debounce from 'lodash/debounce'; +import { useRegistryAssets } from '@/shared/api/registry'; +import { plan, planBuildBroadcast } from '../helpers'; +import { openToast } from '@penumbra-zone/ui/Toast'; + +export type WhichForm = 'Market' | 'Limit' | 'Range'; + +export const isWhichForm = (x: string): x is WhichForm => { + return x === 'Market' || x === 'Limit' || x === 'Range'; +}; + +const GAS_DEBOUNCE_MS = 320; + +export class OrderFormStore { + private _market = new MarketOrderFormStore(); + private _limit = new LimitOrderFormStore(); + private _range = new RangeOrderFormStore(); + private _whichForm: WhichForm = 'Market'; + private _submitting = false; + private _marketPrice: number | undefined = undefined; + address?: Address; + subAccountIndex?: AddressIndex; + private _umAsset?: AssetInfo; + private _gasFee: { symbol: string; display: string } = { symbol: 'UM', display: '--' }; + private _gasFeeLoading = false; + + constructor() { + makeAutoObservable(this); + + reaction( + () => this.plan, + debounce(() => void this.estimateGasFee(), GAS_DEBOUNCE_MS), + ); + } + + private estimateGasFee = async (): Promise => { + if (!this.plan || !this._umAsset) { + return; + } + runInAction(() => { + this._gasFeeLoading = true; + }); + try { + const res = await plan(this.plan); + const fee = res.transactionParameters?.fee; + if (!fee) { + return; + } + runInAction(() => { + if (!this._umAsset) { + return; + } + + this._gasFee = { + symbol: this._umAsset.symbol, + display: pnum(fee.amount, this._umAsset.exponent).toNumber().toString(), + }; + }); + } catch (e) { + if (e instanceof Error && e.message.includes('insufficient funds')) { + openToast({ + type: 'error', + message: 'Gas fee estimation failed', + description: 'The amount exceeds your balance', + }); + } + if ( + e instanceof Error && + ![ + 'ConnectError', + 'PenumbraNotInstalledError', + 'PenumbraProviderNotAvailableError', + 'PenumbraProviderNotConnectedError', + ].includes(e.name) + ) { + openToast({ + type: 'error', + message: e.name, + description: e.message, + }); + } + return undefined; + } finally { + runInAction(() => { + this._gasFeeLoading = false; + }); + } + }; + + setUmAsset = (x: AssetInfo) => { + this._umAsset = x; + }; + + setSubAccountIndex = (x: AddressIndex) => { + this.subAccountIndex = x; + }; + + setAddress = (x: Address) => { + this.address = x; + }; + + get umAsset(): AssetInfo | undefined { + return this._umAsset; + } + + get gasFee(): { symbol: string; display: string } { + return this._gasFee; + } + + get gasFeeLoading(): boolean { + return this._gasFeeLoading; + } + + setAssets(base: AssetInfo, quote: AssetInfo, unsetInputs: boolean) { + this._market.setAssets(base, quote, unsetInputs); + this._limit.setAssets(base, quote, unsetInputs); + this._range.setAssets(base, quote, unsetInputs); + } + + setMarketPrice(price: number) { + this._marketPrice = price; + this._range.marketPrice = price; + this._limit.marketPrice = price; + } + + get marketPrice(): number | undefined { + return this._marketPrice; + } + + setWhichForm(x: WhichForm) { + this._whichForm = x; + } + + get whichForm(): WhichForm { + return this._whichForm; + } + + get marketForm() { + return this._market; + } + + get limitForm() { + return this._limit; + } + + get rangeForm() { + return this._range; + } + + get plan(): undefined | TransactionPlannerRequest { + if (!this.address || !this.subAccountIndex) { + return undefined; + } + if (this._whichForm === 'Market') { + const plan = this._market.plan; + if (!plan) { + return undefined; + } + return new TransactionPlannerRequest({ + swaps: [{ targetAsset: plan.targetAsset, value: plan.value, claimAddress: this.address }], + source: this.subAccountIndex, + }); + } + if (this._whichForm === 'Limit') { + const plan = this._limit.plan; + if (!plan) { + return undefined; + } + return new TransactionPlannerRequest({ + positionOpens: [{ position: plan }], + source: this.subAccountIndex, + }); + } + const plan = this._range.plan; + if (plan === undefined) { + return undefined; + } + return new TransactionPlannerRequest({ + positionOpens: plan.map(x => ({ position: x })), + source: this.subAccountIndex, + }); + } + + get canSubmit(): boolean { + return !this._submitting && this.plan !== undefined; + } + + async submit() { + const plan = this.plan; + const wasSwap = this.whichForm === 'Market'; + const source = this.subAccountIndex; + // Redundant, but makes typescript happier. + if (!plan || !source) { + return; + } + + runInAction(() => { + this._submitting = true; + }); + try { + const tx = await planBuildBroadcast(wasSwap ? 'swap' : 'positionOpen', plan); + if (!wasSwap || !tx) { + return; + } + const swapCommitment = getSwapCommitmentFromTx(tx); + const req = new TransactionPlannerRequest({ + swapClaims: [{ swapCommitment }], + source, + }); + await planBuildBroadcast('swapClaim', req, { skipAuth: true }); + } finally { + runInAction(() => { + this._submitting = false; + }); + } + } +} + +const pluckAssetBalance = (symbol: string, balances: BalancesResponse[]): undefined | Amount => { + for (const balance of balances) { + if (!balance.balanceView?.valueView || balance.balanceView.valueView.case !== 'knownAssetId') { + continue; + } + if (balance.balanceView.valueView.value.metadata?.symbol === symbol) { + const amount = balance.balanceView.valueView.value.amount; + if (amount) { + return amount; + } + } + } + return undefined; +}; + +function getAccountAddress(subAccounts: AddressView[] | undefined) { + const subAccount = subAccounts ? subAccounts[connectionStore.subaccount] : undefined; + let addressIndex = undefined; + let address = undefined; + const addressView = subAccount?.addressView; + if (addressView && addressView.case === 'decoded') { + address = addressView.value.address; + addressIndex = addressView.value.index; + } + return { + address, + addressIndex, + }; +} + +const orderFormStore = new OrderFormStore(); + +export const useOrderFormStore = () => { + const { data: assets } = useRegistryAssets(); + const { data: subAccounts } = useSubaccounts(); + const { address, addressIndex } = getAccountAddress(subAccounts); + const { data: balances } = useBalances(addressIndex); + const { baseAsset, quoteAsset } = usePathToMetadata(); + const marketPrice = useMarketPrice(); + + useEffect(() => { + if ( + baseAsset?.symbol && + baseAsset.penumbraAssetId && + quoteAsset?.symbol && + quoteAsset.penumbraAssetId + ) { + const baseBalance = balances && pluckAssetBalance(baseAsset.symbol, balances); + const quoteBalance = balances && pluckAssetBalance(quoteAsset.symbol, balances); + + const baseAssetInfo = AssetInfo.fromMetadata(baseAsset, baseBalance); + const quoteAssetInfo = AssetInfo.fromMetadata(quoteAsset, quoteBalance); + + const storeMapping = { + Market: orderFormStore.marketForm, + Limit: orderFormStore.limitForm, + Range: orderFormStore.rangeForm, + }; + const childStore = storeMapping[orderFormStore.whichForm]; + const prevBaseAssetInfo = childStore.baseAsset; + const prevQuoteAssetInfo = childStore.quoteAsset; + + const isChangingAssetPair = !!( + prevBaseAssetInfo?.symbol && + prevQuoteAssetInfo?.symbol && + (prevBaseAssetInfo.symbol !== baseAssetInfo?.symbol || + prevQuoteAssetInfo.symbol !== quoteAssetInfo?.symbol) + ); + + if (baseAssetInfo && quoteAssetInfo) { + orderFormStore.setAssets(baseAssetInfo, quoteAssetInfo, isChangingAssetPair); + } + } + }, [baseAsset, quoteAsset, balances]); + + useEffect(() => { + if (address && addressIndex) { + orderFormStore.setSubAccountIndex(addressIndex); + orderFormStore.setAddress(address); + } + }, [address, addressIndex]); + + useEffect(() => { + if (marketPrice) { + orderFormStore.setMarketPrice(marketPrice); + } + }, [marketPrice]); + + useEffect(() => { + let umAsset: AssetInfo | undefined; + if (assets) { + const meta = assets.find(x => x.symbol === 'UM'); + if (meta) { + umAsset = AssetInfo.fromMetadata(meta); + } + } + + if (umAsset && orderFormStore.umAsset?.symbol !== umAsset.symbol) { + orderFormStore.setUmAsset(umAsset); + } + }, [assets]); + + return orderFormStore; +}; diff --git a/src/pages/trade/ui/order-form/store/PriceLinkedInputs.test.ts b/src/pages/trade/ui/order-form/store/PriceLinkedInputs.test.ts new file mode 100644 index 00000000..942d3a31 --- /dev/null +++ b/src/pages/trade/ui/order-form/store/PriceLinkedInputs.test.ts @@ -0,0 +1,38 @@ +import { describe, expect, it } from 'vitest'; +import { PriceLinkedInputs } from './PriceLinkedInputs'; + +describe('PriceLinkedInputs', () => { + it('updates the other input, using the price', () => { + const store = new PriceLinkedInputs(); + store.price = 2; + store.inputA = '1'; + expect(store.inputA).toEqual('1'); + expect(store.inputB).toEqual('2'); + store.inputB = '10'; + expect(store.inputB).toEqual('10'); + expect(store.inputA).toEqual('5'); + }); + + it('will preserve the last edited input when the price changes', () => { + const store = new PriceLinkedInputs(); + store.inputA = '10'; + expect(store.inputB).toEqual('10'); + store.price = 4; + expect(store.inputA).toEqual('10'); + expect(store.inputB).toEqual('40'); + store.inputB = '100'; + store.price = 10; + expect(store.inputA).toEqual('10'); + expect(store.inputB).toEqual('100'); + }); + + it('will not update the other input when not a number', () => { + const store = new PriceLinkedInputs(); + store.inputA = '1'; + expect(store.inputB).toEqual('1'); + store.inputA = 'Dog'; + expect(store.inputB).toEqual('1'); + store.price = 2; + expect(store.inputB).toEqual('1'); + }); +}); diff --git a/src/pages/trade/ui/order-form/store/PriceLinkedInputs.ts b/src/pages/trade/ui/order-form/store/PriceLinkedInputs.ts new file mode 100644 index 00000000..121617bc --- /dev/null +++ b/src/pages/trade/ui/order-form/store/PriceLinkedInputs.ts @@ -0,0 +1,75 @@ +import { parseNumber } from '@/shared/utils/num'; +import { makeAutoObservable } from 'mobx'; + +type LastEdited = 'A' | 'B'; + +/** Compute one input from the other, given the price. + * + * @returns the resulting other input, or `undefined` if the input should not change. + */ +const computeBFromA = (price: number, a: string): string | undefined => { + const aNum = parseNumber(a); + return aNum !== undefined ? (price * aNum).toString() : undefined; +}; + +/** A sub-store for managing a pair of inputs linked by a common price. + * + * This is useful for market and limit orders, where users can specify the amount + * they want to buy or sell, with the other amount automatically updating based on the price. + * + * The inputs are always available as strings, and are intended to be used as the + * value for an ``. + * + * The price, however, is a number, and will update the inputs when it changes. + * It does so by preserving the last edited input, and modifying the other one. + * The intended use case here is that the user specifies that they want to buy / sell + * a certain amount, and that this intent does not change if the market price changes, + * or if they adjust the limit price. + */ +export class PriceLinkedInputs { + private _inputA = ''; + private _inputB = ''; + private _lastEdited: LastEdited = 'A'; + private _price = 1; + + constructor() { + makeAutoObservable(this); + } + + private computeBFromA() { + this._inputB = computeBFromA(this._price, this._inputA) ?? this._inputB; + } + + private computeAFromB() { + this._inputA = computeBFromA(1 / this._price, this._inputB) ?? this._inputA; + } + + get inputA(): string { + return this._inputA; + } + + set inputA(x: string) { + this._lastEdited = 'A'; + this._inputA = x; + this.computeBFromA(); + } + + get inputB(): string { + return this._inputB; + } + + set inputB(x: string) { + this._lastEdited = 'B'; + this._inputB = x; + this.computeAFromB(); + } + + set price(x: number) { + this._price = x; + if (this._lastEdited === 'A') { + this.computeBFromA(); + } else { + this.computeAFromB(); + } + } +} diff --git a/src/pages/trade/ui/order-form/store/RangeOrderFormStore.ts b/src/pages/trade/ui/order-form/store/RangeOrderFormStore.ts new file mode 100644 index 00000000..9b6b5dac --- /dev/null +++ b/src/pages/trade/ui/order-form/store/RangeOrderFormStore.ts @@ -0,0 +1,242 @@ +import { AssetInfo } from '@/pages/trade/model/AssetInfo'; +import { rangeLiquidityPositions } from '@/shared/math/position'; +import { parseNumber } from '@/shared/utils/num'; +import { Position } from '@penumbra-zone/protobuf/penumbra/core/component/dex/v1/dex_pb'; +import { pnum } from '@penumbra-zone/types/pnum'; +import { makeAutoObservable } from 'mobx'; + +export enum UpperBoundOptions { + Market = 'Market', + Plus2Percent = '+2%', + Plus5Percent = '+5%', + Plus10Percent = '+10%', + Plus15Percent = '+15%', +} + +export enum LowerBoundOptions { + Market = 'Market', + Minus2Percent = '-2%', + Minus5Percent = '-5%', + Minus10Percent = '-10%', + Minus15Percent = '-15%', +} + +export enum FeeTierOptions { + '0.1%' = '0.1%', + '0.25%' = '0.25%', + '0.5%' = '0.5%', + '1.00%' = '1.00%', +} + +const UpperBoundMultipliers = { + [UpperBoundOptions.Market]: 1, + [UpperBoundOptions.Plus2Percent]: 1.02, + [UpperBoundOptions.Plus5Percent]: 1.05, + [UpperBoundOptions.Plus10Percent]: 1.1, + [UpperBoundOptions.Plus15Percent]: 1.15, +}; + +const LowerBoundMultipliers = { + [LowerBoundOptions.Market]: 1, + [LowerBoundOptions.Minus2Percent]: 0.98, + [LowerBoundOptions.Minus5Percent]: 0.95, + [LowerBoundOptions.Minus10Percent]: 0.9, + [LowerBoundOptions.Minus15Percent]: 0.85, +}; + +const extractAmount = (positions: Position[], asset: AssetInfo): number => { + let out = 0.0; + for (const position of positions) { + const asset1 = position.phi?.pair?.asset1; + const asset2 = position.phi?.pair?.asset2; + if (asset1?.equals(asset.id)) { + out += pnum(position.reserves?.r1, asset.exponent).toNumber(); + } + if (asset2?.equals(asset.id)) { + out += pnum(position.reserves?.r2, asset.exponent).toNumber(); + } + } + return out; +}; + +export const MIN_POSITION_COUNT = 5; +export const MAX_POSITION_COUNT = 15; + +export class RangeOrderFormStore { + private _baseAsset?: AssetInfo; + private _quoteAsset?: AssetInfo; + liquidityTargetInput = ''; + upperPriceInput = ''; + lowerPriceInput = ''; + upperPriceInputOption: UpperBoundOptions | undefined; + lowerPriceInputOption: LowerBoundOptions | undefined; + feeTierPercentInput = ''; + feeTierPercentInputOption: FeeTierOptions | undefined; + private _positionCountInput = '10'; + private _positionCountSlider = 10; + marketPrice = 1; + + constructor() { + makeAutoObservable(this); + } + + get baseAsset(): undefined | AssetInfo { + return this._baseAsset; + } + + get quoteAsset(): undefined | AssetInfo { + return this._quoteAsset; + } + + get liquidityTarget(): number | undefined { + return parseNumber(this.liquidityTargetInput); + } + + setLiquidityTargetInput = (x: string) => { + this.liquidityTargetInput = x; + }; + + get upperPrice(): number | undefined { + return parseNumber(this.upperPriceInput); + } + + setUpperPriceInput = (x: string, fromOption = false) => { + this.upperPriceInput = x; + if (!fromOption) { + this.upperPriceInputOption = undefined; + } + }; + + setUpperPriceInputOption = (option: UpperBoundOptions) => { + this.upperPriceInputOption = option; + const multiplier = UpperBoundMultipliers[option]; + + if (!multiplier) { + return; + } + + const price = multiplier * this.marketPrice; + this.setUpperPriceInput(price.toString(), true); + }; + + get lowerPrice(): number | undefined { + return parseNumber(this.lowerPriceInput); + } + + setLowerPriceInput = (x: string, fromOption = false) => { + this.lowerPriceInput = x; + if (!fromOption) { + this.lowerPriceInputOption = undefined; + } + }; + + setLowerPriceInputOption = (option: LowerBoundOptions) => { + this.lowerPriceInputOption = option; + const multiplier = LowerBoundMultipliers[option]; + + if (!multiplier) { + return; + } + + const price = multiplier * this.marketPrice; + this.setLowerPriceInput(price.toString(), true); + }; + + // Treat fees that don't parse as 0 + get feeTierPercent(): number { + return Math.max(0, Math.min(parseNumber(this.feeTierPercentInput) ?? 0, 50)); + } + + setFeeTierPercentInput = (x: string, fromOption = false) => { + this.feeTierPercentInput = x; + if (!fromOption) { + this.feeTierPercentInputOption = undefined; + } + }; + + setFeeTierPercentInputOption = (option: FeeTierOptions) => { + this.feeTierPercentInputOption = option; + this.setFeeTierPercentInput(option.replace('%', ''), true); + }; + + get positionCountInput(): string { + return this._positionCountInput; + } + + setPositionCountInput = (x: string) => { + this._positionCountInput = x; + const count = this.positionCount; + if (count !== undefined) { + this._positionCountSlider = Math.max(MIN_POSITION_COUNT, Math.min(count, MAX_POSITION_COUNT)); + } + }; + + get positionCountSlider(): number { + return this._positionCountSlider; + } + + setPositionCountSlider = (x: number) => { + this._positionCountSlider = x; + this._positionCountInput = x.toString(); + }; + + get positionCount(): undefined | number { + return parseNumber(this._positionCountInput); + } + + get plan(): Position[] | undefined { + if ( + !this._baseAsset || + !this._quoteAsset || + this.liquidityTarget === undefined || + this.upperPrice === undefined || + this.lowerPrice === undefined || + this.positionCount === undefined + ) { + return undefined; + } + return rangeLiquidityPositions({ + baseAsset: this._baseAsset, + quoteAsset: this._quoteAsset, + targetLiquidity: this.liquidityTarget, + upperPrice: this.upperPrice, + lowerPrice: this.lowerPrice, + marketPrice: this.marketPrice, + feeBps: this.feeTierPercent * 100, + positions: this.positionCount, + }); + } + + get baseAssetAmount(): string | undefined { + const baseAsset = this._baseAsset; + const plan = this.plan; + if (!plan || !baseAsset) { + return undefined; + } + return baseAsset.formatDisplayAmount(extractAmount(plan, baseAsset)); + } + + get quoteAssetAmount(): string | undefined { + const quoteAsset = this._quoteAsset; + const plan = this.plan; + if (!plan || !quoteAsset) { + return undefined; + } + return quoteAsset.formatDisplayAmount(extractAmount(plan, quoteAsset)); + } + + setAssets(base: AssetInfo, quote: AssetInfo, resetInputs = false) { + this._baseAsset = base; + this._quoteAsset = quote; + if (resetInputs) { + this.liquidityTargetInput = ''; + this.upperPriceInput = ''; + this.upperPriceInputOption = undefined; + this.lowerPriceInput = ''; + this.lowerPriceInputOption = undefined; + this.feeTierPercentInput = ''; + this._positionCountInput = '10'; + this._positionCountSlider = 10; + } + } +} diff --git a/src/pages/trade/ui/order-form/store/asset.ts b/src/pages/trade/ui/order-form/store/asset.ts deleted file mode 100644 index db1c0c11..00000000 --- a/src/pages/trade/ui/order-form/store/asset.ts +++ /dev/null @@ -1,98 +0,0 @@ -import { makeAutoObservable } from 'mobx'; -import { BigNumber } from 'bignumber.js'; -import { round } from 'lodash'; -import { - AssetId, - Metadata, - Value, - ValueView, -} from '@penumbra-zone/protobuf/penumbra/core/asset/v1/asset_pb'; -import { getAssetId, getDisplayDenomExponent } from '@penumbra-zone/getters/metadata'; -import { getAddressIndex, getAddress } from '@penumbra-zone/getters/address-view'; -import { getFormattedAmtFromValueView } from '@penumbra-zone/types/value-view'; -import { joinLoHi, LoHi, toBaseUnit } from '@penumbra-zone/types/lo-hi'; -import { - AddressView, - Address, - AddressIndex, -} from '@penumbra-zone/protobuf/penumbra/core/keys/v1/keys_pb'; - -export class OrderFormAsset { - symbol: string; - metadata?: Metadata; - exponent?: number; - assetId?: AssetId; - balanceView?: ValueView; - accountAddress?: Address; - accountIndex?: AddressIndex; - balance?: number; - amount?: number; - onAmountChangeCallback?: (asset: OrderFormAsset) => Promise; - isEstimating = false; - - constructor(metadata?: Metadata) { - makeAutoObservable(this); - - this.metadata = metadata; - this.symbol = metadata?.symbol ?? ''; - this.assetId = metadata ? getAssetId(metadata) : undefined; - this.exponent = metadata ? getDisplayDenomExponent(metadata) : undefined; - } - - setBalanceView = (balanceView: ValueView): void => { - this.balanceView = balanceView; - this.setBalanceFromBalanceView(balanceView); - }; - - setAccountAddress = (addressView: AddressView): void => { - this.accountAddress = getAddress(addressView); - this.accountIndex = getAddressIndex(addressView); - }; - - setBalanceFromBalanceView = (balanceView: ValueView): void => { - const balance = getFormattedAmtFromValueView(balanceView, true); - this.balance = parseFloat(balance.replace(/,/g, '')); - }; - - setAmount = (amount: string | number, callOnAmountChange = true): void => { - const prevAmount = this.amount; - const nextAmount = round(Number(amount), this.exponent); - - if (prevAmount !== nextAmount) { - this.amount = nextAmount; - - if (this.onAmountChangeCallback && callOnAmountChange) { - void this.onAmountChangeCallback(this); - } - } - }; - - unsetAmount = (): void => { - this.amount = undefined; - this.isEstimating = false; - }; - - setIsEstimating = (isEstimating: boolean): void => { - this.isEstimating = isEstimating; - }; - - onAmountChange = (callback: (asset: OrderFormAsset) => Promise): void => { - this.onAmountChangeCallback = callback; - }; - - toAmount = (): LoHi => { - return toBaseUnit(BigNumber(this.amount ?? 0), this.exponent); - }; - - toUnitAmount = (): bigint => { - const amount = this.toAmount(); - return joinLoHi(amount.lo, amount.hi); - }; - - toValue = (): Value => { - return new Value({ - assetId: this.assetId, - amount: this.toAmount(), - }); - }; -} diff --git a/src/pages/trade/ui/order-form/store/index.ts b/src/pages/trade/ui/order-form/store/index.ts deleted file mode 100644 index fc85883d..00000000 --- a/src/pages/trade/ui/order-form/store/index.ts +++ /dev/null @@ -1,504 +0,0 @@ -import { useEffect } from 'react'; -import { makeAutoObservable } from 'mobx'; -import debounce from 'lodash/debounce'; -import times from 'lodash/times'; -import { SimulationService } from '@penumbra-zone/protobuf'; -import { - BalancesResponse, - TransactionPlannerRequest, -} from '@penumbra-zone/protobuf/penumbra/view/v1/view_pb'; -import { Metadata, ValueView } from '@penumbra-zone/protobuf/penumbra/core/asset/v1/asset_pb'; -import { - SimulateTradeRequest, - PositionState_PositionStateEnum, - TradingPair, - Position, - PositionState, -} from '@penumbra-zone/protobuf/penumbra/core/component/dex/v1/dex_pb'; -import { Amount } from '@penumbra-zone/protobuf/penumbra/core/num/v1/num_pb'; -import { splitLoHi } from '@penumbra-zone/types/lo-hi'; -import { getAssetId } from '@penumbra-zone/getters/metadata'; -import { getSwapCommitmentFromTx } from '@penumbra-zone/getters/transaction'; -import { getAssetIdFromValueView } from '@penumbra-zone/getters/value-view'; -import { getFormattedAmtFromValueView } from '@penumbra-zone/types/value-view'; -import { round } from '@penumbra-zone/types/round'; -import { openToast } from '@penumbra-zone/ui/Toast'; -import { penumbra } from '@/shared/const/penumbra'; -import { useBalances } from '@/shared/api/balances'; -import { plan, planBuildBroadcast } from '../helpers'; -import { usePathToMetadata } from '../../../model/use-path'; -import { OrderFormAsset } from './asset'; -import { RangeLiquidity } from './range-liquidity'; -import BigNumber from 'bignumber.js'; - -export enum Direction { - Buy = 'Buy', - Sell = 'Sell', -} - -export enum FormType { - Market = 'Market', - Limit = 'Limit', - RangeLiquidity = 'RangeLiquidity', -} - -class OrderFormStore { - type: FormType = FormType.Market; - direction: Direction = Direction.Buy; - baseAsset = new OrderFormAsset(); - quoteAsset = new OrderFormAsset(); - rangeLiquidity = new RangeLiquidity(); - balances: BalancesResponse[] | undefined; - exchangeRate: number | null = null; - gasFee: number | null = null; - isLoading = false; - - constructor() { - makeAutoObservable(this); - - void this.calculateGasFee(); - void this.calculateExchangeRate(); - } - - setType = (type: FormType): void => { - this.type = type; - }; - - setDirection = (direction: Direction): void => { - this.direction = direction; - }; - - private setBalancesOfAssets = (): void => { - const baseAssetBalance = this.balances?.find(resp => - getAssetIdFromValueView(resp.balanceView).equals( - getAssetId.optional(this.baseAsset.metadata), - ), - ); - if (baseAssetBalance?.balanceView) { - this.baseAsset.setBalanceView(baseAssetBalance.balanceView); - } - if (baseAssetBalance?.accountAddress) { - this.baseAsset.setAccountAddress(baseAssetBalance.accountAddress); - } - - const quoteAssetBalance = this.balances?.find(resp => - getAssetIdFromValueView(resp.balanceView).equals( - getAssetId.optional(this.quoteAsset.metadata), - ), - ); - if (quoteAssetBalance?.balanceView) { - this.quoteAsset.setBalanceView(quoteAssetBalance.balanceView); - } - if (quoteAssetBalance?.accountAddress) { - this.quoteAsset.setAccountAddress(quoteAssetBalance.accountAddress); - } - }; - - private simulateSwapTx = async ( - assetIn: OrderFormAsset, - assetOut: OrderFormAsset, - ): Promise => { - try { - const req = new SimulateTradeRequest({ - input: assetIn.toValue(), - output: assetOut.assetId, - }); - - const res = await penumbra.service(SimulationService).simulateTrade(req); - - const output = new ValueView({ - valueView: { - case: 'knownAssetId', - value: { - amount: res.output?.output?.amount, - metadata: assetOut.metadata, - }, - }, - }); - - return output; - } catch (e) { - if ( - e instanceof Error && - e.name !== 'PenumbraProviderNotAvailableError' && - e.name !== 'PenumbraProviderNotConnectedError' - ) { - openToast({ - type: 'error', - message: e.name, - description: e.message, - }); - } - } - }; - - setAssets = (baseAsset: Metadata, quoteAsset: Metadata): void => { - this.baseAsset = new OrderFormAsset(baseAsset); - this.quoteAsset = new OrderFormAsset(quoteAsset); - - const debouncedHandleAmountChange = debounce(this.handleAmountChange, 500) as ( - asset: OrderFormAsset, - ) => Promise; - - this.baseAsset.onAmountChange(debouncedHandleAmountChange); - this.quoteAsset.onAmountChange(debouncedHandleAmountChange); - - const debouncedCalculateGasFee = debounce(this.calculateGasFee, 500) as () => Promise; - this.rangeLiquidity.onFieldChange(debouncedCalculateGasFee); - - this.setBalancesOfAssets(); - void this.calculateGasFee(); - void this.calculateExchangeRate(); - }; - - setBalances = (balances: BalancesResponse[]): void => { - this.balances = balances; - this.setBalancesOfAssets(); - }; - - handleAmountChange = async (asset: OrderFormAsset): Promise => { - const assetIsBaseAsset = asset.assetId === this.baseAsset.assetId; - const assetIn = assetIsBaseAsset ? this.baseAsset : this.quoteAsset; - const assetOut = assetIsBaseAsset ? this.quoteAsset : this.baseAsset; - - try { - void this.calculateGasFee(); - - assetOut.setIsEstimating(true); - - const output = await this.simulateSwapTx(assetIn, assetOut); - if (!output) { - return; - } - - const outputAmount = getFormattedAmtFromValueView(output, true); - - assetOut.setAmount(Number(outputAmount), false); - } finally { - assetOut.setIsEstimating(false); - } - }; - - calculateExchangeRate = async (): Promise => { - this.exchangeRate = null; - - const baseAsset: OrderFormAsset = new OrderFormAsset(this.baseAsset.metadata); - baseAsset.setAmount(1); - - const output = await this.simulateSwapTx(baseAsset, this.quoteAsset); - if (!output) { - return; - } - - const outputAmount = getFormattedAmtFromValueView(output, true); - this.exchangeRate = Number(outputAmount); - }; - - calculateMarketGasFee = async (): Promise => { - this.gasFee = null; - - const isBuy = this.direction === Direction.Buy; - const assetIn = isBuy ? this.quoteAsset : this.baseAsset; - const assetOut = isBuy ? this.baseAsset : this.quoteAsset; - - if (!assetIn.amount || !assetOut.amount) { - this.gasFee = 0; - return; - } - - const req = new TransactionPlannerRequest({ - swaps: [ - { - targetAsset: assetOut.assetId, - value: { - amount: assetIn.toAmount(), - assetId: assetIn.assetId, - }, - claimAddress: assetIn.accountAddress, - }, - ], - source: assetIn.accountIndex, - }); - - const txPlan = await plan(req); - const fee = txPlan.transactionParameters?.fee; - const feeValueView = new ValueView({ - valueView: { - case: 'knownAssetId', - value: { - amount: fee?.amount ?? { hi: 0n, lo: 0n }, - metadata: this.baseAsset.metadata, - }, - }, - }); - - const feeAmount = getFormattedAmtFromValueView(feeValueView, true); - this.gasFee = Number(feeAmount); - }; - - constructRangePosition = ({ - positionIndex, - quoteAssetUnitAmount, - baseAssetUnitAmount, - positionUnitAmount, - }: { - positionIndex: number; - quoteAssetUnitAmount: bigint; - baseAssetUnitAmount: bigint; - positionUnitAmount: bigint; - }) => { - const { lowerBound, upperBound, positions, marketPrice, feeTier } = this - .rangeLiquidity as Required; - - const i = positionIndex; - const price = lowerBound + (i * (upperBound - lowerBound)) / (positions - 1); - const priceValue = BigNumber(price).multipliedBy( - new BigNumber(10).pow(this.quoteAsset.exponent ?? 0), - ); - const priceUnit = BigInt(priceValue.toFixed(0)); - - // Cross-multiply exponents and prices for trading function coefficients - // - // We want to write - // p = EndUnit * price - // q = StartUnit - // However, if EndUnit is too small, it might not round correctly after multiplying by price - // To handle this, conditionally apply a scaling factor if the EndUnit amount is too small. - const scale = quoteAssetUnitAmount < 1_000_000n ? 1_000_000n : 1n; - const p = new Amount(splitLoHi(quoteAssetUnitAmount * scale * priceUnit)); - const q = new Amount(splitLoHi(baseAssetUnitAmount * scale)); - - // Compute reserves - // Fund the position with asset 1 if its price exceeds the current price, - // matching the target per-position amount of asset 2. Otherwise, fund with - // asset 2 to avoid immediate arbitrage. - const reserves = - price < marketPrice - ? { - r1: new Amount(splitLoHi(0n)), - r2: new Amount(splitLoHi(positionUnitAmount)), - } - : { - r1: new Amount( - splitLoHi(BigInt(round({ value: Number(positionUnitAmount) / price, decimals: 0 }))), - ), - r2: new Amount(splitLoHi(0n)), - }; - - return { - position: new Position({ - phi: { - component: { fee: feeTier * 100, p, q }, - pair: new TradingPair({ - asset1: this.baseAsset.assetId, - asset2: this.quoteAsset.assetId, - }), - }, - nonce: crypto.getRandomValues(new Uint8Array(32)), - state: new PositionState({ state: PositionState_PositionStateEnum.OPENED }), - reserves, - closeOnFill: false, - }), - }; - }; - - calculateRangeLiquidityGasFee = async (): Promise => { - this.gasFee = null; - - const { lowerBound, upperBound, positions, marketPrice, feeTier } = this.rangeLiquidity; - if ( - !this.quoteAsset.amount || - !lowerBound || - !upperBound || - !positions || - !marketPrice || - !feeTier - ) { - this.gasFee = 0; - return; - } - - if (lowerBound > upperBound) { - this.gasFee = 0; - return; - } - - const baseAssetUnitAmount = this.baseAsset.toUnitAmount(); - const quoteAssetUnitAmount = this.quoteAsset.toUnitAmount(); - const positionUnitAmount = quoteAssetUnitAmount / BigInt(positions); - - const positionsReq = new TransactionPlannerRequest({ - positionOpens: times(positions, i => - this.constructRangePosition({ - positionIndex: i, - quoteAssetUnitAmount, - baseAssetUnitAmount, - positionUnitAmount, - }), - ), - source: this.quoteAsset.accountIndex, - }); - - const txPlan = await plan(positionsReq); - const fee = txPlan.transactionParameters?.fee; - const feeValueView = new ValueView({ - valueView: { - case: 'knownAssetId', - value: { - amount: fee?.amount ?? { hi: 0n, lo: 0n }, - metadata: this.baseAsset.metadata, - }, - }, - }); - - const feeAmount = getFormattedAmtFromValueView(feeValueView, true); - this.gasFee = Number(feeAmount); - }; - - calculateGasFee = async (): Promise => { - if (this.type === FormType.Market) { - await this.calculateMarketGasFee(); - } - - if (this.type === FormType.RangeLiquidity) { - await this.calculateRangeLiquidityGasFee(); - } - }; - - initiateSwapTx = async (): Promise => { - try { - this.isLoading = true; - - const isBuy = this.direction === Direction.Buy; - const assetIn = isBuy ? this.quoteAsset : this.baseAsset; - const assetOut = isBuy ? this.baseAsset : this.quoteAsset; - - if (!assetIn.amount || !assetOut.amount) { - openToast({ - type: 'error', - message: 'Please enter an amount.', - }); - return; - } - - const swapReq = new TransactionPlannerRequest({ - swaps: [ - { - targetAsset: assetOut.assetId, - value: { - amount: assetIn.toAmount(), - assetId: assetIn.assetId, - }, - claimAddress: assetIn.accountAddress, - }, - ], - source: assetIn.accountIndex, - }); - - const swapTx = await planBuildBroadcast('swap', swapReq); - const swapCommitment = getSwapCommitmentFromTx(swapTx); - - // Issue swap claim - const req = new TransactionPlannerRequest({ - swapClaims: [{ swapCommitment }], - source: assetIn.accountIndex, - }); - await planBuildBroadcast('swapClaim', req, { skipAuth: true }); - - assetIn.unsetAmount(); - assetOut.unsetAmount(); - } finally { - this.isLoading = false; - } - }; - - // ref: https://github.com/penumbra-zone/penumbra/blob/main/crates/bin/pcli/src/command/tx/replicate/linear.rs - initiatePositionsTx = async (): Promise => { - try { - this.isLoading = true; - - const { lowerBound, upperBound, positions, marketPrice, feeTier } = this.rangeLiquidity; - if ( - !this.quoteAsset.amount || - !lowerBound || - !upperBound || - !positions || - !marketPrice || - !feeTier - ) { - openToast({ - type: 'error', - message: 'Please enter a valid range.', - }); - return; - } - - if (lowerBound > upperBound) { - openToast({ - type: 'error', - message: 'Upper bound must be greater than the lower bound.', - }); - return; - } - - const baseAssetUnitAmount = this.baseAsset.toUnitAmount(); - const quoteAssetUnitAmount = this.quoteAsset.toUnitAmount(); - const positionUnitAmount = quoteAssetUnitAmount / BigInt(positions); - - const positionsReq = new TransactionPlannerRequest({ - positionOpens: times(positions, i => - this.constructRangePosition({ - positionIndex: i, - quoteAssetUnitAmount, - baseAssetUnitAmount, - positionUnitAmount, - }), - ), - source: this.quoteAsset.accountIndex, - }); - - await planBuildBroadcast('positionOpen', positionsReq); - - this.baseAsset.unsetAmount(); - this.quoteAsset.unsetAmount(); - } finally { - this.isLoading = false; - } - }; - - submitOrder = (): void => { - if (this.type === FormType.Market) { - void this.initiateSwapTx(); - } - - if (this.type === FormType.RangeLiquidity) { - void this.initiatePositionsTx(); - } - }; -} - -export const orderFormStore = new OrderFormStore(); - -export const useOrderFormStore = (type: FormType) => { - const { baseAsset, quoteAsset } = usePathToMetadata(); - const { data: balances } = useBalances(); - const { setAssets, setBalances, setType } = orderFormStore; - - useEffect(() => { - setType(type); - }, [type, setType]); - - useEffect(() => { - if (baseAsset && quoteAsset) { - setAssets(baseAsset, quoteAsset); - } - }, [baseAsset, quoteAsset, setAssets]); - - useEffect(() => { - if (balances) { - setBalances(balances); - } - }, [balances, setBalances]); - - return orderFormStore; -}; diff --git a/src/pages/trade/ui/order-form/store/range-liquidity.ts b/src/pages/trade/ui/order-form/store/range-liquidity.ts deleted file mode 100644 index b4f2b495..00000000 --- a/src/pages/trade/ui/order-form/store/range-liquidity.ts +++ /dev/null @@ -1,131 +0,0 @@ -import { makeAutoObservable } from 'mobx'; -import { round } from '@penumbra-zone/types/round'; - -export enum UpperBoundOptions { - Market = 'Market', - Plus2Percent = '+2%', - Plus5Percent = '+5%', - Plus10Percent = '+10%', - Plus15Percent = '+15%', -} - -export enum LowerBoundOptions { - Market = 'Market', - Minus2Percent = '-2%', - Minus5Percent = '-5%', - Minus10Percent = '-10%', - Minus15Percent = '-15%', -} - -export enum FeeTierOptions { - '0.1%' = '0.1%', - '0.25%' = '0.25%', - '0.5%' = '0.5%', - '1.00%' = '1.00%', -} - -const UpperBoundMultipliers = { - [UpperBoundOptions.Market]: 1, - [UpperBoundOptions.Plus2Percent]: 1.02, - [UpperBoundOptions.Plus5Percent]: 1.05, - [UpperBoundOptions.Plus10Percent]: 1.1, - [UpperBoundOptions.Plus15Percent]: 1.15, -}; - -const LowerBoundMultipliers = { - [LowerBoundOptions.Market]: 1, - [LowerBoundOptions.Minus2Percent]: 0.98, - [LowerBoundOptions.Minus5Percent]: 0.95, - [LowerBoundOptions.Minus10Percent]: 0.9, - [LowerBoundOptions.Minus15Percent]: 0.85, -}; - -const FeeTierValues: Record = { - '0.1%': 0.1, - '0.25%': 0.25, - '0.5%': 0.5, - '1.00%': 1, -}; - -export class RangeLiquidity { - upperBound?: number; - lowerBound?: number; - feeTier?: number; - positions?: number; - marketPrice?: number; - exponent?: number; - onFieldChangeCallback?: () => Promise; - - constructor() { - makeAutoObservable(this); - } - - setUpperBound = (amount: string) => { - this.upperBound = Number(round({ value: Number(amount), decimals: this.exponent ?? 0 })); - }; - - setUpperBoundOption = (option: UpperBoundOptions) => { - if (this.marketPrice) { - this.upperBound = Number( - round({ - value: this.marketPrice * UpperBoundMultipliers[option], - decimals: this.exponent ?? 0, - }), - ); - } - }; - - setLowerBound = (amount: string) => { - this.lowerBound = Number(round({ value: Number(amount), decimals: this.exponent ?? 0 })); - if (this.onFieldChangeCallback) { - void this.onFieldChangeCallback(); - } - }; - - setLowerBoundOption = (option: LowerBoundOptions) => { - if (this.marketPrice) { - this.lowerBound = Number( - round({ - value: this.marketPrice * LowerBoundMultipliers[option], - decimals: this.exponent ?? 0, - }), - ); - } - if (this.onFieldChangeCallback) { - void this.onFieldChangeCallback(); - } - }; - - setFeeTier = (feeTier: string) => { - this.feeTier = Number(feeTier); - if (this.onFieldChangeCallback) { - void this.onFieldChangeCallback(); - } - }; - - setFeeTierOption = (option: FeeTierOptions) => { - this.feeTier = FeeTierValues[option]; - if (this.onFieldChangeCallback) { - void this.onFieldChangeCallback(); - } - }; - - setPositions = (positions: number | string) => { - this.positions = Number(positions); - if (this.onFieldChangeCallback) { - void this.onFieldChangeCallback(); - } - }; - - setMarketPrice = (price: number) => { - this.marketPrice = price; - }; - - setExponent = (exponent: number) => { - this.exponent = exponent; - }; - - onFieldChange = (callback: () => Promise): void => { - this.onFieldChangeCallback = callback; - }; -} diff --git a/src/pages/trade/ui/page.tsx b/src/pages/trade/ui/page.tsx index 470fed0b..f9e809ad 100644 --- a/src/pages/trade/ui/page.tsx +++ b/src/pages/trade/ui/page.tsx @@ -7,6 +7,7 @@ import { RouteTabs } from './route-tabs'; import { TradesTabs } from './trades-tabs'; import { HistoryTabs } from './history-tabs'; import { FormTabs } from './form-tabs'; +import { useEffect, useState } from 'react'; const sharedStyle = 'w-full border-t border-t-other-solidStroke overflow-x-hidden'; @@ -143,13 +144,36 @@ const MobileLayout = () => { }; export const TradePage = () => { - return ( - <> - - - - - - - ); + const [width, setWidth] = useState(1366); + + useEffect(() => { + const resize = () => { + setWidth(document.body.clientWidth); + }; + + window.addEventListener('resize', resize); + resize(); + + return () => { + window.removeEventListener('resize', resize); + }; + }, []); + + if (width > 1600) { + return ; + } + + if (width > 1200) { + return ; + } + + if (width > 900) { + return ; + } + + if (width > 600) { + return ; + } + + return ; }; diff --git a/src/pages/trade/ui/pair-selector.tsx b/src/pages/trade/ui/pair-selector.tsx index 1c24a0c9..d7bdfee8 100644 --- a/src/pages/trade/ui/pair-selector.tsx +++ b/src/pages/trade/ui/pair-selector.tsx @@ -65,7 +65,15 @@ export const PairSelector = observer(({ disabled, dialogTitle }: PairSelectorPro const { data: balances } = useBalances(); const { baseAsset, quoteAsset, error, isLoading } = usePathToMetadata(); - if (error) { + if ( + error instanceof Error && + ![ + 'ConnectError', + 'PenumbraNotInstalledError', + 'PenumbraProviderNotAvailableError', + 'PenumbraProviderNotConnectedError', + ].includes(error.name) + ) { return
Error loading pair selector: ${String(error)}
; } diff --git a/src/pages/trade/ui/trade-row.tsx b/src/pages/trade/ui/trade-row.tsx index 432fac7c..62a3c742 100644 --- a/src/pages/trade/ui/trade-row.tsx +++ b/src/pages/trade/ui/trade-row.tsx @@ -86,7 +86,7 @@ const RouteDisplay = ({ tokens }: { tokens: string[] }) => { return (
{tokens.map((token, index) => ( - + {index > 0 && } {token} diff --git a/src/shared/api/balances.ts b/src/shared/api/balances.ts index c3477055..d5dfcb92 100644 --- a/src/shared/api/balances.ts +++ b/src/shared/api/balances.ts @@ -3,19 +3,20 @@ import { BalancesResponse } from '@penumbra-zone/protobuf/penumbra/view/v1/view_ import { penumbra } from '@/shared/const/penumbra'; import { connectionStore } from '@/shared/model/connection'; import { useQuery } from '@tanstack/react-query'; +import { AddressIndex } from '@penumbra-zone/protobuf/penumbra/core/keys/v1/keys_pb'; -const fetchQuery = async (): Promise => { - return Array.fromAsync(penumbra.service(ViewService).balances({})); +const fetchQuery = (accountFilter?: AddressIndex) => async (): Promise => { + return Array.fromAsync(penumbra.service(ViewService).balances({ accountFilter })); }; /** * Fetches the `BalancesResponse[]` based on the provider connection state. * Must be used within the `observer` mobX HOC */ -export const useBalances = () => { +export const useBalances = (accountFilter?: AddressIndex) => { return useQuery({ queryKey: ['view-service-balances'], - queryFn: fetchQuery, + queryFn: fetchQuery(accountFilter), enabled: connectionStore.connected, }); }; diff --git a/src/shared/math/position.test.ts b/src/shared/math/position.test.ts new file mode 100644 index 00000000..5cf68afc --- /dev/null +++ b/src/shared/math/position.test.ts @@ -0,0 +1,77 @@ +import { AssetId } from '@penumbra-zone/protobuf/penumbra/core/asset/v1/asset_pb'; +import { describe, expect, it } from 'vitest'; +import { planToPosition } from './position'; +import { pnum } from '@penumbra-zone/types/pnum'; +import { Position } from '@penumbra-zone/protobuf/penumbra/core/component/dex/v1/dex_pb'; + +const ASSET_A = new AssetId({ inner: new Uint8Array(Array(32).fill(0xaa)) }); +const ASSET_B = new AssetId({ inner: new Uint8Array(Array(32).fill(0xbb)) }); + +const getPrice = (position: Position): number => { + return pnum(position.phi?.component?.p).toNumber() / pnum(position.phi?.component?.q).toNumber(); +}; + +describe('planToPosition', () => { + it('works for plans with no exponent', () => { + const position = planToPosition({ + baseAsset: { + id: ASSET_A, + exponent: 0, + }, + quoteAsset: { + id: ASSET_B, + exponent: 0, + }, + price: 20.5, + feeBps: 100, + baseReserves: 1000, + quoteReserves: 2000, + }); + expect(position.phi?.component?.fee).toEqual(100); + expect(getPrice(position)).toEqual(20.5); + expect(pnum(position.reserves?.r1).toNumber()).toEqual(1000); + expect(pnum(position.reserves?.r2).toNumber()).toEqual(2000); + }); + + it('works for plans with identical exponent', () => { + const position = planToPosition({ + baseAsset: { + id: ASSET_A, + exponent: 6, + }, + quoteAsset: { + id: ASSET_B, + exponent: 6, + }, + price: 12.34, + feeBps: 100, + baseReserves: 5, + quoteReserves: 7, + }); + expect(position.phi?.component?.fee).toEqual(100); + expect(getPrice(position)).toEqual(12.34); + expect(pnum(position.reserves?.r1).toNumber()).toEqual(5e6); + expect(pnum(position.reserves?.r2).toNumber()).toEqual(7e6); + }); + + it('works for plans with different exponents', () => { + const position = planToPosition({ + baseAsset: { + id: ASSET_A, + exponent: 6, + }, + quoteAsset: { + id: ASSET_B, + exponent: 8, + }, + price: 12.34, + feeBps: 100, + baseReserves: 5, + quoteReserves: 7, + }); + expect(position.phi?.component?.fee).toEqual(100); + expect(getPrice(position) * 10 ** (6 - 8)).toEqual(12.34); + expect(pnum(position.reserves?.r1).toNumber()).toEqual(5e6); + expect(pnum(position.reserves?.r2).toNumber()).toEqual(7e8); + }); +}); diff --git a/src/shared/math/position.ts b/src/shared/math/position.ts new file mode 100644 index 00000000..4bf313d3 --- /dev/null +++ b/src/shared/math/position.ts @@ -0,0 +1,223 @@ +import { AssetId } from '@penumbra-zone/protobuf/penumbra/core/asset/v1/asset_pb'; +import { + Position, + PositionState, + PositionState_PositionStateEnum, + TradingPair, +} from '@penumbra-zone/protobuf/penumbra/core/component/dex/v1/dex_pb'; +import { Amount } from '@penumbra-zone/protobuf/penumbra/core/num/v1/num_pb'; +import { pnum } from '@penumbra-zone/types/pnum'; +import BigNumber from 'bignumber.js'; + +export const compareAssetId = (a: AssetId, b: AssetId): number => { + for (let i = 31; i >= 0; --i) { + const a_i = a.inner[i] ?? -Infinity; + const b_i = b.inner[i] ?? -Infinity; + if (a_i < b_i) { + return -1; + } + if (b_i < a_i) { + return 1; + } + } + return 0; +}; + +/** + * A slimmed-down representation for assets, restricted to what we need for math. + * + * We have an identifier for the kind of asset, which is needed to construct a position, + * and an exponent E, such that 10**E units of the base denom constitute a unit of the display denom. + * + * For example, 10**6 uUSD make up one USD. + */ +export interface Asset { + id: AssetId; + exponent: number; +} + +/** + * A basic plan to create a position. + * + * This can then be passed to `planToPosition` to fill out the position. + */ +export interface PositionPlan { + baseAsset: Asset; + quoteAsset: Asset; + /** How much of the quote asset do you get for each unit of the base asset? + * + * This will be in terms of the *display* denoms, e.g. USD / UM. + */ + price: number; + /** The fee, in [0, 10_000]*/ + feeBps: number; + /** How much of the base asset we want to provide, in display units. */ + baseReserves: number; + /** How much of the quote asset we want to provide, in display units. */ + quoteReserves: number; +} + +const priceToPQ = ( + price: number, + pExponent: number, + qExponent: number, +): { p: Amount; q: Amount } => { + // e.g. price = X USD / UM + // basePrice = Y uUM / uUSD = X USD / UM * uUSD / USD * UM / uUM + // = X * 10 ** qExponent * 10 ** -pExponent + const basePrice = new BigNumber(price).times(new BigNumber(10).pow(qExponent - pExponent)); + + // USD / UM -> [USD, UM], with a given precision. + // Then, we want the invariant that p * UM + q * USD = constant, so + let [p, q] = basePrice.toFraction(); + // These can be higher, but this gives us some leg room. + const max_p_or_q = new BigNumber(10).pow(20); + while (p.isGreaterThanOrEqualTo(max_p_or_q) || q.isGreaterThanOrEqualTo(max_p_or_q)) { + p = p.shiftedBy(-1); + q = q.shiftedBy(-1); + } + p = p.plus(Number(p.isEqualTo(0))); + q = q.plus(Number(p.isEqualTo(0))); + return { p: pnum(BigInt(p.toFixed(0))).toAmount(), q: pnum(BigInt(q.toFixed(0))).toAmount() }; +}; + +/** + * Convert a plan into a position. + * + * Try using `rangeLiquidityPositions` or `limitOrderPosition` instead, with this method existing + * as an escape hatch in case any of those use cases aren't sufficient. + */ +export const planToPosition = (plan: PositionPlan): Position => { + const { p: rawP, q: rawQ } = priceToPQ( + plan.price, + plan.baseAsset.exponent, + plan.quoteAsset.exponent, + ); + const rawA1 = plan.baseAsset; + const rawA2 = plan.quoteAsset; + const rawR1 = pnum(plan.baseReserves, plan.baseAsset.exponent).toAmount(); + const rawR2 = pnum(plan.quoteReserves, plan.quoteAsset.exponent).toAmount(); + + const correctOrder = compareAssetId(plan.baseAsset.id, plan.quoteAsset.id) <= 0; + const [[p, q], [r1, r2], [a1, a2]] = correctOrder + ? [ + [rawP, rawQ], + [rawR1, rawR2], + [rawA1, rawA2], + ] + : [ + [rawQ, rawP], + [rawR2, rawR1], + [rawA2, rawA1], + ]; + + return new Position({ + phi: { + component: { + fee: plan.feeBps, + p: pnum(p).toAmount(), + q: pnum(q).toAmount(), + }, + pair: new TradingPair({ + asset1: a1.id, + asset2: a2.id, + }), + }, + nonce: crypto.getRandomValues(new Uint8Array(32)), + state: new PositionState({ state: PositionState_PositionStateEnum.OPENED }), + reserves: { r1, r2 }, + closeOnFill: false, + }); +}; + +/** + * A range liquidity plan provides for creating multiple positions across a range of prices. + * + * This plan attempts to distribute reserves across equally spaced price points. + * + * It needs to know the market price, to know when to switch from positions that sell the quote + * asset, to positions that buy the quote asset. + * + * All prices are in terms of quoteAsset / baseAsset, in display units. + */ +interface RangeLiquidityPlan { + baseAsset: Asset; + quoteAsset: Asset; + targetLiquidity: number; + upperPrice: number; + lowerPrice: number; + marketPrice: number; + feeBps: number; + positions: number; +} + +/** Given a plan for providing range liquidity, create all the necessary positions to accomplish the plan. */ +export const rangeLiquidityPositions = (plan: RangeLiquidityPlan): Position[] => { + // The step width is positions-1 because it's between the endpoints + // |---|---|---|---| + // 0 1 2 3 4 + // 0 1 2 3 + const stepWidth = (plan.upperPrice - plan.lowerPrice) / plan.positions; + return Array.from({ length: plan.positions }, (_, i) => { + const price = plan.lowerPrice + i * stepWidth; + + let baseReserves: number; + let quoteReserves: number; + if (price < plan.marketPrice) { + // If the price is < market price, then people *paying* that price are getting a good deal, + // and receiving the base asset in exchange, so we don't want to offer them any of that. + baseReserves = 0; + quoteReserves = plan.targetLiquidity / plan.positions; + } else { + // Conversely, when price > market price, then the people that are selling the base asset, + // receiving the quote asset in exchange are getting a good deal, so we don't want to offer that. + baseReserves = plan.targetLiquidity / plan.positions / price; + quoteReserves = 0; + } + + return planToPosition({ + baseAsset: plan.baseAsset, + quoteAsset: plan.quoteAsset, + feeBps: plan.feeBps, + price, + baseReserves, + quoteReserves, + }); + }); +}; + +/** A limit order plan attempts to buy or sell the baseAsset at a given price. + * + * This price is always in terms of quoteAsset / baseAsset. + * + * The input is the quote asset when buying, and the base asset when selling, and in display units. + */ +interface LimitOrderPlan { + buy: 'buy' | 'sell'; + price: number; + input: number; + baseAsset: Asset; + quoteAsset: Asset; +} + +export const limitOrderPosition = (plan: LimitOrderPlan): Position => { + let baseReserves: number; + let quoteReserves: number; + if (plan.buy === 'buy') { + baseReserves = 0; + quoteReserves = plan.input; + } else { + baseReserves = plan.input; + quoteReserves = 0; + } + const pos = planToPosition({ + baseAsset: plan.baseAsset, + quoteAsset: plan.quoteAsset, + feeBps: 0, + price: plan.price, + baseReserves, + quoteReserves, + }); + pos.closeOnFill = true; + return pos; +}; diff --git a/src/shared/model/connection/index.ts b/src/shared/model/connection/index.ts index 35103525..b6c0eb2a 100644 --- a/src/shared/model/connection/index.ts +++ b/src/shared/model/connection/index.ts @@ -17,10 +17,6 @@ class ConnectionStateStore { constructor() { makeAutoObservable(this); - - if (typeof window !== 'undefined') { - this.setup(); - } } private setManifest(manifest: PenumbraManifest | undefined) { diff --git a/src/shared/utils/num.ts b/src/shared/utils/num.ts new file mode 100644 index 00000000..cef4662a --- /dev/null +++ b/src/shared/utils/num.ts @@ -0,0 +1,5 @@ +/** Attempt to parse a string into a number, returning `undefined` on failure. */ +export const parseNumber = (x: string): number | undefined => { + const out = Number(x); + return isNaN(out) || x.length <= 0 ? undefined : out; +};