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; +};