From de0be08ad0ba922ecd461cebffe3ebdddb6945eb Mon Sep 17 00:00:00 2001 From: "Jason M. Hasperhoven" Date: Tue, 3 Dec 2024 18:25:03 +0400 Subject: [PATCH 01/44] Add limit order form --- src/pages/trade/ui/form-tabs.tsx | 5 +- .../trade/ui/order-form/order-form-limit.tsx | 93 +++++++++++++++++++ 2 files changed, 95 insertions(+), 3 deletions(-) create mode 100644 src/pages/trade/ui/order-form/order-form-limit.tsx diff --git a/src/pages/trade/ui/form-tabs.tsx b/src/pages/trade/ui/form-tabs.tsx index a4573d45..2cb11230 100644 --- a/src/pages/trade/ui/form-tabs.tsx +++ b/src/pages/trade/ui/form-tabs.tsx @@ -3,6 +3,7 @@ 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'; enum FormTabsType { @@ -34,9 +35,7 @@ export const FormTabs = () => {
{tab === FormTabsType.Market && } - {tab === FormTabsType.Limit && ( -
Limit order form
- )} + {tab === FormTabsType.Limit && } {tab === FormTabsType.Range && }
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..a0edc51c --- /dev/null +++ b/src/pages/trade/ui/order-form/order-form-limit.tsx @@ -0,0 +1,93 @@ +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 { Slider } from './slider'; +import { InfoRow } from './info-row'; +import { useOrderFormStore, FormType, Direction } from './store'; + +export const LimitOrderForm = observer(() => { + const { connected } = connectionStore; + const { + baseAsset, + quoteAsset, + direction, + setDirection, + submitOrder, + isLoading, + gasFee, + exchangeRate, + } = useOrderFormStore(FormType.Limit); + + const isBuy = direction === Direction.Buy; + + return ( +
+ +
+ baseAsset.setAmount(amount)} + min={0} + max={1000} + isEstimating={isBuy ? baseAsset.isEstimating : false} + isApproximately={isBuy} + denominator={baseAsset.symbol} + /> +
+
+ quoteAsset.setAmount(amount)} + isEstimating={isBuy ? false : quoteAsset.isEstimating} + isApproximately={!isBuy} + denominator={quoteAsset.symbol} + /> +
+ +
+ + +
+
+ {connected ? ( + + ) : ( + + )} +
+ {exchangeRate !== null && ( +
+ + 1 {baseAsset.symbol} ={' '} + + {exchangeRate} {quoteAsset.symbol} + + +
+ )} +
+ ); +}); From a5e2b31483a56b28e4be19cef7731a6eb64a1722 Mon Sep 17 00:00:00 2001 From: "Jason M. Hasperhoven" Date: Tue, 3 Dec 2024 19:24:11 +0400 Subject: [PATCH 02/44] Construct limit order position --- .../trade/ui/order-form/info-row-gas-fee.tsx | 12 +++ .../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 | 52 ++++++--- .../trade/ui/order-form/order-form-market.tsx | 18 +--- .../order-form/order-form-range-liquidity.tsx | 9 +- src/pages/trade/ui/order-form/order-input.tsx | 4 +- src/pages/trade/ui/order-form/store/index.ts | 101 ++++++++++++++++++ .../trade/ui/order-form/store/limit-order.ts | 78 ++++++++++++++ 9 files changed, 250 insertions(+), 38 deletions(-) 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/store/limit-order.ts 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..3cad5fdd --- /dev/null +++ b/src/pages/trade/ui/order-form/info-row-gas-fee.tsx @@ -0,0 +1,12 @@ +import { InfoRow } from './info-row'; + +export const InfoRowGasFee = ({ gasFee, symbol }: { gasFee: number | null; symbol: string }) => { + 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 index a0edc51c..74647d39 100644 --- a/src/pages/trade/ui/order-form/order-form-limit.tsx +++ b/src/pages/trade/ui/order-form/order-form-limit.tsx @@ -1,3 +1,4 @@ +import { useEffect } from 'react'; import { observer } from 'mobx-react-lite'; import { Button } from '@penumbra-zone/ui/Button'; import { Text } from '@penumbra-zone/ui/Text'; @@ -6,8 +7,11 @@ 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 { InfoRowTradingFee } from './info-row-trading-fee'; +import { InfoRowGasFee } from './info-row-gas-fee'; +import { SelectGroup } from './select-group'; import { useOrderFormStore, FormType, Direction } from './store'; +import { BuyLimitOrderOptions, SellLimitOrderOptions } from './store/limit-order'; export const LimitOrderForm = observer(() => { const { connected } = connectionStore; @@ -17,6 +21,7 @@ export const LimitOrderForm = observer(() => { direction, setDirection, submitOrder, + limitOrder, isLoading, gasFee, exchangeRate, @@ -24,9 +29,39 @@ export const LimitOrderForm = observer(() => { const isBuy = direction === Direction.Buy; + useEffect(() => { + if (exchangeRate) { + limitOrder.setMarketPrice(exchangeRate); + } + }, [exchangeRate, limitOrder]); + + useEffect(() => { + if (quoteAsset.exponent) { + limitOrder.setExponent(quoteAsset.exponent); + } + }, [quoteAsset.exponent, limitOrder]); + return (
+
+
+ +
+ + isBuy + ? limitOrder.setBuyLimitPriceOption(option as BuyLimitOrderOptions) + : limitOrder.setSellLimitPriceOption(option as SellLimitOrderOptions) + } + /> +
{
- - + +
{connected ? ( 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..6de613a4 100644 --- a/src/pages/trade/ui/order-form/order-form-market.tsx +++ b/src/pages/trade/ui/order-form/order-form-market.tsx @@ -6,7 +6,8 @@ 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 { InfoRowGasFee } from './info-row-gas-fee'; +import { InfoRowTradingFee } from './info-row-trading-fee'; import { useOrderFormStore, FormType, Direction } from './store'; export const MarketOrderForm = observer(() => { @@ -51,19 +52,8 @@ export const MarketOrderForm = observer(() => {
- - + +
{connected ? ( 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..bc930b6b 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 @@ -9,6 +9,7 @@ import { useSummary } from '../../model/useSummary'; import { OrderInput } from './order-input'; import { SelectGroup } from './select-group'; import { InfoRow } from './info-row'; +import { InfoRowGasFee } from './info-row-gas-fee'; import { useOrderFormStore, FormType } from './store'; import { UpperBoundOptions, LowerBoundOptions, FeeTierOptions } from './store/range-liquidity'; @@ -122,13 +123,7 @@ export const RangeLiquidityOrderForm = observer(() => { - +
{connected ? ( diff --git a/src/pages/trade/ui/order-form/order-input.tsx b/src/pages/trade/ui/order-form/order-input.tsx index e6bd3441..ad936bcf 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?: number | null; placeholder?: string; isEstimating?: boolean; isApproximately?: boolean; @@ -88,7 +88,7 @@ export const OrderInput = forwardRef( "[&[type='number']]:[-moz-appearance:textfield]", )} style={{ paddingRight: denomWidth + 20 }} - value={value ?? ''} + value={value && value !== 0 ? value : ''} onChange={e => onChange?.(e.target.value)} placeholder={placeholder} type='number' diff --git a/src/pages/trade/ui/order-form/store/index.ts b/src/pages/trade/ui/order-form/store/index.ts index fc85883d..38529d02 100644 --- a/src/pages/trade/ui/order-form/store/index.ts +++ b/src/pages/trade/ui/order-form/store/index.ts @@ -29,6 +29,7 @@ import { plan, planBuildBroadcast } from '../helpers'; import { usePathToMetadata } from '../../../model/use-path'; import { OrderFormAsset } from './asset'; import { RangeLiquidity } from './range-liquidity'; +import { LimitOrder } from './limit-order'; import BigNumber from 'bignumber.js'; export enum Direction { @@ -48,6 +49,7 @@ class OrderFormStore { baseAsset = new OrderFormAsset(); quoteAsset = new OrderFormAsset(); rangeLiquidity = new RangeLiquidity(); + limitOrder = new LimitOrder(); balances: BalancesResponse[] | undefined; exchangeRate: number | null = null; gasFee: number | null = null; @@ -302,6 +304,66 @@ class OrderFormStore { }; }; + constructLimitPosition = ({ + quoteAssetUnitAmount, + baseAssetUnitAmount, + }: { + quoteAssetUnitAmount: bigint; + baseAssetUnitAmount: bigint; + }) => { + const { price, marketPrice } = this.limitOrder as Required; + 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(quoteAssetUnitAmount)), + } + : { + r1: new Amount( + splitLoHi( + BigInt(round({ value: Number(quoteAssetUnitAmount) / 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; @@ -412,6 +474,41 @@ class OrderFormStore { } }; + initiatePositionTx = async (): Promise => { + try { + this.isLoading = true; + + const { price } = this.limitOrder; + if (!price) { + openToast({ + type: 'error', + message: 'Please enter a valid limit price.', + }); + return; + } + + const baseAssetUnitAmount = this.baseAsset.toUnitAmount(); + const quoteAssetUnitAmount = this.quoteAsset.toUnitAmount(); + + const positionsReq = new TransactionPlannerRequest({ + positionOpens: [ + this.constructLimitPosition({ + quoteAssetUnitAmount, + baseAssetUnitAmount, + }), + ], + source: this.quoteAsset.accountIndex, + }); + + await planBuildBroadcast('positionOpen', positionsReq); + + this.baseAsset.unsetAmount(); + this.quoteAsset.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 { @@ -471,6 +568,10 @@ class OrderFormStore { void this.initiateSwapTx(); } + if (this.type === FormType.Limit) { + void this.initiatePositionTx(); + } + if (this.type === FormType.RangeLiquidity) { void this.initiatePositionsTx(); } diff --git a/src/pages/trade/ui/order-form/store/limit-order.ts b/src/pages/trade/ui/order-form/store/limit-order.ts new file mode 100644 index 00000000..928086f5 --- /dev/null +++ b/src/pages/trade/ui/order-form/store/limit-order.ts @@ -0,0 +1,78 @@ +import { makeAutoObservable } from 'mobx'; +import { round } from '@penumbra-zone/types/round'; + +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 LimitOrder { + price?: number; + exponent?: number; + marketPrice?: number; + + constructor() { + makeAutoObservable(this); + } + + setPrice = (amount: string) => { + this.price = Number(round({ value: Number(amount), decimals: this.exponent ?? 0 })); + }; + + setBuyLimitPriceOption = (option: BuyLimitOrderOptions) => { + if (this.marketPrice) { + this.price = Number( + round({ + value: this.marketPrice * BuyLimitOrderMultipliers[option], + decimals: this.exponent ?? 0, + }), + ); + } + }; + + setSellLimitPriceOption = (option: SellLimitOrderOptions) => { + if (this.marketPrice) { + this.price = Number( + round({ + value: this.marketPrice * SellLimitOrderMultipliers[option], + decimals: this.exponent ?? 0, + }), + ); + } + }; + + setMarketPrice = (price: number) => { + this.marketPrice = price; + }; + + setExponent = (exponent: number) => { + this.exponent = exponent; + }; +} From a9117e75bde4e1a69f2a63cde2ab0aeaf34bfdee Mon Sep 17 00:00:00 2001 From: "Jason M. Hasperhoven" Date: Wed, 4 Dec 2024 18:51:48 +0400 Subject: [PATCH 03/44] Refactor order form stores & introduce pnum util --- .../order-form/order-form-range-liquidity.tsx | 20 +- src/pages/trade/ui/order-form/order-input.tsx | 2 +- src/pages/trade/ui/order-form/penum.test.ts | 13 ++ src/pages/trade/ui/order-form/pnum.ts | 107 +++++++++++ src/pages/trade/ui/order-form/store/asset.ts | 71 ++++--- src/pages/trade/ui/order-form/store/index.ts | 181 +++++++++--------- .../trade/ui/order-form/store/limit-order.ts | 26 +-- .../ui/order-form/store/range-liquidity.ts | 61 +++--- 8 files changed, 309 insertions(+), 172 deletions(-) create mode 100644 src/pages/trade/ui/order-form/penum.test.ts create mode 100644 src/pages/trade/ui/order-form/pnum.ts 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 bc930b6b..b4c8341a 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 @@ -11,7 +11,13 @@ import { SelectGroup } from './select-group'; import { InfoRow } from './info-row'; import { InfoRowGasFee } from './info-row-gas-fee'; import { useOrderFormStore, FormType } from './store'; -import { UpperBoundOptions, LowerBoundOptions, FeeTierOptions } from './store/range-liquidity'; +import { + UpperBoundOptions, + LowerBoundOptions, + FeeTierOptions, + MIN_POSITIONS, + MAX_POSITIONS, +} from './store/range-liquidity'; export const RangeLiquidityOrderForm = observer(() => { const { connected } = connectionStore; @@ -37,7 +43,7 @@ export const RangeLiquidityOrderForm = observer(() => {
quoteAsset.setAmount(amount)} denominator={quoteAsset.symbol} @@ -61,7 +67,7 @@ export const RangeLiquidityOrderForm = observer(() => {
{
{
{ + describe('from string', () => { + it('should correctly convert from string to number', () => { + const result = new FlexibleNumber('123').toNumber(); + + expect(result).toBe(123); + }); + }); +}); diff --git a/src/pages/trade/ui/order-form/pnum.ts b/src/pages/trade/ui/order-form/pnum.ts new file mode 100644 index 00000000..9930f494 --- /dev/null +++ b/src/pages/trade/ui/order-form/pnum.ts @@ -0,0 +1,107 @@ +import BigNumber from 'bignumber.js'; +import { round } from '@penumbra-zone/types/round'; +import { LoHi, joinLoHi, splitLoHi } from '@penumbra-zone/types/lo-hi'; +import { Amount } from '@penumbra-zone/protobuf/penumbra/core/num/v1/num_pb'; +import { ValueView } from '@penumbra-zone/protobuf/penumbra/core/asset/v1/asset_pb'; +import { getAmount, getDisplayDenomExponentFromValueView } from '@penumbra-zone/getters/value-view'; +import { removeTrailingZeros } from '@penumbra-zone/types/shortify'; + +/** + * pnum (penumbra number) + * + * In Penumbra a number can be in the form of a base unit (bigint, LoHi, Amount, ValueView) + * or a number in decimals for display purposes (string, number) + * + * This constructor handles all these cases and automatically converts them to a BigNumber + * - when the input is a bigint, LoHi, Amount, or ValueView, it is assumed to be in base units + * - when the input is a string or number, it is multiplied by 10^exponent and converted to base units + * + * Likewise for all methods, the outputs are + * - in base units for bigint, LoHi, Amount and ValueView + * - in display form with decimals for string and number + * + * @param input + * @param exponent + */ +function pnum( + input: string | number | LoHi | bigint | Amount | ValueView | undefined, + exponentInput = 0, +) { + let value: BigNumber; + let exponent = exponentInput; + + if (typeof input === 'string' || typeof input === 'number') { + value = new BigNumber(input).shiftedBy(exponent); + } else if (typeof input === 'bigint') { + value = new BigNumber(input.toString()); + } else if (input instanceof ValueView) { + const amount = getAmount(input); + value = new BigNumber(joinLoHi(amount.lo, amount.hi).toString()); + exponent = + input.valueView.case === 'knownAssetId' ? getDisplayDenomExponentFromValueView(input) : 0; + } else if ( + input instanceof Amount || + (typeof input === 'object' && 'lo' in input && 'hi' in input && input.lo && input.hi) + ) { + value = new BigNumber(joinLoHi(input.lo, input.hi).toString()); + } else { + value = new BigNumber(0); + } + + return { + toBigInt(): bigint { + return BigInt(value.toString()); + }, + + toBigNumber(): BigNumber { + return value; + }, + + toString(): string { + return value.shiftedBy(-exponent).toString(); + }, + + toRoundedString(): string { + return round({ value: value.shiftedBy(-exponent).toNumber(), decimals: exponent }); + }, + + toFormattedString(commas = true): string { + return value.shiftedBy(-exponent).toFormat(exponent, { + decimalSeparator: '.', + groupSeparator: commas ? ',' : '', + groupSize: 3, + }); + }, + + toNumber(): number { + const number = value.shiftedBy(-exponent).toNumber(); + if (!Number.isFinite(number)) { + throw new Error('Number exceeds JavaScript numeric limits, convert to other type instead.'); + } + return number; + }, + + toRoundedNumber(): number { + return Number(round({ value: value.shiftedBy(-exponent).toNumber(), decimals: exponent })); + }, + + toFormattedNumber(commas = true): number { + const number = value.shiftedBy(-exponent).toFormat(exponent, { + decimalSeparator: '.', + groupSeparator: commas ? ',' : '', + groupSize: 3, + }); + return Number(removeTrailingZeros(number)); + }, + + toLoHi(): LoHi { + return splitLoHi(BigInt(value.toString())); + }, + + toAmount(): Amount { + return new Amount(splitLoHi(BigInt(value.toString()))); + }, + }; +} + +export { pnum }; diff --git a/src/pages/trade/ui/order-form/store/asset.ts b/src/pages/trade/ui/order-form/store/asset.ts index db1c0c11..4cc00365 100644 --- a/src/pages/trade/ui/order-form/store/asset.ts +++ b/src/pages/trade/ui/order-form/store/asset.ts @@ -1,6 +1,4 @@ import { makeAutoObservable } from 'mobx'; -import { BigNumber } from 'bignumber.js'; -import { round } from 'lodash'; import { AssetId, Metadata, @@ -10,23 +8,19 @@ import { 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 { LoHi } from '@penumbra-zone/types/lo-hi'; import { AddressView, Address, AddressIndex, } from '@penumbra-zone/protobuf/penumbra/core/keys/v1/keys_pb'; +import { pnum } from '../pnum'; export class OrderFormAsset { - symbol: string; metadata?: Metadata; - exponent?: number; - assetId?: AssetId; balanceView?: ValueView; - accountAddress?: Address; - accountIndex?: AddressIndex; - balance?: number; - amount?: number; + addressView?: AddressView; + amount?: number | string; onAmountChangeCallback?: (asset: OrderFormAsset) => Promise; isEstimating = false; @@ -34,32 +28,48 @@ export class OrderFormAsset { 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); + this.addressView = addressView; }; - setBalanceFromBalanceView = (balanceView: ValueView): void => { - const balance = getFormattedAmtFromValueView(balanceView, true); - this.balance = parseFloat(balance.replace(/,/g, '')); - }; + get accountAddress(): Address | undefined { + return this.addressView ? getAddress(this.addressView) : undefined; + } - setAmount = (amount: string | number, callOnAmountChange = true): void => { - const prevAmount = this.amount; - const nextAmount = round(Number(amount), this.exponent); + get accountIndex(): AddressIndex | undefined { + return this.addressView ? getAddressIndex(this.addressView) : undefined; + } + + get assetId(): AssetId | undefined { + return this.metadata ? getAssetId(this.metadata) : undefined; + } + + get exponent(): number | undefined { + return this.metadata ? getDisplayDenomExponent(this.metadata) : undefined; + } - if (prevAmount !== nextAmount) { - this.amount = nextAmount; + get balance(): number | undefined { + if (!this.balanceView) { + return undefined; + } + + const balance = getFormattedAmtFromValueView(this.balanceView, true); + return parseFloat(balance.replace(/,/g, '')); + } + + get symbol(): string { + return this.metadata?.symbol ?? ''; + } + + setAmount = (amount: string | number, callOnAmountChange = true): void => { + if (this.amount !== amount) { + this.amount = amount; if (this.onAmountChangeCallback && callOnAmountChange) { void this.onAmountChangeCallback(this); @@ -80,19 +90,18 @@ export class OrderFormAsset { this.onAmountChangeCallback = callback; }; - toAmount = (): LoHi => { - return toBaseUnit(BigNumber(this.amount ?? 0), this.exponent); + toLoHi = (): LoHi => { + return pnum(this.amount, this.exponent).toLoHi(); }; - toUnitAmount = (): bigint => { - const amount = this.toAmount(); - return joinLoHi(amount.lo, amount.hi); + toBaseUnits = (): bigint => { + return pnum(this.amount, this.exponent).toBigInt(); }; toValue = (): Value => { return new Value({ assetId: this.assetId, - amount: this.toAmount(), + amount: this.toLoHi(), }); }; } diff --git a/src/pages/trade/ui/order-form/store/index.ts b/src/pages/trade/ui/order-form/store/index.ts index 38529d02..fbf0b129 100644 --- a/src/pages/trade/ui/order-form/store/index.ts +++ b/src/pages/trade/ui/order-form/store/index.ts @@ -15,13 +15,10 @@ import { 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'; @@ -30,7 +27,7 @@ import { usePathToMetadata } from '../../../model/use-path'; import { OrderFormAsset } from './asset'; import { RangeLiquidity } from './range-liquidity'; import { LimitOrder } from './limit-order'; -import BigNumber from 'bignumber.js'; +import { pnum } from '../pnum'; export enum Direction { Buy = 'Buy', @@ -71,28 +68,58 @@ class OrderFormStore { }; private setBalancesOfAssets = (): void => { - const baseAssetBalance = this.balances?.find(resp => + 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 => + ); + 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); + ); + if (quoteAssetBalance?.balanceView) { + this.quoteAsset.setBalanceView(quoteAssetBalance.balanceView); + } + if (quoteAssetBalance?.accountAddress) { + this.quoteAsset.setAccountAddress(quoteAssetBalance.accountAddress); + try { + if (!this.balances?.length) { + return; + } + + const baseAssetBalance = this.balances?.find(resp => + getAssetIdFromValueView(resp.balanceView).equals(getAssetId(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(this.quoteAsset.metadata)), + ); + if (quoteAssetBalance?.balanceView) { + this.quoteAsset.setBalanceView(quoteAssetBalance.balanceView); + } + if (quoteAssetBalance?.accountAddress) { + this.quoteAsset.setAccountAddress(quoteAssetBalance.accountAddress); + } + } catch (e) { + openToast({ + type: 'error', + message: 'Error setting form balances', + description: JSON.stringify(e), + }); } }; @@ -173,9 +200,7 @@ class OrderFormStore { return; } - const outputAmount = getFormattedAmtFromValueView(output, true); - - assetOut.setAmount(Number(outputAmount), false); + assetOut.setAmount(pnum(output).toFormattedNumber(), false); } finally { assetOut.setIsEstimating(false); } @@ -213,7 +238,7 @@ class OrderFormStore { { targetAsset: assetOut.assetId, value: { - amount: assetIn.toAmount(), + amount: assetIn.toLoHi(), assetId: assetIn.assetId, }, claimAddress: assetIn.accountAddress, @@ -240,24 +265,20 @@ class OrderFormStore { constructRangePosition = ({ positionIndex, - quoteAssetUnitAmount, - baseAssetUnitAmount, positionUnitAmount, }: { positionIndex: number; - quoteAssetUnitAmount: bigint; - baseAssetUnitAmount: bigint; positionUnitAmount: bigint; }) => { + const baseAssetBaseUnits = this.baseAsset.toBaseUnits(); + const quoteAssetBaseUnits = this.quoteAsset.toBaseUnits(); 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)); + const price = + Number(lowerBound) + + (positionIndex * (Number(upperBound) - Number(lowerBound))) / (positions ?? 1 - 1); + const priceBaseUnits = pnum(price, this.quoteAsset.exponent ?? 0).toBigInt(); // Cross-multiply exponents and prices for trading function coefficients // @@ -266,9 +287,9 @@ class OrderFormStore { // 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)); + const scale = quoteAssetBaseUnits < 1_000_000n ? 1_000_000n : 1n; + const p = pnum(quoteAssetBaseUnits * scale * priceBaseUnits).toAmount(); + const q = pnum(baseAssetBaseUnits * scale).toAmount(); // Compute reserves // Fund the position with asset 1 if its price exceeds the current price, @@ -277,14 +298,12 @@ class OrderFormStore { const reserves = price < marketPrice ? { - r1: new Amount(splitLoHi(0n)), - r2: new Amount(splitLoHi(positionUnitAmount)), + r1: pnum(0n).toAmount(), + r2: pnum(positionUnitAmount).toAmount(), } : { - r1: new Amount( - splitLoHi(BigInt(round({ value: Number(positionUnitAmount) / price, decimals: 0 }))), - ), - r2: new Amount(splitLoHi(0n)), + r1: pnum(Number(positionUnitAmount) / price).toAmount(), + r2: pnum(0n).toAmount(), }; return { @@ -304,18 +323,12 @@ class OrderFormStore { }; }; - constructLimitPosition = ({ - quoteAssetUnitAmount, - baseAssetUnitAmount, - }: { - quoteAssetUnitAmount: bigint; - baseAssetUnitAmount: bigint; - }) => { + constructLimitPosition = () => { + const baseAssetBaseUnits = this.baseAsset.toBaseUnits(); + const quoteAssetBaseUnits = this.quoteAsset.toBaseUnits(); + const { price, marketPrice } = this.limitOrder as Required; - const priceValue = BigNumber(price).multipliedBy( - new BigNumber(10).pow(this.quoteAsset.exponent ?? 0), - ); - const priceUnit = BigInt(priceValue.toFixed(0)); + const priceUnitAmount = pnum(price, this.quoteAsset.exponent ?? 0).toBigInt(); // Cross-multiply exponents and prices for trading function coefficients // @@ -324,9 +337,9 @@ class OrderFormStore { // 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)); + const scale = quoteAssetBaseUnits < 1_000_000n ? 1_000_000n : 1n; + const p = pnum(quoteAssetBaseUnits * scale * priceUnitAmount).toAmount(); + const q = pnum(baseAssetBaseUnits * scale).toAmount(); // Compute reserves // Fund the position with asset 1 if its price exceeds the current price, @@ -335,22 +348,18 @@ class OrderFormStore { const reserves = price < marketPrice ? { - r1: new Amount(splitLoHi(0n)), - r2: new Amount(splitLoHi(quoteAssetUnitAmount)), + r1: pnum(0n).toAmount(), + r2: pnum(quoteAssetBaseUnits).toAmount(), } : { - r1: new Amount( - splitLoHi( - BigInt(round({ value: Number(quoteAssetUnitAmount) / price, decimals: 0 })), - ), - ), - r2: new Amount(splitLoHi(0n)), + r1: pnum(Number(quoteAssetBaseUnits) / price).toAmount(), + r2: pnum(0n).toAmount(), }; return { position: new Position({ phi: { - component: { fee: feeTier * 100, p, q }, + component: { p, q }, pair: new TradingPair({ asset1: this.baseAsset.assetId, asset2: this.quoteAsset.assetId, @@ -359,7 +368,7 @@ class OrderFormStore { nonce: crypto.getRandomValues(new Uint8Array(32)), state: new PositionState({ state: PositionState_PositionStateEnum.OPENED }), reserves, - closeOnFill: false, + closeOnFill: true, }), }; }; @@ -385,16 +394,11 @@ class OrderFormStore { return; } - const baseAssetUnitAmount = this.baseAsset.toUnitAmount(); - const quoteAssetUnitAmount = this.quoteAsset.toUnitAmount(); - const positionUnitAmount = quoteAssetUnitAmount / BigInt(positions); - + const positionUnitAmount = this.quoteAsset.toBaseUnits() / BigInt(positions); const positionsReq = new TransactionPlannerRequest({ - positionOpens: times(positions, i => + positionOpens: times(positions, index => this.constructRangePosition({ - positionIndex: i, - quoteAssetUnitAmount, - baseAssetUnitAmount, + positionIndex: index, positionUnitAmount, }), ), @@ -448,7 +452,7 @@ class OrderFormStore { { targetAsset: assetOut.assetId, value: { - amount: assetIn.toAmount(), + amount: assetIn.toLoHi(), assetId: assetIn.assetId, }, claimAddress: assetIn.accountAddress, @@ -474,7 +478,7 @@ class OrderFormStore { } }; - initiatePositionTx = async (): Promise => { + initiateLimitPositionTx = async (): Promise => { try { this.isLoading = true; @@ -487,16 +491,8 @@ class OrderFormStore { return; } - const baseAssetUnitAmount = this.baseAsset.toUnitAmount(); - const quoteAssetUnitAmount = this.quoteAsset.toUnitAmount(); - const positionsReq = new TransactionPlannerRequest({ - positionOpens: [ - this.constructLimitPosition({ - quoteAssetUnitAmount, - baseAssetUnitAmount, - }), - ], + positionOpens: [this.constructLimitPosition()], source: this.quoteAsset.accountIndex, }); @@ -510,7 +506,7 @@ class OrderFormStore { }; // ref: https://github.com/penumbra-zone/penumbra/blob/main/crates/bin/pcli/src/command/tx/replicate/linear.rs - initiatePositionsTx = async (): Promise => { + initiateRangePositionsTx = async (): Promise => { try { this.isLoading = true; @@ -538,16 +534,11 @@ class OrderFormStore { return; } - const baseAssetUnitAmount = this.baseAsset.toUnitAmount(); - const quoteAssetUnitAmount = this.quoteAsset.toUnitAmount(); - const positionUnitAmount = quoteAssetUnitAmount / BigInt(positions); - + const positionUnitAmount = this.quoteAsset.toBaseUnits() / BigInt(positions); const positionsReq = new TransactionPlannerRequest({ positionOpens: times(positions, i => this.constructRangePosition({ positionIndex: i, - quoteAssetUnitAmount, - baseAssetUnitAmount, positionUnitAmount, }), ), @@ -569,11 +560,11 @@ class OrderFormStore { } if (this.type === FormType.Limit) { - void this.initiatePositionTx(); + void this.initiateLimitPositionTx(); } if (this.type === FormType.RangeLiquidity) { - void this.initiatePositionsTx(); + void this.initiateRangePositionsTx(); } }; } diff --git a/src/pages/trade/ui/order-form/store/limit-order.ts b/src/pages/trade/ui/order-form/store/limit-order.ts index 928086f5..a76e9d2b 100644 --- a/src/pages/trade/ui/order-form/store/limit-order.ts +++ b/src/pages/trade/ui/order-form/store/limit-order.ts @@ -1,5 +1,5 @@ import { makeAutoObservable } from 'mobx'; -import { round } from '@penumbra-zone/types/round'; +import { pnum } from '../pnum'; export enum SellLimitOrderOptions { Market = 'Market', @@ -34,7 +34,7 @@ export const SellLimitOrderMultipliers = { }; export class LimitOrder { - price?: number; + priceInput?: string | number; exponent?: number; marketPrice?: number; @@ -42,29 +42,23 @@ export class LimitOrder { makeAutoObservable(this); } - setPrice = (amount: string) => { - this.price = Number(round({ value: Number(amount), decimals: this.exponent ?? 0 })); + get price(): string { + return pnum(this.priceInput, this.exponent).toRoundedString(); + } + + setPrice = (price: string) => { + this.priceInput = price; }; setBuyLimitPriceOption = (option: BuyLimitOrderOptions) => { if (this.marketPrice) { - this.price = Number( - round({ - value: this.marketPrice * BuyLimitOrderMultipliers[option], - decimals: this.exponent ?? 0, - }), - ); + this.priceInput = this.marketPrice * BuyLimitOrderMultipliers[option]; } }; setSellLimitPriceOption = (option: SellLimitOrderOptions) => { if (this.marketPrice) { - this.price = Number( - round({ - value: this.marketPrice * SellLimitOrderMultipliers[option], - decimals: this.exponent ?? 0, - }), - ); + this.priceInput = this.marketPrice * SellLimitOrderMultipliers[option]; } }; diff --git a/src/pages/trade/ui/order-form/store/range-liquidity.ts b/src/pages/trade/ui/order-form/store/range-liquidity.ts index b4f2b495..caab0786 100644 --- a/src/pages/trade/ui/order-form/store/range-liquidity.ts +++ b/src/pages/trade/ui/order-form/store/range-liquidity.ts @@ -1,5 +1,5 @@ import { makeAutoObservable } from 'mobx'; -import { round } from '@penumbra-zone/types/round'; +import { pnum } from '../pnum'; export enum UpperBoundOptions { Market = 'Market', @@ -47,11 +47,15 @@ const FeeTierValues: Record = { '1.00%': 1, }; +export const DEFAULT_POSITIONS = 10; +export const MIN_POSITIONS = 5; +export const MAX_POSITIONS = 15; + export class RangeLiquidity { - upperBound?: number; - lowerBound?: number; + upperBoundInput?: string | number; + lowerBoundInput?: string | number; + positionsInput?: string | number; feeTier?: number; - positions?: number; marketPrice?: number; exponent?: number; onFieldChangeCallback?: () => Promise; @@ -60,23 +64,41 @@ export class RangeLiquidity { makeAutoObservable(this); } + get upperBound(): string { + return pnum(this.upperBoundInput, this.exponent).toRoundedString(); + } + + get lowerBound(): string { + return pnum(this.lowerBoundInput, this.exponent).toRoundedString(); + } + + get positions(): number | undefined { + return this.positionsInput === '' + ? undefined + : Math.max( + MIN_POSITIONS, + Math.min(MAX_POSITIONS, Number(this.positionsInput ?? DEFAULT_POSITIONS)), + ); + } + setUpperBound = (amount: string) => { - this.upperBound = Number(round({ value: Number(amount), decimals: this.exponent ?? 0 })); + this.upperBoundInput = amount; + if (this.onFieldChangeCallback) { + void this.onFieldChangeCallback(); + } }; setUpperBoundOption = (option: UpperBoundOptions) => { if (this.marketPrice) { - this.upperBound = Number( - round({ - value: this.marketPrice * UpperBoundMultipliers[option], - decimals: this.exponent ?? 0, - }), - ); + this.upperBoundInput = this.marketPrice * UpperBoundMultipliers[option]; + if (this.onFieldChangeCallback) { + void this.onFieldChangeCallback(); + } } }; setLowerBound = (amount: string) => { - this.lowerBound = Number(round({ value: Number(amount), decimals: this.exponent ?? 0 })); + this.lowerBoundInput = amount; if (this.onFieldChangeCallback) { void this.onFieldChangeCallback(); } @@ -84,15 +106,10 @@ export class RangeLiquidity { 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(); + this.lowerBoundInput = this.marketPrice * LowerBoundMultipliers[option]; + if (this.onFieldChangeCallback) { + void this.onFieldChangeCallback(); + } } }; @@ -111,7 +128,7 @@ export class RangeLiquidity { }; setPositions = (positions: number | string) => { - this.positions = Number(positions); + this.positionsInput = positions; if (this.onFieldChangeCallback) { void this.onFieldChangeCallback(); } From 74782f0d7809d06891875b8d36b0a62e06140775 Mon Sep 17 00:00:00 2001 From: "Jason M. Hasperhoven" Date: Wed, 4 Dec 2024 18:57:09 +0400 Subject: [PATCH 04/44] Update pnum description --- src/pages/trade/ui/order-form/pnum.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/pages/trade/ui/order-form/pnum.ts b/src/pages/trade/ui/order-form/pnum.ts index 9930f494..68b116e9 100644 --- a/src/pages/trade/ui/order-form/pnum.ts +++ b/src/pages/trade/ui/order-form/pnum.ts @@ -10,11 +10,11 @@ import { removeTrailingZeros } from '@penumbra-zone/types/shortify'; * pnum (penumbra number) * * In Penumbra a number can be in the form of a base unit (bigint, LoHi, Amount, ValueView) - * or a number in decimals for display purposes (string, number) + * or a number with decimals for display purposes (string, number) * - * This constructor handles all these cases and automatically converts them to a BigNumber - * - when the input is a bigint, LoHi, Amount, or ValueView, it is assumed to be in base units - * - when the input is a string or number, it is multiplied by 10^exponent and converted to base units + * This function handles all these cases automatically internally + * - when input is a bigint, LoHi, Amount, or ValueView, it is assumed to be in base units + * - when input is a string or number, it is multiplied by 10^exponent and converted to base units * * Likewise for all methods, the outputs are * - in base units for bigint, LoHi, Amount and ValueView From 2df69240134d577fea2f0efeb8ca9339376ccab1 Mon Sep 17 00:00:00 2001 From: "Jason M. Hasperhoven" Date: Thu, 5 Dec 2024 17:46:23 +0400 Subject: [PATCH 05/44] Use remote pnum --- src/pages/trade/ui/order-form/penum.test.ts | 13 --- src/pages/trade/ui/order-form/pnum.ts | 107 ------------------ src/pages/trade/ui/order-form/store/asset.ts | 2 +- src/pages/trade/ui/order-form/store/index.ts | 4 +- .../trade/ui/order-form/store/limit-order.ts | 2 +- .../ui/order-form/store/range-liquidity.ts | 2 +- 6 files changed, 5 insertions(+), 125 deletions(-) delete mode 100644 src/pages/trade/ui/order-form/penum.test.ts delete mode 100644 src/pages/trade/ui/order-form/pnum.ts diff --git a/src/pages/trade/ui/order-form/penum.test.ts b/src/pages/trade/ui/order-form/penum.test.ts deleted file mode 100644 index af76166d..00000000 --- a/src/pages/trade/ui/order-form/penum.test.ts +++ /dev/null @@ -1,13 +0,0 @@ -/* eslint-disable no-bitwise -- expected bitwise operations */ -import { describe, expect, it } from 'vitest'; -import { FlexibleNumber } from './flexible-number'; - -describe('FlexibleNumber', () => { - describe('from string', () => { - it('should correctly convert from string to number', () => { - const result = new FlexibleNumber('123').toNumber(); - - expect(result).toBe(123); - }); - }); -}); diff --git a/src/pages/trade/ui/order-form/pnum.ts b/src/pages/trade/ui/order-form/pnum.ts deleted file mode 100644 index 68b116e9..00000000 --- a/src/pages/trade/ui/order-form/pnum.ts +++ /dev/null @@ -1,107 +0,0 @@ -import BigNumber from 'bignumber.js'; -import { round } from '@penumbra-zone/types/round'; -import { LoHi, joinLoHi, splitLoHi } from '@penumbra-zone/types/lo-hi'; -import { Amount } from '@penumbra-zone/protobuf/penumbra/core/num/v1/num_pb'; -import { ValueView } from '@penumbra-zone/protobuf/penumbra/core/asset/v1/asset_pb'; -import { getAmount, getDisplayDenomExponentFromValueView } from '@penumbra-zone/getters/value-view'; -import { removeTrailingZeros } from '@penumbra-zone/types/shortify'; - -/** - * pnum (penumbra number) - * - * In Penumbra a number can be in the form of a base unit (bigint, LoHi, Amount, ValueView) - * or a number with decimals for display purposes (string, number) - * - * This function handles all these cases automatically internally - * - when input is a bigint, LoHi, Amount, or ValueView, it is assumed to be in base units - * - when input is a string or number, it is multiplied by 10^exponent and converted to base units - * - * Likewise for all methods, the outputs are - * - in base units for bigint, LoHi, Amount and ValueView - * - in display form with decimals for string and number - * - * @param input - * @param exponent - */ -function pnum( - input: string | number | LoHi | bigint | Amount | ValueView | undefined, - exponentInput = 0, -) { - let value: BigNumber; - let exponent = exponentInput; - - if (typeof input === 'string' || typeof input === 'number') { - value = new BigNumber(input).shiftedBy(exponent); - } else if (typeof input === 'bigint') { - value = new BigNumber(input.toString()); - } else if (input instanceof ValueView) { - const amount = getAmount(input); - value = new BigNumber(joinLoHi(amount.lo, amount.hi).toString()); - exponent = - input.valueView.case === 'knownAssetId' ? getDisplayDenomExponentFromValueView(input) : 0; - } else if ( - input instanceof Amount || - (typeof input === 'object' && 'lo' in input && 'hi' in input && input.lo && input.hi) - ) { - value = new BigNumber(joinLoHi(input.lo, input.hi).toString()); - } else { - value = new BigNumber(0); - } - - return { - toBigInt(): bigint { - return BigInt(value.toString()); - }, - - toBigNumber(): BigNumber { - return value; - }, - - toString(): string { - return value.shiftedBy(-exponent).toString(); - }, - - toRoundedString(): string { - return round({ value: value.shiftedBy(-exponent).toNumber(), decimals: exponent }); - }, - - toFormattedString(commas = true): string { - return value.shiftedBy(-exponent).toFormat(exponent, { - decimalSeparator: '.', - groupSeparator: commas ? ',' : '', - groupSize: 3, - }); - }, - - toNumber(): number { - const number = value.shiftedBy(-exponent).toNumber(); - if (!Number.isFinite(number)) { - throw new Error('Number exceeds JavaScript numeric limits, convert to other type instead.'); - } - return number; - }, - - toRoundedNumber(): number { - return Number(round({ value: value.shiftedBy(-exponent).toNumber(), decimals: exponent })); - }, - - toFormattedNumber(commas = true): number { - const number = value.shiftedBy(-exponent).toFormat(exponent, { - decimalSeparator: '.', - groupSeparator: commas ? ',' : '', - groupSize: 3, - }); - return Number(removeTrailingZeros(number)); - }, - - toLoHi(): LoHi { - return splitLoHi(BigInt(value.toString())); - }, - - toAmount(): Amount { - return new Amount(splitLoHi(BigInt(value.toString()))); - }, - }; -} - -export { pnum }; diff --git a/src/pages/trade/ui/order-form/store/asset.ts b/src/pages/trade/ui/order-form/store/asset.ts index 4cc00365..4653d4df 100644 --- a/src/pages/trade/ui/order-form/store/asset.ts +++ b/src/pages/trade/ui/order-form/store/asset.ts @@ -8,13 +8,13 @@ import { 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 { pnum } from '@penumbra-zone/types/pnum'; import { LoHi } from '@penumbra-zone/types/lo-hi'; import { AddressView, Address, AddressIndex, } from '@penumbra-zone/protobuf/penumbra/core/keys/v1/keys_pb'; -import { pnum } from '../pnum'; export class OrderFormAsset { metadata?: Metadata; diff --git a/src/pages/trade/ui/order-form/store/index.ts b/src/pages/trade/ui/order-form/store/index.ts index fbf0b129..abda9c8b 100644 --- a/src/pages/trade/ui/order-form/store/index.ts +++ b/src/pages/trade/ui/order-form/store/index.ts @@ -19,6 +19,7 @@ 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 { pnum } from '@penumbra-zone/types/pnum'; import { openToast } from '@penumbra-zone/ui/Toast'; import { penumbra } from '@/shared/const/penumbra'; import { useBalances } from '@/shared/api/balances'; @@ -27,7 +28,6 @@ import { usePathToMetadata } from '../../../model/use-path'; import { OrderFormAsset } from './asset'; import { RangeLiquidity } from './range-liquidity'; import { LimitOrder } from './limit-order'; -import { pnum } from '../pnum'; export enum Direction { Buy = 'Buy', @@ -200,7 +200,7 @@ class OrderFormStore { return; } - assetOut.setAmount(pnum(output).toFormattedNumber(), false); + assetOut.setAmount(pnum(output).toFormattedString(), false); } finally { assetOut.setIsEstimating(false); } diff --git a/src/pages/trade/ui/order-form/store/limit-order.ts b/src/pages/trade/ui/order-form/store/limit-order.ts index a76e9d2b..68fda3f5 100644 --- a/src/pages/trade/ui/order-form/store/limit-order.ts +++ b/src/pages/trade/ui/order-form/store/limit-order.ts @@ -1,5 +1,5 @@ import { makeAutoObservable } from 'mobx'; -import { pnum } from '../pnum'; +import { pnum } from '@penumbra-zone/types/pnum'; export enum SellLimitOrderOptions { Market = 'Market', diff --git a/src/pages/trade/ui/order-form/store/range-liquidity.ts b/src/pages/trade/ui/order-form/store/range-liquidity.ts index caab0786..88ce5b90 100644 --- a/src/pages/trade/ui/order-form/store/range-liquidity.ts +++ b/src/pages/trade/ui/order-form/store/range-liquidity.ts @@ -1,5 +1,5 @@ import { makeAutoObservable } from 'mobx'; -import { pnum } from '../pnum'; +import { pnum } from '@penumbra-zone/types/pnum'; export enum UpperBoundOptions { Market = 'Market', From aae366ee0bbb71bcd19ada9da0743915282f7fc1 Mon Sep 17 00:00:00 2001 From: "Jason M. Hasperhoven" Date: Thu, 5 Dec 2024 17:52:49 +0400 Subject: [PATCH 06/44] Fix lint issues --- .../ui/order-form/order-form-range-liquidity.tsx | 2 +- src/pages/trade/ui/order-form/slider.tsx | 2 +- src/pages/trade/ui/order-form/store/index.ts | 11 ++++++----- 3 files changed, 8 insertions(+), 7 deletions(-) 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 b4c8341a..3e8768e4 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 @@ -110,7 +110,7 @@ export const RangeLiquidityOrderForm = observer(() => {
+ const baseAssetBalance = this.balances.find(resp => getAssetIdFromValueView(resp.balanceView).equals(getAssetId(this.baseAsset.metadata)), ); if (baseAssetBalance?.balanceView) { @@ -105,7 +105,7 @@ class OrderFormStore { this.baseAsset.setAccountAddress(baseAssetBalance.accountAddress); } - const quoteAssetBalance = this.balances?.find(resp => + const quoteAssetBalance = this.balances.find(resp => getAssetIdFromValueView(resp.balanceView).equals(getAssetId(this.quoteAsset.metadata)), ); if (quoteAssetBalance?.balanceView) { @@ -328,7 +328,8 @@ class OrderFormStore { const quoteAssetBaseUnits = this.quoteAsset.toBaseUnits(); const { price, marketPrice } = this.limitOrder as Required; - const priceUnitAmount = pnum(price, this.quoteAsset.exponent ?? 0).toBigInt(); + const priceNumber = Number(price); + const priceUnitAmount = pnum(priceNumber, this.quoteAsset.exponent ?? 0).toBigInt(); // Cross-multiply exponents and prices for trading function coefficients // @@ -346,13 +347,13 @@ class OrderFormStore { // matching the target per-position amount of asset 2. Otherwise, fund with // asset 2 to avoid immediate arbitrage. const reserves = - price < marketPrice + priceNumber < marketPrice ? { r1: pnum(0n).toAmount(), r2: pnum(quoteAssetBaseUnits).toAmount(), } : { - r1: pnum(Number(quoteAssetBaseUnits) / price).toAmount(), + r1: pnum(Number(quoteAssetBaseUnits) / priceNumber).toAmount(), r2: pnum(0n).toAmount(), }; From 8d54da750723bd262253a13fdf17fe4482d81a3b Mon Sep 17 00:00:00 2001 From: "Jason M. Hasperhoven" Date: Fri, 6 Dec 2024 15:45:12 +0400 Subject: [PATCH 07/44] Fix handling of input fields numbers --- .../ui/order-form/order-form-range-liquidity.tsx | 2 +- src/pages/trade/ui/order-form/order-input.tsx | 6 +++--- src/pages/trade/ui/order-form/store/asset.ts | 6 ++---- src/pages/trade/ui/order-form/store/index.ts | 8 ++++++-- src/pages/trade/ui/order-form/store/limit-order.ts | 7 +++++-- .../trade/ui/order-form/store/range-liquidity.ts | 14 ++++++++++---- src/pages/trade/ui/pair-selector.tsx | 10 +++++++++- 7 files changed, 36 insertions(+), 17 deletions(-) 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 3e8768e4..c6fffb65 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 @@ -67,7 +67,7 @@ export const RangeLiquidityOrderForm = observer(() => {
( '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 !== 0 ? value : ''} + value={value !== 0 ? value : ''} onChange={e => onChange?.(e.target.value)} placeholder={placeholder} type='number' @@ -103,7 +103,7 @@ export const OrderInput = forwardRef( ≈
- +
diff --git a/src/pages/trade/ui/order-form/store/asset.ts b/src/pages/trade/ui/order-form/store/asset.ts index 4653d4df..6ec4c833 100644 --- a/src/pages/trade/ui/order-form/store/asset.ts +++ b/src/pages/trade/ui/order-form/store/asset.ts @@ -7,7 +7,6 @@ import { } 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 { pnum } from '@penumbra-zone/types/pnum'; import { LoHi } from '@penumbra-zone/types/lo-hi'; import { @@ -54,13 +53,12 @@ export class OrderFormAsset { return this.metadata ? getDisplayDenomExponent(this.metadata) : undefined; } - get balance(): number | undefined { + get balance(): string | undefined { if (!this.balanceView) { return undefined; } - const balance = getFormattedAmtFromValueView(this.balanceView, true); - return parseFloat(balance.replace(/,/g, '')); + return pnum(this.balanceView).toFormattedString(); } get symbol(): string { diff --git a/src/pages/trade/ui/order-form/store/index.ts b/src/pages/trade/ui/order-form/store/index.ts index 5efed6b6..236f3521 100644 --- a/src/pages/trade/ui/order-form/store/index.ts +++ b/src/pages/trade/ui/order-form/store/index.ts @@ -149,8 +149,12 @@ class OrderFormStore { } catch (e) { if ( e instanceof Error && - e.name !== 'PenumbraProviderNotAvailableError' && - e.name !== 'PenumbraProviderNotConnectedError' + ![ + 'ConnectError', + 'PenumbraNotInstalledError', + 'PenumbraProviderNotAvailableError', + 'PenumbraProviderNotConnectedError', + ].includes(e.name) ) { openToast({ type: 'error', diff --git a/src/pages/trade/ui/order-form/store/limit-order.ts b/src/pages/trade/ui/order-form/store/limit-order.ts index 68fda3f5..82d19ff2 100644 --- a/src/pages/trade/ui/order-form/store/limit-order.ts +++ b/src/pages/trade/ui/order-form/store/limit-order.ts @@ -42,8 +42,11 @@ export class LimitOrder { makeAutoObservable(this); } - get price(): string { - return pnum(this.priceInput, this.exponent).toRoundedString(); + get price(): number { + if (this.priceInput === undefined || this.priceInput === '') { + return ''; + } + return pnum(this.priceInput, this.exponent).toRoundedNumber(); } setPrice = (price: string) => { diff --git a/src/pages/trade/ui/order-form/store/range-liquidity.ts b/src/pages/trade/ui/order-form/store/range-liquidity.ts index 88ce5b90..c33bb69e 100644 --- a/src/pages/trade/ui/order-form/store/range-liquidity.ts +++ b/src/pages/trade/ui/order-form/store/range-liquidity.ts @@ -64,12 +64,18 @@ export class RangeLiquidity { makeAutoObservable(this); } - get upperBound(): string { - return pnum(this.upperBoundInput, this.exponent).toRoundedString(); + get upperBound(): string | number { + if (this.upperBoundInput === undefined || this.upperBoundInput === '') { + return ''; + } + return pnum(this.upperBoundInput, this.exponent).toRoundedNumber(); } - get lowerBound(): string { - return pnum(this.lowerBoundInput, this.exponent).toRoundedString(); + get lowerBound(): string | number { + if (this.lowerBoundInput === undefined || this.lowerBoundInput === '') { + return ''; + } + return pnum(this.lowerBoundInput, this.exponent).toRoundedNumber(); } get positions(): number | undefined { 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)}
; } From 178013fd1fc965dad39946780742f8272371dfb4 Mon Sep 17 00:00:00 2001 From: "Jason M. Hasperhoven" Date: Mon, 9 Dec 2024 22:38:25 +0400 Subject: [PATCH 08/44] tmp commit --- .../order-form/order-form-range-liquidity.tsx | 65 ++++-- src/pages/trade/ui/order-form/store/asset.ts | 4 +- src/pages/trade/ui/order-form/store/index.ts | 204 ++++------------- .../ui/order-form/store/range-liquidity.ts | 208 +++++++++++++++++- 4 files changed, 288 insertions(+), 193 deletions(-) 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 c6fffb65..aa07509c 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 @@ -24,7 +24,8 @@ export const RangeLiquidityOrderForm = observer(() => { const { baseAsset, quoteAsset, rangeLiquidity, submitOrder, isLoading, gasFee, exchangeRate } = useOrderFormStore(FormType.RangeLiquidity); const { data } = useSummary('1d'); - const price = data && 'price' in data ? data.price : undefined; + // const price = data && 'price' in data ? data.price : undefined; + const price = 1; useEffect(() => { if (price) { @@ -33,10 +34,10 @@ export const RangeLiquidityOrderForm = observer(() => { }, [price, rangeLiquidity]); useEffect(() => { - if (quoteAsset.exponent) { - rangeLiquidity.setExponent(quoteAsset.exponent); + if (baseAsset && quoteAsset) { + rangeLiquidity.setAssets(baseAsset, quoteAsset); } - }, [quoteAsset.exponent, rangeLiquidity]); + }, [baseAsset, quoteAsset, rangeLiquidity]); return (
@@ -44,24 +45,35 @@ export const RangeLiquidityOrderForm = observer(() => {
quoteAsset.setAmount(amount)} + value={rangeLiquidity.target} + onChange={target => rangeLiquidity.setTarget(target)} denominator={quoteAsset.symbol} />
-
- - Available Balance - - +
+
+
+ + {baseAsset.balance} {baseAsset.symbol} + +
+ +
@@ -127,9 +139,20 @@ export const RangeLiquidityOrderForm = observer(() => {
- - - + + +
{connected ? ( diff --git a/src/pages/trade/ui/order-form/store/asset.ts b/src/pages/trade/ui/order-form/store/asset.ts index 6ec4c833..93dbaaba 100644 --- a/src/pages/trade/ui/order-form/store/asset.ts +++ b/src/pages/trade/ui/order-form/store/asset.ts @@ -58,7 +58,9 @@ export class OrderFormAsset { return undefined; } - return pnum(this.balanceView).toFormattedString(); + return pnum(this.balanceView).toFormattedString({ + decimals: this.exponent, + }); } get symbol(): string { diff --git a/src/pages/trade/ui/order-form/store/index.ts b/src/pages/trade/ui/order-form/store/index.ts index 236f3521..6766475d 100644 --- a/src/pages/trade/ui/order-form/store/index.ts +++ b/src/pages/trade/ui/order-form/store/index.ts @@ -1,7 +1,7 @@ import { useEffect } from 'react'; import { makeAutoObservable } from 'mobx'; import debounce from 'lodash/debounce'; -import times from 'lodash/times'; +import BigNumber from 'bignumber.js'; import { SimulationService } from '@penumbra-zone/protobuf'; import { BalancesResponse, @@ -18,7 +18,6 @@ import { 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 { pnum } from '@penumbra-zone/types/pnum'; import { openToast } from '@penumbra-zone/ui/Toast'; import { penumbra } from '@/shared/const/penumbra'; @@ -176,9 +175,6 @@ class OrderFormStore { 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(); @@ -221,8 +217,7 @@ class OrderFormStore { return; } - const outputAmount = getFormattedAmtFromValueView(output, true); - this.exchangeRate = Number(outputAmount); + this.exchangeRate = pnum(output).toRoundedNumber(); }; calculateMarketGasFee = async (): Promise => { @@ -263,77 +258,16 @@ class OrderFormStore { }, }); - const feeAmount = getFormattedAmtFromValueView(feeValueView, true); - this.gasFee = Number(feeAmount); + this.gasFee = pnum(feeValueView).toRoundedNumber(); }; - constructRangePosition = ({ - positionIndex, - positionUnitAmount, - }: { - positionIndex: number; - positionUnitAmount: bigint; - }) => { - const baseAssetBaseUnits = this.baseAsset.toBaseUnits(); - const quoteAssetBaseUnits = this.quoteAsset.toBaseUnits(); - const { lowerBound, upperBound, positions, marketPrice, feeTier } = this - .rangeLiquidity as Required; - - const price = - Number(lowerBound) + - (positionIndex * (Number(upperBound) - Number(lowerBound))) / (positions ?? 1 - 1); - const priceBaseUnits = pnum(price, this.quoteAsset.exponent ?? 0).toBigInt(); - - // 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 = quoteAssetBaseUnits < 1_000_000n ? 1_000_000n : 1n; - const p = pnum(quoteAssetBaseUnits * scale * priceBaseUnits).toAmount(); - const q = pnum(baseAssetBaseUnits * scale).toAmount(); - - // 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: pnum(0n).toAmount(), - r2: pnum(positionUnitAmount).toAmount(), - } - : { - r1: pnum(Number(positionUnitAmount) / price).toAmount(), - r2: pnum(0n).toAmount(), - }; - - 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, - }), - }; - }; - - constructLimitPosition = () => { - const baseAssetBaseUnits = this.baseAsset.toBaseUnits(); - const quoteAssetBaseUnits = this.quoteAsset.toBaseUnits(); + // ref: https://github.com/penumbra-zone/penumbra/blob/main/crates/bin/pcli/src/command/tx/replicate/linear.rs + buildLimitPosition = (): Position => { + const baseAssetExponentUnits = BigInt(10) ** BigInt(this.baseAsset.exponent ?? 0); + const quoteAssetExponentUnits = BigInt(10) ** BigInt(this.quoteAsset.exponent ?? 0); const { price, marketPrice } = this.limitOrder as Required; - const priceNumber = Number(price); - const priceUnitAmount = pnum(priceNumber, this.quoteAsset.exponent ?? 0).toBigInt(); + const positionPrice = Number(price); // Cross-multiply exponents and prices for trading function coefficients // @@ -342,88 +276,43 @@ class OrderFormStore { // 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 = quoteAssetBaseUnits < 1_000_000n ? 1_000_000n : 1n; - const p = pnum(quoteAssetBaseUnits * scale * priceUnitAmount).toAmount(); - const q = pnum(baseAssetBaseUnits * scale).toAmount(); + const scale = quoteAssetExponentUnits < 1_000_000n ? 1_000_000n : 1n; + + const p = pnum( + BigNumber((quoteAssetExponentUnits * scale).toString()) + .times(BigNumber(positionPrice)) + .toFixed(0), + ).toAmount(); + const q = pnum(baseAssetExponentUnits * scale).toAmount(); // 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 = - priceNumber < marketPrice + positionPrice < marketPrice ? { r1: pnum(0n).toAmount(), - r2: pnum(quoteAssetBaseUnits).toAmount(), + r2: pnum(this.quoteAsset.amount).toAmount(), } : { - r1: pnum(Number(quoteAssetBaseUnits) / priceNumber).toAmount(), + r1: pnum(Number(this.quoteAsset.amount) / positionPrice).toAmount(), r2: pnum(0n).toAmount(), }; - return { - position: new Position({ - phi: { - component: { 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: true, - }), - }; - }; - - 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 positionUnitAmount = this.quoteAsset.toBaseUnits() / BigInt(positions); - const positionsReq = new TransactionPlannerRequest({ - positionOpens: times(positions, index => - this.constructRangePosition({ - positionIndex: index, - positionUnitAmount, + return new Position({ + phi: { + component: { p, q }, + pair: new TradingPair({ + asset1: this.baseAsset.assetId, + asset2: this.quoteAsset.assetId, }), - ), - 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, - }, }, + nonce: crypto.getRandomValues(new Uint8Array(32)), + state: new PositionState({ state: PositionState_PositionStateEnum.OPENED }), + reserves, + closeOnFill: true, }); - - const feeAmount = getFormattedAmtFromValueView(feeValueView, true); - this.gasFee = Number(feeAmount); }; calculateGasFee = async (): Promise => { @@ -431,9 +320,9 @@ class OrderFormStore { await this.calculateMarketGasFee(); } - if (this.type === FormType.RangeLiquidity) { - await this.calculateRangeLiquidityGasFee(); - } + // if (this.type === FormType.RangeLiquidity) { + // await this.calculateRangeLiquidityGasFee(); + // } }; initiateSwapTx = async (): Promise => { @@ -497,7 +386,11 @@ class OrderFormStore { } const positionsReq = new TransactionPlannerRequest({ - positionOpens: [this.constructLimitPosition()], + positionOpens: [ + { + position: this.buildLimitPosition(), + }, + ], source: this.quoteAsset.accountIndex, }); @@ -510,20 +403,14 @@ class OrderFormStore { } }; - // ref: https://github.com/penumbra-zone/penumbra/blob/main/crates/bin/pcli/src/command/tx/replicate/linear.rs initiateRangePositionsTx = async (): Promise => { try { this.isLoading = true; - const { lowerBound, upperBound, positions, marketPrice, feeTier } = this.rangeLiquidity; - if ( - !this.quoteAsset.amount || - !lowerBound || - !upperBound || - !positions || - !marketPrice || - !feeTier - ) { + const { target, lowerBound, upperBound, positions, marketPrice, feeTier } = + this.rangeLiquidity; + + if (!target || !lowerBound || !upperBound || !positions || !marketPrice || !feeTier) { openToast({ type: 'error', message: 'Please enter a valid range.', @@ -539,14 +426,9 @@ class OrderFormStore { return; } - const positionUnitAmount = this.quoteAsset.toBaseUnits() / BigInt(positions); + const linearPositions = this.rangeLiquidity.buildPositions(); const positionsReq = new TransactionPlannerRequest({ - positionOpens: times(positions, i => - this.constructRangePosition({ - positionIndex: i, - positionUnitAmount, - }), - ), + positionOpens: linearPositions.map(position => ({ position })), source: this.quoteAsset.accountIndex, }); diff --git a/src/pages/trade/ui/order-form/store/range-liquidity.ts b/src/pages/trade/ui/order-form/store/range-liquidity.ts index c33bb69e..216f3a53 100644 --- a/src/pages/trade/ui/order-form/store/range-liquidity.ts +++ b/src/pages/trade/ui/order-form/store/range-liquidity.ts @@ -1,5 +1,19 @@ import { makeAutoObservable } from 'mobx'; import { pnum } from '@penumbra-zone/types/pnum'; +import { + Position, + PositionOpen, + PositionState, + PositionState_PositionStateEnum, + TradingPair, +} from '@penumbra-zone/protobuf/penumbra/core/component/dex/v1/dex_pb'; +import { TransactionPlannerRequest } from '@penumbra-zone/protobuf/penumbra/view/v1/view_pb'; +import { OrderFormAsset } from './asset'; +import BigNumber from 'bignumber.js'; +import { plan } from '../helpers'; +import { ValueView } from '@penumbra-zone/protobuf/penumbra/core/asset/v1/asset_pb'; + +window.BigNumber = BigNumber; export enum UpperBoundOptions { Market = 'Market', @@ -52,30 +66,35 @@ export const MIN_POSITIONS = 5; export const MAX_POSITIONS = 15; export class RangeLiquidity { + target?: number | string = ''; upperBoundInput?: string | number; lowerBoundInput?: string | number; positionsInput?: string | number; feeTier?: number; marketPrice?: number; - exponent?: number; + baseAsset?: OrderFormAsset; + quoteAsset?: OrderFormAsset; + gasFee?: number; onFieldChangeCallback?: () => Promise; constructor() { makeAutoObservable(this); + + this.onFieldChangeCallback = this.calculateFeesAndAmounts; } get upperBound(): string | number { - if (this.upperBoundInput === undefined || this.upperBoundInput === '') { + if (this.upperBoundInput === undefined || this.upperBoundInput === '' || !this.quoteAsset) { return ''; } - return pnum(this.upperBoundInput, this.exponent).toRoundedNumber(); + return pnum(this.upperBoundInput, this.quoteAsset.exponent).toRoundedNumber(); } get lowerBound(): string | number { - if (this.lowerBoundInput === undefined || this.lowerBoundInput === '') { + if (this.lowerBoundInput === undefined || this.lowerBoundInput === '' || !this.quoteAsset) { return ''; } - return pnum(this.lowerBoundInput, this.exponent).toRoundedNumber(); + return pnum(this.lowerBoundInput, this.quoteAsset.exponent).toRoundedNumber(); } get positions(): number | undefined { @@ -87,6 +106,174 @@ export class RangeLiquidity { ); } + // logic from: /penumbra/core/crates/bin/pcli/src/command/tx/replicate/linear.rs + buildPositions = (): Position[] => { + if ( + !this.positions || + !this.target || + !this.baseAsset || + !this.quoteAsset || + !this.baseAsset.exponent || + !this.quoteAsset.exponent || + !this.marketPrice + ) { + return []; + } + + // The step width is positions-1 because it's between the endpoints + // |---|---|---|---| + // 0 1 2 3 4 + // 0 1 2 3 + const stepWidth = (Number(this.upperBound) - Number(this.lowerBound)) / (this.positions - 1); + + // We are treating quote asset as the numeraire and want to have an even spread + // of quote asset value across all positions. + // const targetInput = pnum(this.target, this.quoteAsset.exponent).toBigInt(); + const quoteAssetAmountPerPosition = Number(this.target) / this.positions; + + const baseAssetExponentUnits = BigInt(10) ** BigInt(this.baseAsset.exponent); + const quoteAssetExponentUnits = BigInt(10) ** BigInt(this.quoteAsset.exponent); + + const positions = Array.from({ length: this.positions }, (_, i) => { + const positionPrice = Number(this.lowerBound) + stepWidth * i; + + // 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 = quoteAssetExponentUnits < 1_000_000n ? 1_000_000n : 1n; + + const p = pnum( + BigNumber((quoteAssetExponentUnits * scale).toString()) + .times(BigNumber(positionPrice)) + .toFixed(0), + ).toAmount(); + // const p = pnum( + // BigNumber(positionPrice) + // .shiftedBy(this.quoteAsset.exponent ?? 0) + // .times(scale.toString()) + // .toFixed(0), + // ).toAmount(); + + const q = pnum(baseAssetExponentUnits * scale).toAmount(); + + console.log( + 'TCL: RangeLiquidity -> quoteAssetAmountPerPosition', + quoteAssetAmountPerPosition, + ); + + // Compute reserves + const reserves = + positionPrice < this.marketPrice + ? // If the position's price is _less_ than the current price, fund it with asset 2 + // so the position isn't immediately arbitraged. + { + r1: pnum(0n).toAmount(), + r2: pnum(quoteAssetAmountPerPosition, this.quoteAsset?.exponent).toAmount(), + } + : { + // If the position's price is _greater_ than the current price, fund it with + // an equivalent amount of asset 1 as the target per-position amount of asset 2. + r1: pnum( + quoteAssetAmountPerPosition / positionPrice, + this.baseAsset?.exponent, + ).toAmount(), + r2: pnum(0n).toAmount(), + }; + + const fee = (this.feeTier ?? 0.1) * 100; + + return new Position({ + phi: { + component: { fee, 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, + }); + }); + + return positions; + }; + + calculateFeesAndAmounts = async (): Promise => { + const positions = this.buildPositions(); + + if (!positions.length) { + return; + } + + // const baseAssetExponentUnits = BigInt(10) ** BigInt(this.baseAsset.exponent); + // const quoteAssetExponentUnits = BigInt(10) ** BigInt(this.quoteAsset.exponent); + + console.table({ + baseAsset: this.baseAsset?.symbol, + quoteAsset: this.quoteAsset?.symbol, + baseAssetExponent: this.baseAsset?.exponent, + quoteAssetExponent: this.quoteAsset?.exponent, + positions: this.positions, + target: this.target, + upperBound: this.upperBound, + lowerBound: this.lowerBound, + feeTier: this.feeTier, + }); + + console.table( + positions.map(position => ({ + p: pnum(position.phi?.component?.p).toRoundedNumber(), + q: pnum(position.phi?.component?.q).toRoundedNumber(), + r1: pnum(position.reserves?.r1).toRoundedNumber(), + r2: pnum(position.reserves?.r2).toRoundedNumber(), + })), + ); + + const { baseAmount, quoteAmount } = positions.reduce( + (amounts, position: Position) => { + return { + baseAmount: amounts.baseAmount + pnum(position.reserves?.r1).toBigInt(), + quoteAmount: amounts.quoteAmount + pnum(position.reserves?.r2).toBigInt(), + }; + }, + { baseAmount: 0n, quoteAmount: 0n }, + ); + console.log( + 'TCL: total baseAmount', + pnum(baseAmount, this.baseAsset.exponent).toRoundedNumber(), + ); + console.log( + 'TCL: total quoteAmount', + pnum(quoteAmount, this.quoteAsset.exponent).toRoundedNumber(), + ); + + this.baseAsset?.setAmount(pnum(baseAmount, this.baseAsset.exponent).toRoundedNumber()); + this.quoteAsset?.setAmount(pnum(quoteAmount, this.quoteAsset.exponent).toRoundedNumber()); + + const positionsReq = new TransactionPlannerRequest({ + positionOpens: positions.map(position => ({ position })), + source: this.quoteAsset?.accountIndex, + }); + + const txPlan = await plan(positionsReq); + const fee = txPlan.transactionParameters?.fee; + + this.gasFee = pnum(fee?.amount, this.baseAsset?.exponent).toRoundedNumber(); + }; + + setTarget = (target: string | number): void => { + this.target = target; + if (this.onFieldChangeCallback) { + void this.onFieldChangeCallback(); + } + }; + setUpperBound = (amount: string) => { this.upperBoundInput = amount; if (this.onFieldChangeCallback) { @@ -144,11 +331,12 @@ export class RangeLiquidity { this.marketPrice = price; }; - setExponent = (exponent: number) => { - this.exponent = exponent; + setAssets = (baseAsset: OrderFormAsset, quoteAsset: OrderFormAsset): void => { + this.baseAsset = baseAsset; + this.quoteAsset = quoteAsset; }; - onFieldChange = (callback: () => Promise): void => { - this.onFieldChangeCallback = callback; - }; + // onFieldChange = (callback: () => Promise): void => { + // this.onFieldChangeCallback = callback; + // }; } From ffcdd28e176855be79f6813fc0ddb88d1ae34cdf Mon Sep 17 00:00:00 2001 From: "Jason M. Hasperhoven" Date: Mon, 9 Dec 2024 23:42:46 +0400 Subject: [PATCH 09/44] Convert to base units --- .../ui/order-form/store/range-liquidity.ts | 29 +++++++++---------- 1 file changed, 14 insertions(+), 15 deletions(-) diff --git a/src/pages/trade/ui/order-form/store/range-liquidity.ts b/src/pages/trade/ui/order-form/store/range-liquidity.ts index 216f3a53..82e090c3 100644 --- a/src/pages/trade/ui/order-form/store/range-liquidity.ts +++ b/src/pages/trade/ui/order-form/store/range-liquidity.ts @@ -128,8 +128,9 @@ export class RangeLiquidity { // We are treating quote asset as the numeraire and want to have an even spread // of quote asset value across all positions. - // const targetInput = pnum(this.target, this.quoteAsset.exponent).toBigInt(); - const quoteAssetAmountPerPosition = Number(this.target) / this.positions; + const targetInput = pnum(this.target, this.quoteAsset.exponent).toBigInt(); + // const quoteAssetAmountPerPosition = Number(this.target) / this.positions; + const quoteAssetAmountPerPosition = targetInput / BigInt(this.positions); const baseAssetExponentUnits = BigInt(10) ** BigInt(this.baseAsset.exponent); const quoteAssetExponentUnits = BigInt(10) ** BigInt(this.quoteAsset.exponent); @@ -151,12 +152,6 @@ export class RangeLiquidity { .times(BigNumber(positionPrice)) .toFixed(0), ).toAmount(); - // const p = pnum( - // BigNumber(positionPrice) - // .shiftedBy(this.quoteAsset.exponent ?? 0) - // .times(scale.toString()) - // .toFixed(0), - // ).toAmount(); const q = pnum(baseAssetExponentUnits * scale).toAmount(); @@ -172,14 +167,18 @@ export class RangeLiquidity { // so the position isn't immediately arbitraged. { r1: pnum(0n).toAmount(), - r2: pnum(quoteAssetAmountPerPosition, this.quoteAsset?.exponent).toAmount(), + // r2: pnum(quoteAssetAmountPerPosition, this.quoteAsset?.exponent).toAmount(), + r2: pnum(quoteAssetAmountPerPosition).toAmount(), } : { // If the position's price is _greater_ than the current price, fund it with // an equivalent amount of asset 1 as the target per-position amount of asset 2. + // r1: pnum( + // quoteAssetAmountPerPosition / positionPrice, + // this.baseAsset?.exponent, + // ).toAmount(), r1: pnum( - quoteAssetAmountPerPosition / positionPrice, - this.baseAsset?.exponent, + BigNumber(quoteAssetAmountPerPosition.toString()).div(positionPrice).toFixed(0), ).toAmount(), r2: pnum(0n).toAmount(), }; @@ -228,10 +227,10 @@ export class RangeLiquidity { console.table( positions.map(position => ({ - p: pnum(position.phi?.component?.p).toRoundedNumber(), - q: pnum(position.phi?.component?.q).toRoundedNumber(), - r1: pnum(position.reserves?.r1).toRoundedNumber(), - r2: pnum(position.reserves?.r2).toRoundedNumber(), + p: pnum(position.phi?.component?.p).toBigInt(), + q: pnum(position.phi?.component?.q).toBigInt(), + r1: pnum(position.reserves?.r1).toBigInt(), + r2: pnum(position.reserves?.r2).toBigInt(), })), ); From 925091fc41e84ae2cb73baca4271f0b59aef0eb3 Mon Sep 17 00:00:00 2001 From: Lucas Meier Date: Mon, 9 Dec 2024 16:06:48 -0800 Subject: [PATCH 10/44] Start fleshing out shared position logic --- .../ui/order-form/store/range-liquidity.ts | 4 -- src/shared/math/position.ts | 68 +++++++++++++++++++ 2 files changed, 68 insertions(+), 4 deletions(-) create mode 100644 src/shared/math/position.ts diff --git a/src/pages/trade/ui/order-form/store/range-liquidity.ts b/src/pages/trade/ui/order-form/store/range-liquidity.ts index 82e090c3..450bb2c2 100644 --- a/src/pages/trade/ui/order-form/store/range-liquidity.ts +++ b/src/pages/trade/ui/order-form/store/range-liquidity.ts @@ -2,7 +2,6 @@ import { makeAutoObservable } from 'mobx'; import { pnum } from '@penumbra-zone/types/pnum'; import { Position, - PositionOpen, PositionState, PositionState_PositionStateEnum, TradingPair, @@ -11,9 +10,6 @@ import { TransactionPlannerRequest } from '@penumbra-zone/protobuf/penumbra/view import { OrderFormAsset } from './asset'; import BigNumber from 'bignumber.js'; import { plan } from '../helpers'; -import { ValueView } from '@penumbra-zone/protobuf/penumbra/core/asset/v1/asset_pb'; - -window.BigNumber = BigNumber; export enum UpperBoundOptions { Market = 'Market', diff --git a/src/shared/math/position.ts b/src/shared/math/position.ts new file mode 100644 index 00000000..47939b18 --- /dev/null +++ b/src/shared/math/position.ts @@ -0,0 +1,68 @@ +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 { pnum } from '@penumbra-zone/types/pnum'; +import BigNumber from 'bignumber.js'; + +// This should be set so that we can still represent prices as numbers even multiplied by 10 ** this. +// +// For example, if this is set to 6, we should be able to represent PRICE * 10**6 as a number. +// In the year 202X, when 1 BTC = 1 million USD, then this is still only 1e12 < 2^50. +const PRECISION_DECIMALS = 6; + +export interface Asset { + id: AssetId; + exponent: number; +} + +export interface PositionPlan { + baseAsset: Asset; + quoteAsset: Asset; + price: number; + fee_bps: number; + baseReserves: number; + quoteReserves: number; +} + +const priceToPQ = ( + price: number, + pExponent: number, + qExponent: number, +): { p: number; q: number } => { + // 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] + const [q, p] = basePrice.toFraction(10 ** PRECISION_DECIMALS); + return { p: p.toNumber(), q: q.toNumber() }; +}; + +export const planToPosition = (plan: PositionPlan): Position => { + const { p, q } = priceToPQ(plan.price, plan.baseAsset.exponent, plan.quoteAsset.exponent); + + const r1 = pnum(plan.baseReserves, plan.baseAsset.exponent).toAmount(); + const r2 = pnum(plan.quoteReserves, plan.quoteAsset.exponent).toAmount(); + + return new Position({ + phi: { + component: { + fee: plan.fee_bps, + p: pnum(p).toAmount(), + q: pnum(q).toAmount(), + }, + pair: new TradingPair({ + asset1: plan.baseAsset.id, + asset2: plan.quoteAsset.id, + }), + }, + nonce: crypto.getRandomValues(new Uint8Array(32)), + state: new PositionState({ state: PositionState_PositionStateEnum.OPENED }), + reserves: { r1, r2 }, + closeOnFill: false, + }); +}; From 6cec7edc753612f0048ec4741e3ecf1319cdbaf9 Mon Sep 17 00:00:00 2001 From: Lucas Meier Date: Mon, 9 Dec 2024 16:33:15 -0800 Subject: [PATCH 11/44] Add tests for position math --- src/shared/math/position.test.ts | 77 ++++++++++++++++++++++++++++++++ src/shared/math/position.ts | 2 +- 2 files changed, 78 insertions(+), 1 deletion(-) create mode 100644 src/shared/math/position.test.ts diff --git a/src/shared/math/position.test.ts b/src/shared/math/position.test.ts new file mode 100644 index 00000000..7d9119e9 --- /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?.q).toNumber() / pnum(position.phi?.component?.p).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, + fee_bps: 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, + fee_bps: 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, + fee_bps: 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 index 47939b18..bbba4f4b 100644 --- a/src/shared/math/position.ts +++ b/src/shared/math/position.ts @@ -37,7 +37,7 @@ const priceToPQ = ( // 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] + // USD / UM -> [USD, UM], with a given precision const [q, p] = basePrice.toFraction(10 ** PRECISION_DECIMALS); return { p: p.toNumber(), q: q.toNumber() }; }; From 0da213e2ebdf0e28b2162b1c89812eec68ea50fb Mon Sep 17 00:00:00 2001 From: Lucas Meier Date: Mon, 9 Dec 2024 17:29:33 -0800 Subject: [PATCH 12/44] Integrate position logic into range liquidity form --- .../ui/order-form/store/range-liquidity.ts | 100 ++++-------------- src/shared/math/position.test.ts | 6 +- src/shared/math/position.ts | 51 ++++++++- 3 files changed, 71 insertions(+), 86 deletions(-) diff --git a/src/pages/trade/ui/order-form/store/range-liquidity.ts b/src/pages/trade/ui/order-form/store/range-liquidity.ts index 450bb2c2..b71d5beb 100644 --- a/src/pages/trade/ui/order-form/store/range-liquidity.ts +++ b/src/pages/trade/ui/order-form/store/range-liquidity.ts @@ -10,6 +10,7 @@ import { TransactionPlannerRequest } from '@penumbra-zone/protobuf/penumbra/view import { OrderFormAsset } from './asset'; import BigNumber from 'bignumber.js'; import { plan } from '../helpers'; +import { rangeLiquidityPositions } from '@/shared/math/position'; export enum UpperBoundOptions { Market = 'Market', @@ -108,7 +109,9 @@ export class RangeLiquidity { !this.positions || !this.target || !this.baseAsset || + !this.baseAsset.assetId || !this.quoteAsset || + !this.quoteAsset.assetId || !this.baseAsset.exponent || !this.quoteAsset.exponent || !this.marketPrice @@ -116,84 +119,21 @@ export class RangeLiquidity { return []; } - // The step width is positions-1 because it's between the endpoints - // |---|---|---|---| - // 0 1 2 3 4 - // 0 1 2 3 - const stepWidth = (Number(this.upperBound) - Number(this.lowerBound)) / (this.positions - 1); - - // We are treating quote asset as the numeraire and want to have an even spread - // of quote asset value across all positions. - const targetInput = pnum(this.target, this.quoteAsset.exponent).toBigInt(); - // const quoteAssetAmountPerPosition = Number(this.target) / this.positions; - const quoteAssetAmountPerPosition = targetInput / BigInt(this.positions); - - const baseAssetExponentUnits = BigInt(10) ** BigInt(this.baseAsset.exponent); - const quoteAssetExponentUnits = BigInt(10) ** BigInt(this.quoteAsset.exponent); - - const positions = Array.from({ length: this.positions }, (_, i) => { - const positionPrice = Number(this.lowerBound) + stepWidth * i; - - // 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 = quoteAssetExponentUnits < 1_000_000n ? 1_000_000n : 1n; - - const p = pnum( - BigNumber((quoteAssetExponentUnits * scale).toString()) - .times(BigNumber(positionPrice)) - .toFixed(0), - ).toAmount(); - - const q = pnum(baseAssetExponentUnits * scale).toAmount(); - - console.log( - 'TCL: RangeLiquidity -> quoteAssetAmountPerPosition', - quoteAssetAmountPerPosition, - ); - - // Compute reserves - const reserves = - positionPrice < this.marketPrice - ? // If the position's price is _less_ than the current price, fund it with asset 2 - // so the position isn't immediately arbitraged. - { - r1: pnum(0n).toAmount(), - // r2: pnum(quoteAssetAmountPerPosition, this.quoteAsset?.exponent).toAmount(), - r2: pnum(quoteAssetAmountPerPosition).toAmount(), - } - : { - // If the position's price is _greater_ than the current price, fund it with - // an equivalent amount of asset 1 as the target per-position amount of asset 2. - // r1: pnum( - // quoteAssetAmountPerPosition / positionPrice, - // this.baseAsset?.exponent, - // ).toAmount(), - r1: pnum( - BigNumber(quoteAssetAmountPerPosition.toString()).div(positionPrice).toFixed(0), - ).toAmount(), - r2: pnum(0n).toAmount(), - }; - - const fee = (this.feeTier ?? 0.1) * 100; - - return new Position({ - phi: { - component: { fee, 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, - }); + const positions = rangeLiquidityPositions({ + baseAsset: { + id: this.baseAsset.assetId, + exponent: this.baseAsset.exponent, + }, + quoteAsset: { + id: this.quoteAsset.assetId, + exponent: this.quoteAsset.exponent, + }, + targetLiquidity: Number(this.target), + upperPrice: Number(this.upperBound), + lowerPrice: Number(this.lowerBound), + marketPrice: this.marketPrice, + feeBps: (this.feeTier ?? 0.1) * 100, + positions: this.positions, }); return positions; @@ -241,11 +181,11 @@ export class RangeLiquidity { ); console.log( 'TCL: total baseAmount', - pnum(baseAmount, this.baseAsset.exponent).toRoundedNumber(), + pnum(baseAmount, this.baseAsset?.exponent).toRoundedNumber(), ); console.log( 'TCL: total quoteAmount', - pnum(quoteAmount, this.quoteAsset.exponent).toRoundedNumber(), + pnum(quoteAmount, this.quoteAsset?.exponent).toRoundedNumber(), ); this.baseAsset?.setAmount(pnum(baseAmount, this.baseAsset.exponent).toRoundedNumber()); diff --git a/src/shared/math/position.test.ts b/src/shared/math/position.test.ts index 7d9119e9..a77a0dc5 100644 --- a/src/shared/math/position.test.ts +++ b/src/shared/math/position.test.ts @@ -23,7 +23,7 @@ describe('planToPosition', () => { exponent: 0, }, price: 20.5, - fee_bps: 100, + feeBps: 100, baseReserves: 1000, quoteReserves: 2000, }); @@ -44,7 +44,7 @@ describe('planToPosition', () => { exponent: 6, }, price: 12.34, - fee_bps: 100, + feeBps: 100, baseReserves: 5, quoteReserves: 7, }); @@ -65,7 +65,7 @@ describe('planToPosition', () => { exponent: 8, }, price: 12.34, - fee_bps: 100, + feeBps: 100, baseReserves: 5, quoteReserves: 7, }); diff --git a/src/shared/math/position.ts b/src/shared/math/position.ts index bbba4f4b..c71e2434 100644 --- a/src/shared/math/position.ts +++ b/src/shared/math/position.ts @@ -12,7 +12,7 @@ import BigNumber from 'bignumber.js'; // // For example, if this is set to 6, we should be able to represent PRICE * 10**6 as a number. // In the year 202X, when 1 BTC = 1 million USD, then this is still only 1e12 < 2^50. -const PRECISION_DECIMALS = 6; +const PRECISION_DECIMALS = 12; export interface Asset { id: AssetId; @@ -23,7 +23,7 @@ export interface PositionPlan { baseAsset: Asset; quoteAsset: Asset; price: number; - fee_bps: number; + feeBps: number; baseReserves: number; quoteReserves: number; } @@ -51,7 +51,7 @@ export const planToPosition = (plan: PositionPlan): Position => { return new Position({ phi: { component: { - fee: plan.fee_bps, + fee: plan.feeBps, p: pnum(p).toAmount(), q: pnum(q).toAmount(), }, @@ -66,3 +66,48 @@ export const planToPosition = (plan: PositionPlan): Position => { closeOnFill: false, }); }; + +interface RangeLiquidityPlan { + baseAsset: Asset; + quoteAsset: Asset; + targetLiquidity: number; + upperPrice: number; + lowerPrice: number; + marketPrice: number; + feeBps: number; + positions: number; +} + +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, + }); + }); +}; From c2f5f8dde635e76a2e892ebb418f30c01f11a8a1 Mon Sep 17 00:00:00 2001 From: Lucas Meier Date: Mon, 9 Dec 2024 17:48:05 -0800 Subject: [PATCH 13/44] Define limit order position function --- src/shared/math/position.ts | 30 ++++++++++++++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/src/shared/math/position.ts b/src/shared/math/position.ts index c71e2434..7561a55f 100644 --- a/src/shared/math/position.ts +++ b/src/shared/math/position.ts @@ -111,3 +111,33 @@ export const rangeLiquidityPositions = (plan: RangeLiquidityPlan): Position[] => }); }); }; + +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; +}; From 77d2c337202083fd206d2a5a6d3f40f503514acd Mon Sep 17 00:00:00 2001 From: Lucas Meier Date: Mon, 9 Dec 2024 18:00:31 -0800 Subject: [PATCH 14/44] Add some documentation to position math --- src/shared/math/position.ts | 43 +++++++++++++++++++++++++++++++++++++ 1 file changed, 43 insertions(+) diff --git a/src/shared/math/position.ts b/src/shared/math/position.ts index 7561a55f..c5da59b0 100644 --- a/src/shared/math/position.ts +++ b/src/shared/math/position.ts @@ -14,17 +14,37 @@ import BigNumber from 'bignumber.js'; // In the year 202X, when 1 BTC = 1 million USD, then this is still only 1e12 < 2^50. const PRECISION_DECIMALS = 12; +/** + * 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; } @@ -42,6 +62,12 @@ const priceToPQ = ( return { p: p.toNumber(), q: q.toNumber() }; }; +/** + * 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, q } = priceToPQ(plan.price, plan.baseAsset.exponent, plan.quoteAsset.exponent); @@ -67,6 +93,16 @@ export const planToPosition = (plan: PositionPlan): Position => { }); }; +/** + * 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; @@ -78,6 +114,7 @@ interface RangeLiquidityPlan { 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 // |---|---|---|---| @@ -112,6 +149,12 @@ export const rangeLiquidityPositions = (plan: RangeLiquidityPlan): Position[] => }); }; +/** 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; From a85105dc38d3532cf78d542bde40b463bde64458 Mon Sep 17 00:00:00 2001 From: Lucas Meier Date: Mon, 9 Dec 2024 18:23:07 -0800 Subject: [PATCH 15/44] Wire up limit position form --- .../trade/ui/order-form/order-form-limit.tsx | 6 +- src/pages/trade/ui/order-form/store/index.ts | 73 +++++++------------ 2 files changed, 28 insertions(+), 51 deletions(-) diff --git a/src/pages/trade/ui/order-form/order-form-limit.tsx b/src/pages/trade/ui/order-form/order-form-limit.tsx index 74647d39..2d032da2 100644 --- a/src/pages/trade/ui/order-form/order-form-limit.tsx +++ b/src/pages/trade/ui/order-form/order-form-limit.tsx @@ -36,10 +36,10 @@ export const LimitOrderForm = observer(() => { }, [exchangeRate, limitOrder]); useEffect(() => { - if (quoteAsset.exponent) { - limitOrder.setExponent(quoteAsset.exponent); + if (quoteAsset.exponent && baseAsset.exponent) { + limitOrder.setExponent(quoteAsset.exponent - baseAsset.exponent); } - }, [quoteAsset.exponent, limitOrder]); + }, [baseAsset.exponent, quoteAsset.exponent, limitOrder]); return (
diff --git a/src/pages/trade/ui/order-form/store/index.ts b/src/pages/trade/ui/order-form/store/index.ts index 6766475d..f32cb625 100644 --- a/src/pages/trade/ui/order-form/store/index.ts +++ b/src/pages/trade/ui/order-form/store/index.ts @@ -27,6 +27,7 @@ import { usePathToMetadata } from '../../../model/use-path'; import { OrderFormAsset } from './asset'; import { RangeLiquidity } from './range-liquidity'; import { LimitOrder } from './limit-order'; +import { limitOrderPosition } from '@/shared/math/position'; export enum Direction { Buy = 'Buy', @@ -263,55 +264,31 @@ class OrderFormStore { // ref: https://github.com/penumbra-zone/penumbra/blob/main/crates/bin/pcli/src/command/tx/replicate/linear.rs buildLimitPosition = (): Position => { - const baseAssetExponentUnits = BigInt(10) ** BigInt(this.baseAsset.exponent ?? 0); - const quoteAssetExponentUnits = BigInt(10) ** BigInt(this.quoteAsset.exponent ?? 0); - - const { price, marketPrice } = this.limitOrder as Required; - const positionPrice = Number(price); - - // 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 = quoteAssetExponentUnits < 1_000_000n ? 1_000_000n : 1n; - - const p = pnum( - BigNumber((quoteAssetExponentUnits * scale).toString()) - .times(BigNumber(positionPrice)) - .toFixed(0), - ).toAmount(); - const q = pnum(baseAssetExponentUnits * scale).toAmount(); - - // 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 = - positionPrice < marketPrice - ? { - r1: pnum(0n).toAmount(), - r2: pnum(this.quoteAsset.amount).toAmount(), - } - : { - r1: pnum(Number(this.quoteAsset.amount) / positionPrice).toAmount(), - r2: pnum(0n).toAmount(), - }; - - return new Position({ - phi: { - component: { p, q }, - pair: new TradingPair({ - asset1: this.baseAsset.assetId, - asset2: this.quoteAsset.assetId, - }), + const { price } = this.limitOrder as Required; + if ( + !this.baseAsset.assetId || + !this.quoteAsset.assetId || + !this.quoteAsset.exponent || + !this.baseAsset.exponent || + !this.quoteAsset.amount + ) { + throw new Error('incomplete limit position form'); + } + return limitOrderPosition({ + buy: this.direction === 'Buy' ? 'buy' : 'sell', + price: Number(price), + baseAsset: { + id: this.baseAsset.assetId, + exponent: this.baseAsset.exponent, + }, + quoteAsset: { + id: this.quoteAsset.assetId, + exponent: this.quoteAsset.exponent, }, - nonce: crypto.getRandomValues(new Uint8Array(32)), - state: new PositionState({ state: PositionState_PositionStateEnum.OPENED }), - reserves, - closeOnFill: true, + input: + this.direction === 'Buy' + ? Number(this.quoteAsset.amount) + : Number(this.quoteAsset.amount) / Number(price), }); }; From 707034aaccc0f93eb184296ef9b550f44914ceef Mon Sep 17 00:00:00 2001 From: "Jason M. Hasperhoven" Date: Tue, 10 Dec 2024 18:23:35 +0400 Subject: [PATCH 16/44] Remove slider from limit order form --- src/pages/trade/ui/order-form/order-form-limit.tsx | 1 - .../ui/order-form/order-form-range-liquidity.tsx | 11 ++++------- .../trade/ui/order-form/store/range-liquidity.ts | 6 ++---- 3 files changed, 6 insertions(+), 12 deletions(-) diff --git a/src/pages/trade/ui/order-form/order-form-limit.tsx b/src/pages/trade/ui/order-form/order-form-limit.tsx index 2d032da2..90f81055 100644 --- a/src/pages/trade/ui/order-form/order-form-limit.tsx +++ b/src/pages/trade/ui/order-form/order-form-limit.tsx @@ -84,7 +84,6 @@ export const LimitOrderForm = observer(() => { denominator={quoteAsset.symbol} />
-
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 aa07509c..f1f5173f 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 @@ -21,11 +21,10 @@ import { export const RangeLiquidityOrderForm = observer(() => { const { connected } = connectionStore; - const { baseAsset, quoteAsset, rangeLiquidity, submitOrder, isLoading, gasFee, exchangeRate } = + const { baseAsset, quoteAsset, rangeLiquidity, submitOrder, isLoading, exchangeRate } = useOrderFormStore(FormType.RangeLiquidity); const { data } = useSummary('1d'); - // const price = data && 'price' in data ? data.price : undefined; - const price = 1; + const price = data && 'price' in data ? data.price : undefined; useEffect(() => { if (price) { @@ -34,9 +33,7 @@ export const RangeLiquidityOrderForm = observer(() => { }, [price, rangeLiquidity]); useEffect(() => { - if (baseAsset && quoteAsset) { - rangeLiquidity.setAssets(baseAsset, quoteAsset); - } + rangeLiquidity.setAssets(baseAsset, quoteAsset); }, [baseAsset, quoteAsset, rangeLiquidity]); return ( @@ -50,7 +47,7 @@ export const RangeLiquidityOrderForm = observer(() => { denominator={quoteAsset.symbol} />
-
+
Available Balances diff --git a/src/pages/trade/ui/order-form/store/range-liquidity.ts b/src/pages/trade/ui/order-form/store/range-liquidity.ts index b71d5beb..978ad168 100644 --- a/src/pages/trade/ui/order-form/store/range-liquidity.ts +++ b/src/pages/trade/ui/order-form/store/range-liquidity.ts @@ -108,10 +108,8 @@ export class RangeLiquidity { if ( !this.positions || !this.target || - !this.baseAsset || - !this.baseAsset.assetId || - !this.quoteAsset || - !this.quoteAsset.assetId || + !this.baseAsset?.assetId || + !this.quoteAsset?.assetId || !this.baseAsset.exponent || !this.quoteAsset.exponent || !this.marketPrice From f1f15e3fb825a39bcedf898239cafd3b2e9b2176 Mon Sep 17 00:00:00 2001 From: "Jason M. Hasperhoven" Date: Tue, 10 Dec 2024 18:55:00 +0400 Subject: [PATCH 17/44] Remove key error --- src/pages/trade/ui/trade-row.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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} From c943e9cd0ffff06949105af02af06385fd6bc71f Mon Sep 17 00:00:00 2001 From: "Jason M. Hasperhoven" Date: Tue, 10 Dec 2024 18:55:52 +0400 Subject: [PATCH 18/44] Apply same p,q,r1,r2 values as rust code --- src/shared/math/position.ts | 42 ++++++++++++++++++++++++++++++++----- 1 file changed, 37 insertions(+), 5 deletions(-) diff --git a/src/shared/math/position.ts b/src/shared/math/position.ts index c5da59b0..17be82fd 100644 --- a/src/shared/math/position.ts +++ b/src/shared/math/position.ts @@ -5,6 +5,7 @@ import { 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'; @@ -62,6 +63,31 @@ const priceToPQ = ( return { p: p.toNumber(), q: q.toNumber() }; }; +const priceToPQ2 = ( + price: number, + pExponent: number, + qExponent: number, +): { p: Amount; q: Amount } => { + const pExponentUnits = BigInt(10) ** BigInt(pExponent); + const qExponentUnits = BigInt(10) ** BigInt(qExponent); + + const scale = qExponentUnits < 1_000_000n ? 1_000_000n : 1n; + + const p = pnum( + BigInt( + BigNumber((qExponentUnits * scale).toString()) + .times(BigNumber(price)) + .shiftedBy(-(qExponent - pExponent)) + .toFixed(0), + ), + qExponent, + ).toAmount(); + + const q = pnum(pExponentUnits * scale, pExponent).toAmount(); + + return { p, q }; +}; + /** * Convert a plan into a position. * @@ -69,17 +95,23 @@ const priceToPQ = ( * as an escape hatch in case any of those use cases aren't sufficient. */ export const planToPosition = (plan: PositionPlan): Position => { - const { p, q } = priceToPQ(plan.price, plan.baseAsset.exponent, plan.quoteAsset.exponent); + console.log('TCL: plan', plan); + // const { p, q } = priceToPQ(plan.price, plan.baseAsset.exponent, plan.quoteAsset.exponent); + const { p, q } = priceToPQ2(plan.price, plan.baseAsset.exponent, plan.quoteAsset.exponent); - const r1 = pnum(plan.baseReserves, plan.baseAsset.exponent).toAmount(); - const r2 = pnum(plan.quoteReserves, plan.quoteAsset.exponent).toAmount(); + // const r1 = pnum(plan.baseReserves, plan.baseAsset.exponent).toAmount(); + // const r2 = pnum(plan.quoteReserves, plan.quoteAsset.exponent).toAmount(); + const r1 = pnum(plan.baseReserves).toAmount(); + const r2 = pnum(plan.quoteReserves).toAmount(); return new Position({ phi: { component: { fee: plan.feeBps, - p: pnum(p).toAmount(), - q: pnum(q).toAmount(), + // p: pnum(p).toAmount(), + // q: pnum(q).toAmount(), + p, + q, }, pair: new TradingPair({ asset1: plan.baseAsset.id, From 4538e10afb7de2c7ea308b3d536eb25a0375f375 Mon Sep 17 00:00:00 2001 From: Lucas Meier Date: Tue, 10 Dec 2024 09:22:15 -0800 Subject: [PATCH 19/44] Construct positions taking into account asset order --- src/shared/math/position.ts | 48 ++++++++++++++++++++++++++++--------- 1 file changed, 37 insertions(+), 11 deletions(-) diff --git a/src/shared/math/position.ts b/src/shared/math/position.ts index 17be82fd..2dc3f72e 100644 --- a/src/shared/math/position.ts +++ b/src/shared/math/position.ts @@ -15,6 +15,20 @@ import BigNumber from 'bignumber.js'; // In the year 202X, when 1 BTC = 1 million USD, then this is still only 1e12 < 2^50. const PRECISION_DECIMALS = 12; +const compareAssetId = (a: AssetId, b: AssetId): number => { + for (let i = 0; i < 32; ++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. * @@ -53,14 +67,14 @@ const priceToPQ = ( price: number, pExponent: number, qExponent: number, -): { p: number; q: 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 const [q, p] = basePrice.toFraction(10 ** PRECISION_DECIMALS); - return { p: p.toNumber(), q: q.toNumber() }; + return { p: pnum(BigInt(p.toFixed(0))).toAmount(), q: pnum(BigInt(q.toFixed(0))).toAmount() }; }; const priceToPQ2 = ( @@ -96,22 +110,34 @@ const priceToPQ2 = ( */ export const planToPosition = (plan: PositionPlan): Position => { console.log('TCL: plan', plan); - // const { p, q } = priceToPQ(plan.price, plan.baseAsset.exponent, plan.quoteAsset.exponent); - const { p, q } = priceToPQ2(plan.price, plan.baseAsset.exponent, plan.quoteAsset.exponent); + const { p: raw_p, q: raw_q } = priceToPQ( + plan.price, + plan.baseAsset.exponent, + plan.quoteAsset.exponent, + ); + //const { p, q } = priceToPQ2(plan.price, plan.baseAsset.exponent, plan.quoteAsset.exponent); // const r1 = pnum(plan.baseReserves, plan.baseAsset.exponent).toAmount(); // const r2 = pnum(plan.quoteReserves, plan.quoteAsset.exponent).toAmount(); - const r1 = pnum(plan.baseReserves).toAmount(); - const r2 = pnum(plan.quoteReserves).toAmount(); - + const raw_r1 = pnum(plan.baseReserves).toAmount(); + const raw_r2 = pnum(plan.quoteReserves).toAmount(); + + const correctOrder = compareAssetId(plan.baseAsset.id, plan.quoteAsset.id) <= 0; + const [[p, q], [r1, r2]] = correctOrder + ? [ + [raw_p, raw_q], + [raw_r1, raw_r2], + ] + : [ + [raw_q, raw_p], + [raw_r2, raw_r1], + ]; return new Position({ phi: { component: { fee: plan.feeBps, - // p: pnum(p).toAmount(), - // q: pnum(q).toAmount(), - p, - q, + p: pnum(p).toAmount(), + q: pnum(q).toAmount(), }, pair: new TradingPair({ asset1: plan.baseAsset.id, From f812cdf1847fd921f6115046b9b4af2984067950 Mon Sep 17 00:00:00 2001 From: Lucas Meier Date: Tue, 10 Dec 2024 09:30:22 -0800 Subject: [PATCH 20/44] Have position plans take in display units for reserves --- src/shared/math/position.ts | 9 ++------- 1 file changed, 2 insertions(+), 7 deletions(-) diff --git a/src/shared/math/position.ts b/src/shared/math/position.ts index 2dc3f72e..f4302eae 100644 --- a/src/shared/math/position.ts +++ b/src/shared/math/position.ts @@ -109,18 +109,13 @@ const priceToPQ2 = ( * as an escape hatch in case any of those use cases aren't sufficient. */ export const planToPosition = (plan: PositionPlan): Position => { - console.log('TCL: plan', plan); const { p: raw_p, q: raw_q } = priceToPQ( plan.price, plan.baseAsset.exponent, plan.quoteAsset.exponent, ); - //const { p, q } = priceToPQ2(plan.price, plan.baseAsset.exponent, plan.quoteAsset.exponent); - - // const r1 = pnum(plan.baseReserves, plan.baseAsset.exponent).toAmount(); - // const r2 = pnum(plan.quoteReserves, plan.quoteAsset.exponent).toAmount(); - const raw_r1 = pnum(plan.baseReserves).toAmount(); - const raw_r2 = pnum(plan.quoteReserves).toAmount(); + const raw_r1 = pnum(plan.baseReserves, plan.baseAsset.exponent).toAmount(); + const raw_r2 = pnum(plan.quoteReserves, plan.quoteAsset.exponent).toAmount(); const correctOrder = compareAssetId(plan.baseAsset.id, plan.quoteAsset.id) <= 0; const [[p, q], [r1, r2]] = correctOrder From 21876636bfeaa2435d6b99925d568e17d30d0630 Mon Sep 17 00:00:00 2001 From: "Jason M. Hasperhoven" Date: Tue, 10 Dec 2024 22:03:43 +0400 Subject: [PATCH 21/44] Fix limit order amount handling + gas fee calc --- .../trade/ui/order-form/order-form-limit.tsx | 10 +- src/pages/trade/ui/order-form/store/index.ts | 96 ++++++++++++++----- .../ui/order-form/store/range-liquidity.ts | 44 +-------- src/shared/math/position.ts | 27 +----- 4 files changed, 82 insertions(+), 95 deletions(-) diff --git a/src/pages/trade/ui/order-form/order-form-limit.tsx b/src/pages/trade/ui/order-form/order-form-limit.tsx index 90f81055..0d110c7c 100644 --- a/src/pages/trade/ui/order-form/order-form-limit.tsx +++ b/src/pages/trade/ui/order-form/order-form-limit.tsx @@ -6,12 +6,12 @@ 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 { InfoRowTradingFee } from './info-row-trading-fee'; import { InfoRowGasFee } from './info-row-gas-fee'; import { SelectGroup } from './select-group'; import { useOrderFormStore, FormType, Direction } from './store'; import { BuyLimitOrderOptions, SellLimitOrderOptions } from './store/limit-order'; +import { useSummary } from '../../model/useSummary'; export const LimitOrderForm = observer(() => { const { connected } = connectionStore; @@ -26,14 +26,16 @@ export const LimitOrderForm = observer(() => { gasFee, exchangeRate, } = useOrderFormStore(FormType.Limit); + const { data } = useSummary('1d'); + const price = data && 'price' in data ? data.price : undefined; const isBuy = direction === Direction.Buy; useEffect(() => { - if (exchangeRate) { - limitOrder.setMarketPrice(exchangeRate); + if (price) { + limitOrder.setMarketPrice(price); } - }, [exchangeRate, limitOrder]); + }, [price, limitOrder]); useEffect(() => { if (quoteAsset.exponent && baseAsset.exponent) { diff --git a/src/pages/trade/ui/order-form/store/index.ts b/src/pages/trade/ui/order-form/store/index.ts index f32cb625..14ce0443 100644 --- a/src/pages/trade/ui/order-form/store/index.ts +++ b/src/pages/trade/ui/order-form/store/index.ts @@ -1,7 +1,6 @@ import { useEffect } from 'react'; import { makeAutoObservable } from 'mobx'; import debounce from 'lodash/debounce'; -import BigNumber from 'bignumber.js'; import { SimulationService } from '@penumbra-zone/protobuf'; import { BalancesResponse, @@ -191,19 +190,45 @@ class OrderFormStore { const assetIn = assetIsBaseAsset ? this.baseAsset : this.quoteAsset; const assetOut = assetIsBaseAsset ? this.quoteAsset : this.baseAsset; - try { - void this.calculateGasFee(); + if (this.type === FormType.Market) { + try { + void this.calculateGasFee(); - assetOut.setIsEstimating(true); + assetOut.setIsEstimating(true); - const output = await this.simulateSwapTx(assetIn, assetOut); - if (!output) { - return; + const output = await this.simulateSwapTx(assetIn, assetOut); + if (!output) { + return; + } + + assetOut.setAmount(pnum(output).toFormattedString(), false); + } finally { + assetOut.setIsEstimating(false); } + } - assetOut.setAmount(pnum(output).toFormattedString(), false); - } finally { - assetOut.setIsEstimating(false); + if (this.type === FormType.Limit) { + try { + void this.calculateGasFee(); + + assetOut.setIsEstimating(true); + + if (assetIsBaseAsset) { + const amount = Number(this.baseAsset.amount) * this.limitOrder.price; + + if (amount !== this.quoteAsset.amount) { + this.quoteAsset.setAmount(amount, true); + } + } else { + const amount = Number(this.quoteAsset.amount) / this.limitOrder.price; + + if (amount !== this.baseAsset.amount) { + this.baseAsset.setAmount(amount, true); + } + } + } finally { + assetOut.setIsEstimating(false); + } } }; @@ -249,17 +274,41 @@ class OrderFormStore { 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, + console.log('TCL: OrderFormStore -> fee', fee); + + this.gasFee = pnum(fee?.amount, this.baseAsset.exponent).toRoundedNumber(); + }; + + calculateLimitGasFee = async (): Promise => { + console.log('called'); + this.gasFee = null; + + const isBuy = this.direction === Direction.Buy; + const assetIn = isBuy ? this.quoteAsset : this.baseAsset; + console.log('TCL: OrderFormStore -> assetIn', assetIn, assetIn.amount); + const assetOut = isBuy ? this.baseAsset : this.quoteAsset; + console.log('TCL: OrderFormStore -> assetOut', assetOut, assetOut.amount); + + if (!assetIn.amount || !assetOut.amount) { + this.gasFee = 0; + return; + } + + const positionsReq = new TransactionPlannerRequest({ + positionOpens: [ + { + position: this.buildLimitPosition(), }, - }, + ], + source: this.quoteAsset.accountIndex, }); - this.gasFee = pnum(feeValueView).toRoundedNumber(); + console.log('TCL: OrderFormStore -> positionsReq', positionsReq); + const txPlan = await plan(positionsReq); + const fee = txPlan.transactionParameters?.fee; + console.log('TCL: OrderFormStore -> fee', fee); + + this.gasFee = pnum(fee?.amount, this.baseAsset.exponent).toRoundedNumber(); }; // ref: https://github.com/penumbra-zone/penumbra/blob/main/crates/bin/pcli/src/command/tx/replicate/linear.rs @@ -274,8 +323,9 @@ class OrderFormStore { ) { throw new Error('incomplete limit position form'); } + return limitOrderPosition({ - buy: this.direction === 'Buy' ? 'buy' : 'sell', + buy: this.direction === Direction.Buy ? 'buy' : 'sell', price: Number(price), baseAsset: { id: this.baseAsset.assetId, @@ -286,7 +336,7 @@ class OrderFormStore { exponent: this.quoteAsset.exponent, }, input: - this.direction === 'Buy' + this.direction === Direction.Buy ? Number(this.quoteAsset.amount) : Number(this.quoteAsset.amount) / Number(price), }); @@ -297,9 +347,9 @@ class OrderFormStore { await this.calculateMarketGasFee(); } - // if (this.type === FormType.RangeLiquidity) { - // await this.calculateRangeLiquidityGasFee(); - // } + if (this.type === FormType.Limit) { + await this.calculateLimitGasFee(); + } }; initiateSwapTx = async (): Promise => { diff --git a/src/pages/trade/ui/order-form/store/range-liquidity.ts b/src/pages/trade/ui/order-form/store/range-liquidity.ts index 978ad168..91ef56e4 100644 --- a/src/pages/trade/ui/order-form/store/range-liquidity.ts +++ b/src/pages/trade/ui/order-form/store/range-liquidity.ts @@ -1,14 +1,8 @@ import { makeAutoObservable } from 'mobx'; import { pnum } from '@penumbra-zone/types/pnum'; -import { - Position, - PositionState, - PositionState_PositionStateEnum, - TradingPair, -} from '@penumbra-zone/protobuf/penumbra/core/component/dex/v1/dex_pb'; +import { Position } from '@penumbra-zone/protobuf/penumbra/core/component/dex/v1/dex_pb'; import { TransactionPlannerRequest } from '@penumbra-zone/protobuf/penumbra/view/v1/view_pb'; import { OrderFormAsset } from './asset'; -import BigNumber from 'bignumber.js'; import { plan } from '../helpers'; import { rangeLiquidityPositions } from '@/shared/math/position'; @@ -144,30 +138,6 @@ export class RangeLiquidity { return; } - // const baseAssetExponentUnits = BigInt(10) ** BigInt(this.baseAsset.exponent); - // const quoteAssetExponentUnits = BigInt(10) ** BigInt(this.quoteAsset.exponent); - - console.table({ - baseAsset: this.baseAsset?.symbol, - quoteAsset: this.quoteAsset?.symbol, - baseAssetExponent: this.baseAsset?.exponent, - quoteAssetExponent: this.quoteAsset?.exponent, - positions: this.positions, - target: this.target, - upperBound: this.upperBound, - lowerBound: this.lowerBound, - feeTier: this.feeTier, - }); - - console.table( - positions.map(position => ({ - p: pnum(position.phi?.component?.p).toBigInt(), - q: pnum(position.phi?.component?.q).toBigInt(), - r1: pnum(position.reserves?.r1).toBigInt(), - r2: pnum(position.reserves?.r2).toBigInt(), - })), - ); - const { baseAmount, quoteAmount } = positions.reduce( (amounts, position: Position) => { return { @@ -177,14 +147,6 @@ export class RangeLiquidity { }, { baseAmount: 0n, quoteAmount: 0n }, ); - console.log( - 'TCL: total baseAmount', - pnum(baseAmount, this.baseAsset?.exponent).toRoundedNumber(), - ); - console.log( - 'TCL: total quoteAmount', - pnum(quoteAmount, this.quoteAsset?.exponent).toRoundedNumber(), - ); this.baseAsset?.setAmount(pnum(baseAmount, this.baseAsset.exponent).toRoundedNumber()); this.quoteAsset?.setAmount(pnum(quoteAmount, this.quoteAsset.exponent).toRoundedNumber()); @@ -268,8 +230,4 @@ export class RangeLiquidity { this.baseAsset = baseAsset; this.quoteAsset = quoteAsset; }; - - // onFieldChange = (callback: () => Promise): void => { - // this.onFieldChangeCallback = callback; - // }; } diff --git a/src/shared/math/position.ts b/src/shared/math/position.ts index f4302eae..b9d83212 100644 --- a/src/shared/math/position.ts +++ b/src/shared/math/position.ts @@ -72,36 +72,12 @@ const priceToPQ = ( // 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 const [q, p] = basePrice.toFraction(10 ** PRECISION_DECIMALS); return { p: pnum(BigInt(p.toFixed(0))).toAmount(), q: pnum(BigInt(q.toFixed(0))).toAmount() }; }; -const priceToPQ2 = ( - price: number, - pExponent: number, - qExponent: number, -): { p: Amount; q: Amount } => { - const pExponentUnits = BigInt(10) ** BigInt(pExponent); - const qExponentUnits = BigInt(10) ** BigInt(qExponent); - - const scale = qExponentUnits < 1_000_000n ? 1_000_000n : 1n; - - const p = pnum( - BigInt( - BigNumber((qExponentUnits * scale).toString()) - .times(BigNumber(price)) - .shiftedBy(-(qExponent - pExponent)) - .toFixed(0), - ), - qExponent, - ).toAmount(); - - const q = pnum(pExponentUnits * scale, pExponent).toAmount(); - - return { p, q }; -}; - /** * Convert a plan into a position. * @@ -127,6 +103,7 @@ export const planToPosition = (plan: PositionPlan): Position => { [raw_q, raw_p], [raw_r2, raw_r1], ]; + return new Position({ phi: { component: { From cc83543cdcb7ed964609f9f8c396fabdd0695b4b Mon Sep 17 00:00:00 2001 From: "Jason M. Hasperhoven" Date: Tue, 10 Dec 2024 22:20:52 +0400 Subject: [PATCH 22/44] Fix display amounts for range liquidity form --- src/pages/trade/ui/order-form/order-form-range-liquidity.tsx | 4 ++-- src/pages/trade/ui/order-form/store/range-liquidity.ts | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) 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 f1f5173f..3451039f 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 @@ -160,12 +160,12 @@ export const RangeLiquidityOrderForm = observer(() => { )}
- {exchangeRate !== null && ( + {price !== undefined && (
1 {baseAsset.symbol} ={' '} - {exchangeRate} {quoteAsset.symbol} + {price} {quoteAsset.symbol}
diff --git a/src/pages/trade/ui/order-form/store/range-liquidity.ts b/src/pages/trade/ui/order-form/store/range-liquidity.ts index 91ef56e4..eaa6069f 100644 --- a/src/pages/trade/ui/order-form/store/range-liquidity.ts +++ b/src/pages/trade/ui/order-form/store/range-liquidity.ts @@ -148,8 +148,8 @@ export class RangeLiquidity { { baseAmount: 0n, quoteAmount: 0n }, ); - this.baseAsset?.setAmount(pnum(baseAmount, this.baseAsset.exponent).toRoundedNumber()); - this.quoteAsset?.setAmount(pnum(quoteAmount, this.quoteAsset.exponent).toRoundedNumber()); + this.baseAsset?.setAmount(Number(baseAmount)); + this.quoteAsset?.setAmount(Number(quoteAmount)); const positionsReq = new TransactionPlannerRequest({ positionOpens: positions.map(position => ({ position })), From 05b8d164cd1f4cc7da34a2dc88a4d3d9763f2d4c Mon Sep 17 00:00:00 2001 From: Lucas Meier Date: Tue, 10 Dec 2024 20:43:25 -0800 Subject: [PATCH 23/44] Bug: don't display weird text in order input --- src/pages/trade/ui/order-form/order-input.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/pages/trade/ui/order-form/order-input.tsx b/src/pages/trade/ui/order-form/order-input.tsx index ab72810a..dc96aae6 100644 --- a/src/pages/trade/ui/order-form/order-input.tsx +++ b/src/pages/trade/ui/order-form/order-input.tsx @@ -72,7 +72,7 @@ export const OrderInput = forwardRef( <>
{value}
From 62868fb64466e00a87d21cfdf2e3a485b34e78e3 Mon Sep 17 00:00:00 2001 From: Lucas Meier Date: Wed, 11 Dec 2024 01:02:52 -0800 Subject: [PATCH 24/44] 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. --- .../store/PriceLinkedInputs.test.ts | 38 +++++++++ .../ui/order-form/store/PriceLinkedInputs.ts | 80 +++++++++++++++++++ 2 files changed, 118 insertions(+) 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 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..ff727a47 --- /dev/null +++ b/src/pages/trade/ui/order-form/store/PriceLinkedInputs.ts @@ -0,0 +1,80 @@ +import { makeAutoObservable } from 'mobx'; + +type LastEdited = 'A' | 'B'; + +/** Attempt to parse a string into a number, returning `undefined` on failure. */ +const parseNumber = (x: string): number | undefined => { + const out = Number(x); + return isNaN(out) ? undefined : out; +}; + +/** 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: string = ''; + private _inputB: string = ''; + private _lastEdited: LastEdited = 'A'; + private _price: number = 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; + } + + get inputB(): string { + return this._inputB; + } + + set inputA(x: string) { + this._lastEdited = 'A'; + this._inputA = x; + this.computeBFromA(); + } + + 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 if (this._lastEdited === 'B') { + this.computeAFromB(); + } + } +} From ea5dcf52dd80c51183c15fafbab64a838c1013f2 Mon Sep 17 00:00:00 2001 From: Lucas Meier Date: Wed, 11 Dec 2024 15:56:35 -0800 Subject: [PATCH 25/44] Define new store for MarketOrderForm --- .../order-form/store/MarketOrderFormStore.ts | 227 ++++++++++++++++++ .../ui/order-form/store/PriceLinkedInputs.ts | 7 +- src/shared/utils/num.ts | 5 + 3 files changed, 233 insertions(+), 6 deletions(-) create mode 100644 src/pages/trade/ui/order-form/store/MarketOrderFormStore.ts create mode 100644 src/shared/utils/num.ts 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..7f08fd9f --- /dev/null +++ b/src/pages/trade/ui/order-form/store/MarketOrderFormStore.ts @@ -0,0 +1,227 @@ +import { makeAutoObservable, reaction } 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'; + +class AssetInfo { + constructor( + public id: AssetId, + public balance: number, + public exponent: number, + public symbol: string, + ) {} + + value(display: number): Value { + return new Value({ + amount: pnum(display, this.exponent).toAmount(), + assetId: this.id, + }); + } + + formatDisplayAmount(amount: number): string { + const amountString = pnum(amount, this.exponent).toFormattedString({ + commas: true, + decimals: 4, + trailingZeros: false, + }); + return `${amountString} ${this.symbol}`; + } +} + +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 BuySell = '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 = 200; + +export interface MarketOrderPlan { + targetAsset: AssetId; + value: Value; +} + +export class MarketOrderFormStore { + private _baseAsset?: AssetInfo; + private _quoteAsset?: AssetInfo; + private _baseAssetInput: string = ''; + private _quoteAssetInput: string = ''; + private _baseEstimating: boolean = false; + private _quoteEstimating: boolean = false; + private _buySell: BuySell = 'Buy'; + private _lastEdited: LastEdited = 'Base'; + + constructor() { + makeAutoObservable(this); + + reaction( + () => [this._baseAssetInput, this._quoteAssetInput, this._baseAsset, this._quoteAsset], + debounce(async () => { + if (!this._baseAsset || !this._quoteAsset) { + return; + } + if (this._lastEdited === 'Base') { + const input = this.baseInputAmount; + if (input === undefined) { + return; + } + this._quoteEstimating = true; + try { + const res = await estimateAmount(this._quoteAsset, this._baseAsset, input); + if (res === undefined) { + return; + } + this._quoteAssetInput = res.toString(); + } finally { + this._quoteEstimating = false; + } + } else { + const input = this.quoteInputAmount; + if (input === undefined) { + return; + } + this._baseEstimating = true; + try { + const res = await estimateAmount(this._baseAsset, this._quoteAsset, input); + if (res === undefined) { + return; + } + this._baseAssetInput = res.toString(); + } finally { + this._baseEstimating = false; + } + } + }, ESTIMATE_DEBOUNCE_MS), + ); + } + + get baseInput(): string { + return this._baseAssetInput; + } + + get quoteInput(): string { + return this._quoteAssetInput; + } + + set baseInput(x: string) { + this._lastEdited = 'Base'; + this._baseAssetInput = x; + } + + set quoteInput(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._buySell === 'Buy') { + if (!this._quoteAsset) { + return undefined; + } + return this._quoteAsset.formatDisplayAmount(this._quoteAsset.balance); + } + if (!this._baseAsset) { + return undefined; + } + return this._baseAsset.formatDisplayAmount(this._baseAsset.balance); + } + + set buySell(x: BuySell) { + this._buySell = x; + } + + get lastEdited(): LastEdited { + return this._lastEdited; + } + + assetChange(base: AssetInfo, quote: AssetInfo) { + this._baseAsset = base; + this._quoteAsset = quote; + this._baseAssetInput = ''; + this._quoteAssetInput = ''; + } + + get plan(): undefined | MarketOrderPlan { + if (!this._baseAsset || !this._quoteAsset) { + return; + } + const { inputAsset, inputAmount, output } = + this._buySell === '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/PriceLinkedInputs.ts b/src/pages/trade/ui/order-form/store/PriceLinkedInputs.ts index ff727a47..9994c637 100644 --- a/src/pages/trade/ui/order-form/store/PriceLinkedInputs.ts +++ b/src/pages/trade/ui/order-form/store/PriceLinkedInputs.ts @@ -1,13 +1,8 @@ +import { parseNumber } from '@/shared/utils/num'; import { makeAutoObservable } from 'mobx'; type LastEdited = 'A' | 'B'; -/** Attempt to parse a string into a number, returning `undefined` on failure. */ -const parseNumber = (x: string): number | undefined => { - const out = Number(x); - return isNaN(out) ? undefined : out; -}; - /** Compute one input from the other, given the price. * * @returns the resulting other input, or `undefined` if the input should not change. diff --git a/src/shared/utils/num.ts b/src/shared/utils/num.ts new file mode 100644 index 00000000..c19b29b2 --- /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) ? undefined : out; +}; From 15e2e33655533225a934e5ad4918a3ebd0ba986c Mon Sep 17 00:00:00 2001 From: Lucas Meier Date: Wed, 11 Dec 2024 16:12:48 -0800 Subject: [PATCH 26/44] Move AssetInfo to separate file --- src/pages/trade/model/AssetInfo.ts | 43 +++++++++++++++++++ .../order-form/store/MarketOrderFormStore.ts | 34 ++++----------- 2 files changed, 52 insertions(+), 25 deletions(-) create mode 100644 src/pages/trade/model/AssetInfo.ts diff --git a/src/pages/trade/model/AssetInfo.ts b/src/pages/trade/model/AssetInfo.ts new file mode 100644 index 00000000..f6ee1e91 --- /dev/null +++ b/src/pages/trade/model/AssetInfo.ts @@ -0,0 +1,43 @@ +import { AssetId, Value } from '@penumbra-zone/protobuf/penumbra/core/asset/v1/asset_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 balance: number, + public exponent: number, + public symbol: string, + ) {} + + /** 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(): string { + return this.formatDisplayAmount(this.balance); + } +} diff --git a/src/pages/trade/ui/order-form/store/MarketOrderFormStore.ts b/src/pages/trade/ui/order-form/store/MarketOrderFormStore.ts index 7f08fd9f..8b8210df 100644 --- a/src/pages/trade/ui/order-form/store/MarketOrderFormStore.ts +++ b/src/pages/trade/ui/order-form/store/MarketOrderFormStore.ts @@ -7,31 +7,7 @@ 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'; - -class AssetInfo { - constructor( - public id: AssetId, - public balance: number, - public exponent: number, - public symbol: string, - ) {} - - value(display: number): Value { - return new Value({ - amount: pnum(display, this.exponent).toAmount(), - assetId: this.id, - }); - } - - formatDisplayAmount(amount: number): string { - const amountString = pnum(amount, this.exponent).toFormattedString({ - commas: true, - decimals: 4, - trailingZeros: false, - }); - return `${amountString} ${this.symbol}`; - } -} +import { AssetInfo } from '@/pages/trade/model/AssetInfo'; const estimateAmount = async ( from: AssetInfo, @@ -200,6 +176,14 @@ export class MarketOrderFormStore { 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; From 9e6e637e56620920158e85334b632d25da2304bf Mon Sep 17 00:00:00 2001 From: Lucas Meier Date: Wed, 11 Dec 2024 17:14:27 -0800 Subject: [PATCH 27/44] Add store for limit order position --- .../order-form/store/LimitOrderFormStore.ts | 77 +++++++++++++++++++ .../order-form/store/MarketOrderFormStore.ts | 8 +- 2 files changed, 81 insertions(+), 4 deletions(-) create mode 100644 src/pages/trade/ui/order-form/store/LimitOrderFormStore.ts 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..ca755a97 --- /dev/null +++ b/src/pages/trade/ui/order-form/store/LimitOrderFormStore.ts @@ -0,0 +1,77 @@ +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 } from 'mobx'; + +export type BuySell = 'buy' | 'sell'; + +export class LimitOrderFormStore { + private _baseAsset?: AssetInfo; + private _quoteAsset?: AssetInfo; + private _input = new PriceLinkedInputs(); + buySell: BuySell = 'buy'; + priceInput: string = ''; + + constructor() { + makeAutoObservable(this); + } + + get baseAsset(): undefined | AssetInfo { + return this._baseAsset; + } + + get quoteAsset(): undefined | AssetInfo { + return this._quoteAsset; + } + + get baseInput(): string { + this._input.inputA; + } + + get quoteInput(): string { + this._input.inputB; + } + + get price(): number | undefined { + return parseNumber(this._input); + } + + get plan(): Position | undefined { + const input = + this.buySell === 'buy' ? parseNumber(this.quoteInput) : parseNumber(this.baseInput); + if (!input || !this._baseAsset || !this._quoteAsset) { + return undefined; + } + return limitOrderPosition({ + buy: this.buySell, + price: this.price, + input, + baseAsset: this._baseAsset, + quoteAsset: this._quoteAsset, + }); + } + + assetChange(base: AssetInfo, quote: AssetInfo) { + this._baseAsset = base; + this._quoteAsset = quote; + this._input.inputA = ''; + this._input.inputB = ''; + this._priceInput = ''; + } + + set baseInput(x: string) { + this._input.inputA = x; + } + + set quoteInput(x: string) { + this._input.inputB = x; + } + + set priceInput(x: string) { + this._priceInput = x; + const price = this.price; + if (this.price !== undefined) { + this._input.price = price; + } + } +} diff --git a/src/pages/trade/ui/order-form/store/MarketOrderFormStore.ts b/src/pages/trade/ui/order-form/store/MarketOrderFormStore.ts index 8b8210df..ba4e8f21 100644 --- a/src/pages/trade/ui/order-form/store/MarketOrderFormStore.ts +++ b/src/pages/trade/ui/order-form/store/MarketOrderFormStore.ts @@ -47,7 +47,7 @@ const estimateAmount = async ( } }; -export type BuySell = 'Buy' | 'Sell'; +export type BuySell = 'buy' | 'sell'; export type LastEdited = 'Base' | 'Quote'; @@ -67,7 +67,7 @@ export class MarketOrderFormStore { private _quoteAssetInput: string = ''; private _baseEstimating: boolean = false; private _quoteEstimating: boolean = false; - private _buySell: BuySell = 'Buy'; + private _buySell: BuySell = 'buy'; private _lastEdited: LastEdited = 'Base'; constructor() { @@ -149,7 +149,7 @@ export class MarketOrderFormStore { } get balance(): undefined | string { - if (this._buySell === 'Buy') { + if (this._buySell === 'buy') { if (!this._quoteAsset) { return undefined; } @@ -189,7 +189,7 @@ export class MarketOrderFormStore { return; } const { inputAsset, inputAmount, output } = - this._buySell === 'Buy' + this._buySell === 'buy' ? { inputAsset: this._quoteAsset, inputAmount: this.quoteInputAmount, From a55dcd84d3e47b62ca87abe7930dfd0779d07df5 Mon Sep 17 00:00:00 2001 From: Lucas Meier Date: Wed, 11 Dec 2024 17:44:23 -0800 Subject: [PATCH 28/44] Add RangeOrderFormStore --- .../order-form/store/RangeOrderFormStore.ts | 142 ++++++++++++++++++ 1 file changed, 142 insertions(+) create mode 100644 src/pages/trade/ui/order-form/store/RangeOrderFormStore.ts 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..43be43db --- /dev/null +++ b/src/pages/trade/ui/order-form/store/RangeOrderFormStore.ts @@ -0,0 +1,142 @@ +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'; + +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 && asset1.equals(asset.id)) { + out += pnum(position.reserves?.r1, asset.exponent).toNumber(); + } + if (asset2 && 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 = ''; + feeTierPercentInput = ''; + private _positionCountInput = '5'; + private _positionCountSlider = 5; + 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); + } + + get upperPrice(): number | undefined { + return parseNumber(this.upperPriceInput); + } + + get lowerPrice(): number | undefined { + return parseNumber(this.lowerPriceInput); + } + + // Treat fees that don't parse as 0 + get feeTierPercent(): number { + return Math.max(0, Math.min(parseNumber(this.feeTierPercentInput) ?? 0, 100)); + } + + get positionCountInput(): string { + return this._positionCountInput; + } + + get positionCountSlider(): number { + return this._positionCountSlider; + } + + 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(): undefined | number { + const baseAsset = this._baseAsset; + const plan = this.plan; + if (!plan || !baseAsset) { + return undefined; + } + return extractAmount(plan, baseAsset); + } + + get quoteAssetAmount(): undefined | number { + const quoteAsset = this._quoteAsset; + const plan = this.plan; + if (!plan || !quoteAsset) { + return undefined; + } + return extractAmount(plan, quoteAsset); + } + + assetChange(base: AssetInfo, quote: AssetInfo) { + this._baseAsset = base; + this._quoteAsset = quote; + this.liquidityTargetInput = ''; + this.upperPriceInput = ''; + this.lowerPriceInput = ''; + this.feeTierPercentInput = ''; + this._positionCountInput = '5'; + this._positionCountSlider = 5; + } + + set positionCountInput(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)); + } + } + + set positionCountSlider(x: number) { + this._positionCountSlider = x; + this._positionCountInput = x.toString(); + } +} From ca51fa1cdc7b59b85d61c2eff70b5f0e4dee7461 Mon Sep 17 00:00:00 2001 From: Lucas Meier Date: Wed, 11 Dec 2024 22:31:39 -0800 Subject: [PATCH 29/44] Basic wiring of form refactor --- src/pages/trade/model/AssetInfo.ts | 18 +- src/pages/trade/model/useMarketPrice.ts | 9 + src/pages/trade/ui/form-tabs.tsx | 36 ++-- .../trade/ui/order-form/order-form-limit.tsx | 105 +++++------ .../trade/ui/order-form/order-form-market.tsx | 90 +++++---- .../order-form/order-form-range-liquidity.tsx | 152 ++++++++------- src/pages/trade/ui/order-form/order-input.tsx | 4 +- .../trade/ui/order-form/segmented-control.tsx | 13 +- .../order-form/store/LimitOrderFormStore.ts | 19 +- .../order-form/store/MarketOrderFormStore.ts | 84 +++++---- .../ui/order-form/store/OrderFormStore.ts | 177 ++++++++++++++++++ .../order-form/store/RangeOrderFormStore.ts | 8 +- src/shared/api/balances.ts | 9 +- src/shared/utils/num.ts | 2 +- 14 files changed, 482 insertions(+), 244 deletions(-) create mode 100644 src/pages/trade/model/useMarketPrice.ts create mode 100644 src/pages/trade/ui/order-form/store/OrderFormStore.ts diff --git a/src/pages/trade/model/AssetInfo.ts b/src/pages/trade/model/AssetInfo.ts index f6ee1e91..cb81cfcd 100644 --- a/src/pages/trade/model/AssetInfo.ts +++ b/src/pages/trade/model/AssetInfo.ts @@ -1,4 +1,5 @@ -import { AssetId, Value } from '@penumbra-zone/protobuf/penumbra/core/asset/v1/asset_pb'; +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. @@ -13,11 +14,24 @@ export class AssetInfo { */ constructor( public id: AssetId, - public balance: number, 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({ 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 2cb11230..0e179c9a 100644 --- a/src/pages/trade/ui/form-tabs.tsx +++ b/src/pages/trade/ui/form-tabs.tsx @@ -1,43 +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.whichForm = 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 && } - {tab === FormTabsType.Range && } + {store.whichForm === 'Market' && } + {store.whichForm === 'Limit' && } + {store.whichForm === 'Range' && }
); -}; +}); diff --git a/src/pages/trade/ui/order-form/order-form-limit.tsx b/src/pages/trade/ui/order-form/order-form-limit.tsx index 0d110c7c..92d41be1 100644 --- a/src/pages/trade/ui/order-form/order-form-limit.tsx +++ b/src/pages/trade/ui/order-form/order-form-limit.tsx @@ -1,4 +1,3 @@ -import { useEffect } from 'react'; import { observer } from 'mobx-react-lite'; import { Button } from '@penumbra-zone/ui/Button'; import { Text } from '@penumbra-zone/ui/Text'; @@ -9,106 +8,86 @@ 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 { useOrderFormStore, FormType, Direction } from './store'; -import { BuyLimitOrderOptions, SellLimitOrderOptions } from './store/limit-order'; -import { useSummary } from '../../model/useSummary'; +import { useOrderFormStore } from './store/OrderFormStore'; -export const LimitOrderForm = observer(() => { - const { connected } = connectionStore; - const { - baseAsset, - quoteAsset, - direction, - setDirection, - submitOrder, - limitOrder, - isLoading, - gasFee, - exchangeRate, - } = useOrderFormStore(FormType.Limit); - const { data } = useSummary('1d'); - const price = data && 'price' in data ? data.price : undefined; +const BUY_PRICE_OPTIONS: Record number> = { + Market: (mp: number) => mp, + '-2%': mp => 0.98 * mp, + '-5%': mp => 0.95 * mp, + '-10%': mp => 0.9 * mp, + '-15%': mp => 0.85 * mp, +}; - const isBuy = direction === Direction.Buy; +const SELL_PRICE_OPTIONS: Record number> = { + Market: (mp: number) => mp, + '+2%': mp => 1.02 * mp, + '+5%': mp => 1.05 * mp, + '+10%': mp => 1.1 * mp, + '+15%': mp => 1.15 * mp, +}; - useEffect(() => { - if (price) { - limitOrder.setMarketPrice(price); - } - }, [price, limitOrder]); +export const LimitOrderForm = observer(() => { + const { connected } = connectionStore; + const parentStore = useOrderFormStore(); + const store = parentStore.limitForm; - useEffect(() => { - if (quoteAsset.exponent && baseAsset.exponent) { - limitOrder.setExponent(quoteAsset.exponent - baseAsset.exponent); - } - }, [baseAsset.exponent, quoteAsset.exponent, limitOrder]); + const isBuy = store.buySell === 'buy'; + const priceOptions = isBuy ? BUY_PRICE_OPTIONS : SELL_PRICE_OPTIONS; return (
- + (store.buySell = x)} />
(store.priceInput = x)} + denominator={store.quoteAsset?.symbol} />
- isBuy - ? limitOrder.setBuyLimitPriceOption(option as BuyLimitOrderOptions) - : limitOrder.setSellLimitPriceOption(option as SellLimitOrderOptions) + options={Object.keys(priceOptions)} + onChange={o => + (store.priceInput = (priceOptions[o] ?? (x => x))(store.marketPrice).toString()) } />
baseAsset.setAmount(amount)} - min={0} - max={1000} - isEstimating={isBuy ? baseAsset.isEstimating : false} - isApproximately={isBuy} - denominator={baseAsset.symbol} + label={isBuy ? 'Buy' : 'Sell'} + value={store.baseInput} + onChange={x => (store.baseInput = x)} + denominator={store.baseAsset?.symbol} />
quoteAsset.setAmount(amount)} - isEstimating={isBuy ? false : quoteAsset.isEstimating} - isApproximately={!isBuy} - denominator={quoteAsset.symbol} + value={store.quoteInput} + onChange={x => (store.quoteInput = x)} + denominator={store.quoteAsset?.symbol} />
- +
{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-market.tsx b/src/pages/trade/ui/order-form/order-form-market.tsx index 6de613a4..638c039f 100644 --- a/src/pages/trade/ui/order-form/order-form-market.tsx +++ b/src/pages/trade/ui/order-form/order-form-market.tsx @@ -5,75 +5,99 @@ 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 { InfoRowGasFee } from './info-row-gas-fee'; import { InfoRowTradingFee } from './info-row-trading-fee'; -import { useOrderFormStore, FormType, Direction } from './store'; +import { useOrderFormStore } from './store/OrderFormStore'; +import { Slider as PenumbraSlider } from '@penumbra-zone/ui/Slider'; + +interface SliderProps { + balance?: string; + setBalanceFraction: (fraction: number) => void; +} +const Slider = observer(({ balance, setBalanceFraction }: SliderProps) => { + return ( +
+
+ setBalanceFraction(x / 10)} + showTrackGaps={true} + trackGapBackground='base.black' + showFill={true} + /> +
+
+ + Available Balance + + +
+
+ ); +}); export const MarketOrderForm = observer(() => { const { connected } = connectionStore; - const { - baseAsset, - quoteAsset, - direction, - setDirection, - submitOrder, - isLoading, - gasFee, - exchangeRate, - } = useOrderFormStore(FormType.Market); + const parentStore = useOrderFormStore(); + const store = parentStore.marketForm; - const isBuy = direction === Direction.Buy; + const isBuy = store.buySell === 'buy'; return (
- + (store.buySell = x)} />
baseAsset.setAmount(amount)} - min={0} - max={1000} - isEstimating={isBuy ? baseAsset.isEstimating : false} + label={isBuy ? 'Buy' : 'Sell'} + value={store.baseInput} + onChange={x => (store.baseInput = x)} + isEstimating={store.baseEstimating} isApproximately={isBuy} - denominator={baseAsset.symbol} + denominator={store.baseAsset?.symbol} />
quoteAsset.setAmount(amount)} - isEstimating={isBuy ? false : quoteAsset.isEstimating} + value={store.quoteInput} + onChange={x => (store.quoteInput = x)} + 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 3451039f..183c6134 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,40 +1,43 @@ 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 { InfoRowGasFee } from './info-row-gas-fee'; -import { useOrderFormStore, FormType } from './store'; -import { - UpperBoundOptions, - LowerBoundOptions, - FeeTierOptions, - MIN_POSITIONS, - MAX_POSITIONS, -} from './store/range-liquidity'; +import { useOrderFormStore } from './store/OrderFormStore'; +import { MAX_POSITION_COUNT, MIN_POSITION_COUNT } from './store/RangeOrderFormStore'; -export const RangeLiquidityOrderForm = observer(() => { - const { connected } = connectionStore; - const { baseAsset, quoteAsset, rangeLiquidity, submitOrder, isLoading, exchangeRate } = - useOrderFormStore(FormType.RangeLiquidity); - const { data } = useSummary('1d'); - const price = data && 'price' in data ? data.price : undefined; +const LOWER_PRICE_OPTIONS: Record number> = { + Market: (mp: number) => mp, + '-2%': mp => 0.98 * mp, + '-5%': mp => 0.95 * mp, + '-10%': mp => 0.9 * mp, + '-15%': mp => 0.85 * mp, +}; + +const UPPER_PRICE_OPTIONS: Record number> = { + Market: (mp: number) => mp, + '+2%': mp => 1.02 * mp, + '+5%': mp => 1.05 * mp, + '+10%': mp => 1.1 * mp, + '+15%': mp => 1.15 * mp, +}; - useEffect(() => { - if (price) { - rangeLiquidity.setMarketPrice(price); - } - }, [price, rangeLiquidity]); +const FEE_TIERS: Record = { + '0.1%': 0.1, + '0.25%': 0.25, + '0.5%': 0.5, + '1.00%': 1, +}; - useEffect(() => { - rangeLiquidity.setAssets(baseAsset, quoteAsset); - }, [baseAsset, quoteAsset, rangeLiquidity]); +export const RangeLiquidityOrderForm = observer(() => { + const { connected } = connectionStore; + const parentStore = useOrderFormStore(); + const store = parentStore.rangeForm; return (
@@ -42,9 +45,9 @@ export const RangeLiquidityOrderForm = observer(() => {
rangeLiquidity.setTarget(target)} - denominator={quoteAsset.symbol} + value={store.liquidityTargetInput} + onChange={x => (store.liquidityTargetInput = x)} + denominator={store.quoteAsset?.symbol} />
@@ -56,18 +59,21 @@ export const RangeLiquidityOrderForm = observer(() => {
- {baseAsset.balance} {baseAsset.symbol} + {store.baseAsset?.formatBalance() ?? `-- ${store.baseAsset?.symbol}`}
@@ -77,95 +83,107 @@ export const RangeLiquidityOrderForm = observer(() => {
(store.upperPriceInput = x)} + denominator={store.quoteAsset?.symbol} />
rangeLiquidity.setUpperBoundOption(option as UpperBoundOptions)} + options={Object.keys(UPPER_PRICE_OPTIONS)} + onChange={o => + (store.upperPriceInput = (UPPER_PRICE_OPTIONS[o] ?? (x => x))( + store.marketPrice, + ).toString()) + } />
(store.lowerPriceInput = x)} + denominator={store.quoteAsset?.symbol} />
rangeLiquidity.setLowerBoundOption(option as LowerBoundOptions)} + options={Object.keys(LOWER_PRICE_OPTIONS)} + onChange={o => + (store.lowerPriceInput = (LOWER_PRICE_OPTIONS[o] ?? (x => x))( + store.marketPrice, + ).toString()) + } />
(store.feeTierPercentInput = x)} denominator='%' />
void} + options={Object.keys(FEE_TIERS)} + onChange={o => { + if (o in FEE_TIERS) { + store.feeTierPercentInput = o.toString(); + } + }} />
(store.positionCountInput = x)} /> (store.positionCountSlider = x)} showTrackGaps={true} trackGapBackground='base.black' showFill={true} />
- + - +
{connected ? ( - ) : ( )}
- {price !== undefined && ( + {parentStore.marketPrice && (
- 1 {baseAsset.symbol} ={' '} + 1 {store.baseAsset?.symbol} ={' '} - {price} {quoteAsset.symbol} + {store.quoteAsset?.formatDisplayAmount(parentStore.marketPrice)}
diff --git a/src/pages/trade/ui/order-form/order-input.tsx b/src/pages/trade/ui/order-form/order-input.tsx index dc96aae6..0c6ffa74 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 | string | null; + value: string; placeholder?: string; isEstimating?: boolean; isApproximately?: boolean; @@ -88,7 +88,7 @@ export const OrderInput = forwardRef( "[&[type='number']]:[-moz-appearance:textfield]", )} style={{ paddingRight: denomWidth + 20 }} - value={value !== 0 ? value : ''} + value={value} onChange={e => onChange?.(e.target.value)} placeholder={placeholder} type='number' 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/store/LimitOrderFormStore.ts b/src/pages/trade/ui/order-form/store/LimitOrderFormStore.ts index ca755a97..da07e95c 100644 --- a/src/pages/trade/ui/order-form/store/LimitOrderFormStore.ts +++ b/src/pages/trade/ui/order-form/store/LimitOrderFormStore.ts @@ -2,6 +2,8 @@ import { Position } from '@penumbra-zone/protobuf/penumbra/core/component/dex/v1 import { PriceLinkedInputs } from './PriceLinkedInputs'; import { limitOrderPosition } from '@/shared/math/position'; import { makeAutoObservable } from 'mobx'; +import { AssetInfo } from '@/pages/trade/model/AssetInfo'; +import { parseNumber } from '@/shared/utils/num'; export type BuySell = 'buy' | 'sell'; @@ -10,7 +12,8 @@ export class LimitOrderFormStore { private _quoteAsset?: AssetInfo; private _input = new PriceLinkedInputs(); buySell: BuySell = 'buy'; - priceInput: string = ''; + marketPrice = 1.0; + private _priceInput: string = ''; constructor() { makeAutoObservable(this); @@ -25,21 +28,25 @@ export class LimitOrderFormStore { } get baseInput(): string { - this._input.inputA; + return this._input.inputA; } get quoteInput(): string { - this._input.inputB; + return this._input.inputB; + } + + get priceInput(): string { + return this._priceInput; } get price(): number | undefined { - return parseNumber(this._input); + return parseNumber(this._priceInput); } get plan(): Position | undefined { const input = this.buySell === 'buy' ? parseNumber(this.quoteInput) : parseNumber(this.baseInput); - if (!input || !this._baseAsset || !this._quoteAsset) { + if (!input || !this._baseAsset || !this._quoteAsset || !this.price) { return undefined; } return limitOrderPosition({ @@ -70,7 +77,7 @@ export class LimitOrderFormStore { set priceInput(x: string) { this._priceInput = x; const price = this.price; - if (this.price !== undefined) { + if (price !== undefined) { this._input.price = price; } } diff --git a/src/pages/trade/ui/order-form/store/MarketOrderFormStore.ts b/src/pages/trade/ui/order-form/store/MarketOrderFormStore.ts index ba4e8f21..e41e3111 100644 --- a/src/pages/trade/ui/order-form/store/MarketOrderFormStore.ts +++ b/src/pages/trade/ui/order-form/store/MarketOrderFormStore.ts @@ -53,7 +53,7 @@ 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 = 200; +const ESTIMATE_DEBOUNCE_MS = 160; export interface MarketOrderPlan { targetAsset: AssetId; @@ -67,48 +67,54 @@ export class MarketOrderFormStore { private _quoteAssetInput: string = ''; private _baseEstimating: boolean = false; private _quoteEstimating: boolean = false; - private _buySell: BuySell = 'buy'; + buySell: BuySell = 'buy'; private _lastEdited: LastEdited = 'Base'; constructor() { makeAutoObservable(this); + // Two reactions to avoid a double trigger. reaction( - () => [this._baseAssetInput, this._quoteAssetInput, this._baseAsset, this._quoteAsset], + () => [this._lastEdited, this._baseAssetInput, this._baseAsset, this._quoteAsset], debounce(async () => { - if (!this._baseAsset || !this._quoteAsset) { + if (!this._baseAsset || !this._quoteAsset || this._lastEdited !== 'Base') { return; } - if (this._lastEdited === 'Base') { - const input = this.baseInputAmount; - if (input === undefined) { + const input = this.baseInputAmount; + if (input === undefined) { + return; + } + this._quoteEstimating = true; + try { + const res = await estimateAmount(this._quoteAsset, this._baseAsset, input); + if (res === undefined) { return; } - this._quoteEstimating = true; - try { - const res = await estimateAmount(this._quoteAsset, this._baseAsset, input); - if (res === undefined) { - return; - } - this._quoteAssetInput = res.toString(); - } finally { - this._quoteEstimating = false; - } - } else { - const input = this.quoteInputAmount; - if (input === undefined) { + this._quoteAssetInput = res.toString(); + } finally { + this._quoteEstimating = false; + } + }, ESTIMATE_DEBOUNCE_MS), + ); + reaction( + () => [this._lastEdited, this._quoteAssetInput, this._baseAsset, this._quoteAsset], + debounce(async () => { + if (!this._baseAsset || !this._quoteAsset || this._lastEdited !== 'Quote') { + return; + } + const input = this.quoteInputAmount; + if (input === undefined) { + return; + } + this._baseEstimating = true; + try { + const res = await estimateAmount(this._baseAsset, this._quoteAsset, input); + if (res === undefined) { return; } - this._baseEstimating = true; - try { - const res = await estimateAmount(this._baseAsset, this._quoteAsset, input); - if (res === undefined) { - return; - } - this._baseAssetInput = res.toString(); - } finally { - this._baseEstimating = false; - } + this._baseAssetInput = res.toString(); + } finally { + this._baseEstimating = false; } }, ESTIMATE_DEBOUNCE_MS), ); @@ -149,20 +155,26 @@ export class MarketOrderFormStore { } get balance(): undefined | string { - if (this._buySell === 'buy') { - if (!this._quoteAsset) { + if (this.buySell === 'buy') { + if (!this._quoteAsset?.balance) { return undefined; } return this._quoteAsset.formatDisplayAmount(this._quoteAsset.balance); } - if (!this._baseAsset) { + if (!this._baseAsset?.balance) { return undefined; } return this._baseAsset.formatDisplayAmount(this._baseAsset.balance); } - set buySell(x: BuySell) { - this._buySell = x; + setBalanceFraction(x: number) { + x = Math.max(0.0, Math.min(1.0, x)); + if (this.buySell === 'buy' && this._quoteAsset?.balance) { + this.quoteInput = (x * this._quoteAsset.balance).toString(); + } + if (this.buySell === 'sell' && this._baseAsset?.balance) { + this.baseInput = (x * this._baseAsset.balance).toString(); + } } get lastEdited(): LastEdited { @@ -189,7 +201,7 @@ export class MarketOrderFormStore { return; } const { inputAsset, inputAmount, output } = - this._buySell === 'buy' + this.buySell === 'buy' ? { inputAsset: this._quoteAsset, inputAmount: this.quoteInputAmount, 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..7d6ad82d --- /dev/null +++ b/src/pages/trade/ui/order-form/store/OrderFormStore.ts @@ -0,0 +1,177 @@ +import { makeAutoObservable } 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 } 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 { useEffect } from 'react'; +import { useMarketPrice } from '@/pages/trade/model/useMarketPrice'; + +export type WhichForm = 'Market' | 'Limit' | 'Range'; + +export const isWhichForm = (x: string): x is WhichForm => { + return x === 'Market' || x === 'Limit' || x === 'Range'; +}; + +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; + + constructor() { + makeAutoObservable(this); + } + + assetChange(base: AssetInfo, quote: AssetInfo) { + this._market.assetChange(base, quote); + this._limit.assetChange(base, quote); + this._range.assetChange(base, quote); + } + + set marketPrice(price: number) { + this._marketPrice = price; + this._range.marketPrice = price; + this._limit.marketPrice = price; + } + + get marketPrice(): number | undefined { + return this._marketPrice; + } + + set whichForm(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, + }); + } + if (this._whichForm === 'Range') { + const plan = this._range.plan; + if (plan === undefined) { + return undefined; + } + return new TransactionPlannerRequest({ + positionOpens: plan.map(x => ({ position: x })), + source: this.subAccountIndex, + }); + } + return undefined; + } + + get canSubmit(): boolean { + return !this._submitting && this.plan !== undefined; + } +} + +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; +}; + +const orderFormStore = new OrderFormStore(); + +export const useOrderFormStore = () => { + const { baseAsset, quoteAsset } = usePathToMetadata(); + const { data: subAccounts } = useSubaccounts(); + 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; + } + const { data: balances } = useBalances(addressIndex); + useEffect(() => { + orderFormStore.subAccountIndex = addressIndex; + orderFormStore.address = address; + if ( + !baseAsset?.symbol || + !baseAsset?.penumbraAssetId || + !quoteAsset?.symbol || + !quoteAsset?.penumbraAssetId + ) { + return; + } + 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); + if (baseAssetInfo && quoteAssetInfo) { + orderFormStore.assetChange(baseAssetInfo, quoteAssetInfo); + orderFormStore.subAccountIndex = addressIndex; + } + }, [orderFormStore, baseAsset, quoteAsset, balances, address, addressIndex]); + + const marketPrice = useMarketPrice(); + + useEffect(() => { + if (!marketPrice) { + return; + } + orderFormStore.marketPrice = marketPrice; + }, [orderFormStore, marketPrice]); + return orderFormStore; +}; diff --git a/src/pages/trade/ui/order-form/store/RangeOrderFormStore.ts b/src/pages/trade/ui/order-form/store/RangeOrderFormStore.ts index 43be43db..110f151a 100644 --- a/src/pages/trade/ui/order-form/store/RangeOrderFormStore.ts +++ b/src/pages/trade/ui/order-form/store/RangeOrderFormStore.ts @@ -98,22 +98,22 @@ export class RangeOrderFormStore { }); } - get baseAssetAmount(): undefined | number { + get baseAssetAmount(): string | undefined { const baseAsset = this._baseAsset; const plan = this.plan; if (!plan || !baseAsset) { return undefined; } - return extractAmount(plan, baseAsset); + return baseAsset.formatDisplayAmount(extractAmount(plan, baseAsset)); } - get quoteAssetAmount(): undefined | number { + get quoteAssetAmount(): string | undefined { const quoteAsset = this._quoteAsset; const plan = this.plan; if (!plan || !quoteAsset) { return undefined; } - return extractAmount(plan, quoteAsset); + return quoteAsset.formatDisplayAmount(extractAmount(plan, quoteAsset)); } assetChange(base: AssetInfo, quote: AssetInfo) { 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/utils/num.ts b/src/shared/utils/num.ts index c19b29b2..cef4662a 100644 --- a/src/shared/utils/num.ts +++ b/src/shared/utils/num.ts @@ -1,5 +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) ? undefined : out; + return isNaN(out) || x.length <= 0 ? undefined : out; }; From 58c0096d66b6b1ec36425329360e0a49fe608238 Mon Sep 17 00:00:00 2001 From: Lucas Meier Date: Wed, 11 Dec 2024 23:01:42 -0800 Subject: [PATCH 30/44] Implement order submission --- .../trade/ui/order-form/order-form-limit.tsx | 6 ++++- .../trade/ui/order-form/order-form-market.tsx | 2 +- .../order-form/order-form-range-liquidity.tsx | 6 ++++- .../ui/order-form/store/OrderFormStore.ts | 27 +++++++++++++++++++ src/shared/math/position.ts | 4 ++- 5 files changed, 41 insertions(+), 4 deletions(-) diff --git a/src/pages/trade/ui/order-form/order-form-limit.tsx b/src/pages/trade/ui/order-form/order-form-limit.tsx index 92d41be1..8dbe76e9 100644 --- a/src/pages/trade/ui/order-form/order-form-limit.tsx +++ b/src/pages/trade/ui/order-form/order-form-limit.tsx @@ -75,7 +75,11 @@ export const LimitOrderForm = observer(() => {
{connected ? ( - ) : ( 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 638c039f..6bf170be 100644 --- a/src/pages/trade/ui/order-form/order-form-market.tsx +++ b/src/pages/trade/ui/order-form/order-form-market.tsx @@ -84,7 +84,7 @@ export const MarketOrderForm = observer(() => { 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 183c6134..6c3106a8 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 @@ -171,7 +171,11 @@ export const RangeLiquidityOrderForm = observer(() => {
{connected ? ( - ) : ( diff --git a/src/pages/trade/ui/order-form/store/OrderFormStore.ts b/src/pages/trade/ui/order-form/store/OrderFormStore.ts index 7d6ad82d..63d4fc18 100644 --- a/src/pages/trade/ui/order-form/store/OrderFormStore.ts +++ b/src/pages/trade/ui/order-form/store/OrderFormStore.ts @@ -15,6 +15,8 @@ import { connectionStore } from '@/shared/model/connection'; import { useSubaccounts } from '@/widgets/header/api/subaccounts'; import { useEffect } from 'react'; import { useMarketPrice } from '@/pages/trade/model/useMarketPrice'; +import { planBuildBroadcast } from '../helpers'; +import { getSwapCommitmentFromTx } from '@penumbra-zone/getters/transaction'; export type WhichForm = 'Market' | 'Limit' | 'Range'; @@ -112,6 +114,31 @@ export class OrderFormStore { 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; + } + 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 { + this._submitting = false; + } + } } const pluckAssetBalance = (symbol: string, balances: BalancesResponse[]): undefined | Amount => { diff --git a/src/shared/math/position.ts b/src/shared/math/position.ts index b9d83212..1d81258f 100644 --- a/src/shared/math/position.ts +++ b/src/shared/math/position.ts @@ -16,7 +16,7 @@ import BigNumber from 'bignumber.js'; const PRECISION_DECIMALS = 12; const compareAssetId = (a: AssetId, b: AssetId): number => { - for (let i = 0; i < 32; ++i) { + 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) { @@ -85,6 +85,7 @@ const priceToPQ = ( * as an escape hatch in case any of those use cases aren't sufficient. */ export const planToPosition = (plan: PositionPlan): Position => { + console.log(plan); const { p: raw_p, q: raw_q } = priceToPQ( plan.price, plan.baseAsset.exponent, @@ -94,6 +95,7 @@ export const planToPosition = (plan: PositionPlan): Position => { const raw_r2 = pnum(plan.quoteReserves, plan.quoteAsset.exponent).toAmount(); const correctOrder = compareAssetId(plan.baseAsset.id, plan.quoteAsset.id) <= 0; + console.table({ correctOrder, raw_p, raw_q, raw_r1, raw_r2 }); const [[p, q], [r1, r2]] = correctOrder ? [ [raw_p, raw_q], From df756fca56f87e9b7ad3afc17d028bfa46e18755 Mon Sep 17 00:00:00 2001 From: Lucas Meier Date: Wed, 11 Dec 2024 23:05:44 -0800 Subject: [PATCH 31/44] Fix range liquidity fee tier selection --- src/pages/trade/ui/order-form/order-form-range-liquidity.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 6c3106a8..b42c181b 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 @@ -128,7 +128,7 @@ export const RangeLiquidityOrderForm = observer(() => { options={Object.keys(FEE_TIERS)} onChange={o => { if (o in FEE_TIERS) { - store.feeTierPercentInput = o.toString(); + store.feeTierPercentInput = (FEE_TIERS[o] ?? 0).toString(); } }} /> From 4a9c90f72e8e805e2b0db4bacc49517ed2eb42a9 Mon Sep 17 00:00:00 2001 From: Lucas Meier Date: Wed, 11 Dec 2024 23:33:18 -0800 Subject: [PATCH 32/44] Correct range liquidity --- src/shared/math/position.ts | 28 +++++++++++++++------------- 1 file changed, 15 insertions(+), 13 deletions(-) diff --git a/src/shared/math/position.ts b/src/shared/math/position.ts index 1d81258f..3fa6fb82 100644 --- a/src/shared/math/position.ts +++ b/src/shared/math/position.ts @@ -15,7 +15,7 @@ import BigNumber from 'bignumber.js'; // In the year 202X, when 1 BTC = 1 million USD, then this is still only 1e12 < 2^50. const PRECISION_DECIMALS = 12; -const compareAssetId = (a: AssetId, b: AssetId): number => { +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; @@ -85,25 +85,27 @@ const priceToPQ = ( * as an escape hatch in case any of those use cases aren't sufficient. */ export const planToPosition = (plan: PositionPlan): Position => { - console.log(plan); - const { p: raw_p, q: raw_q } = priceToPQ( + const { p: rawP, q: rawQ } = priceToPQ( plan.price, plan.baseAsset.exponent, plan.quoteAsset.exponent, ); - const raw_r1 = pnum(plan.baseReserves, plan.baseAsset.exponent).toAmount(); - const raw_r2 = pnum(plan.quoteReserves, plan.quoteAsset.exponent).toAmount(); + 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; - console.table({ correctOrder, raw_p, raw_q, raw_r1, raw_r2 }); - const [[p, q], [r1, r2]] = correctOrder + const [[p, q], [r1, r2], [a1, a2]] = correctOrder ? [ - [raw_p, raw_q], - [raw_r1, raw_r2], + [rawP, rawQ], + [rawR1, rawR2], + [rawA1, rawA2], ] : [ - [raw_q, raw_p], - [raw_r2, raw_r1], + [rawQ, rawP], + [rawR2, rawR1], + [rawA2, rawA1], ]; return new Position({ @@ -114,8 +116,8 @@ export const planToPosition = (plan: PositionPlan): Position => { q: pnum(q).toAmount(), }, pair: new TradingPair({ - asset1: plan.baseAsset.id, - asset2: plan.quoteAsset.id, + asset1: a1.id, + asset2: a2.id, }), }, nonce: crypto.getRandomValues(new Uint8Array(32)), From b3dc667ae2c4065a1e6d21ea6a4035073073376a Mon Sep 17 00:00:00 2001 From: Lucas Meier Date: Thu, 12 Dec 2024 00:07:12 -0800 Subject: [PATCH 33/44] Gas fee calculation, remove unused stuff --- src/pages/trade/model/AssetInfo.ts | 4 +- .../trade/ui/order-form/info-row-gas-fee.tsx | 12 +- .../trade/ui/order-form/order-form-limit.tsx | 6 +- .../trade/ui/order-form/order-form-market.tsx | 6 +- .../order-form/order-form-range-liquidity.tsx | 6 +- src/pages/trade/ui/order-form/slider.tsx | 40 -- .../order-form/store/LimitOrderFormStore.ts | 2 +- .../order-form/store/MarketOrderFormStore.ts | 8 +- .../ui/order-form/store/OrderFormStore.ts | 89 ++- .../ui/order-form/store/PriceLinkedInputs.ts | 6 +- .../order-form/store/RangeOrderFormStore.ts | 6 +- src/pages/trade/ui/order-form/store/asset.ts | 107 ---- src/pages/trade/ui/order-form/store/index.ts | 510 ------------------ .../trade/ui/order-form/store/limit-order.ts | 75 --- .../ui/order-form/store/range-liquidity.ts | 233 -------- 15 files changed, 121 insertions(+), 989 deletions(-) delete mode 100644 src/pages/trade/ui/order-form/slider.tsx 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/limit-order.ts delete mode 100644 src/pages/trade/ui/order-form/store/range-liquidity.ts diff --git a/src/pages/trade/model/AssetInfo.ts b/src/pages/trade/model/AssetInfo.ts index cb81cfcd..22e93b7a 100644 --- a/src/pages/trade/model/AssetInfo.ts +++ b/src/pages/trade/model/AssetInfo.ts @@ -51,7 +51,7 @@ export class AssetInfo { } /** Format the balance of this asset as a simple string. */ - formatBalance(): string { - return this.formatDisplayAmount(this.balance); + formatBalance(): undefined | string { + return this.balance !== undefined ? this.formatDisplayAmount(this.balance) : undefined; } } 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 index 3cad5fdd..cb06a330 100644 --- a/src/pages/trade/ui/order-form/info-row-gas-fee.tsx +++ b/src/pages/trade/ui/order-form/info-row-gas-fee.tsx @@ -1,10 +1,18 @@ import { InfoRow } from './info-row'; -export const InfoRowGasFee = ({ gasFee, symbol }: { gasFee: number | null; symbol: string }) => { +export const InfoRowGasFee = ({ + gasFee, + symbol, + isLoading, +}: { + gasFee: string; + symbol: string; + isLoading: boolean; +}) => { return ( diff --git a/src/pages/trade/ui/order-form/order-form-limit.tsx b/src/pages/trade/ui/order-form/order-form-limit.tsx index 8dbe76e9..13b6854a 100644 --- a/src/pages/trade/ui/order-form/order-form-limit.tsx +++ b/src/pages/trade/ui/order-form/order-form-limit.tsx @@ -71,7 +71,11 @@ export const LimitOrderForm = observer(() => {
- +
{connected ? ( 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 6bf170be..20ff0062 100644 --- a/src/pages/trade/ui/order-form/order-form-market.tsx +++ b/src/pages/trade/ui/order-form/order-form-market.tsx @@ -77,7 +77,11 @@ export const MarketOrderForm = observer(() => { store.setBalanceFraction(x)} />
- +
{connected ? ( 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 b42c181b..631b21f8 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 @@ -167,7 +167,11 @@ export const RangeLiquidityOrderForm = observer(() => { value={store.quoteAssetAmount} toolTip={`The amount of ${store.quoteAsset?.symbol} provided as liquidity`} /> - +
{connected ? ( 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 d226b3e8..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 index da07e95c..8510ce03 100644 --- a/src/pages/trade/ui/order-form/store/LimitOrderFormStore.ts +++ b/src/pages/trade/ui/order-form/store/LimitOrderFormStore.ts @@ -13,7 +13,7 @@ export class LimitOrderFormStore { private _input = new PriceLinkedInputs(); buySell: BuySell = 'buy'; marketPrice = 1.0; - private _priceInput: string = ''; + private _priceInput = ''; constructor() { makeAutoObservable(this); diff --git a/src/pages/trade/ui/order-form/store/MarketOrderFormStore.ts b/src/pages/trade/ui/order-form/store/MarketOrderFormStore.ts index e41e3111..02f4f9e4 100644 --- a/src/pages/trade/ui/order-form/store/MarketOrderFormStore.ts +++ b/src/pages/trade/ui/order-form/store/MarketOrderFormStore.ts @@ -63,10 +63,10 @@ export interface MarketOrderPlan { export class MarketOrderFormStore { private _baseAsset?: AssetInfo; private _quoteAsset?: AssetInfo; - private _baseAssetInput: string = ''; - private _quoteAssetInput: string = ''; - private _baseEstimating: boolean = false; - private _quoteEstimating: boolean = false; + private _baseAssetInput = ''; + private _quoteAssetInput = ''; + private _baseEstimating = false; + private _quoteEstimating = false; buySell: BuySell = 'buy'; private _lastEdited: LastEdited = 'Base'; diff --git a/src/pages/trade/ui/order-form/store/OrderFormStore.ts b/src/pages/trade/ui/order-form/store/OrderFormStore.ts index 63d4fc18..6e6a97cc 100644 --- a/src/pages/trade/ui/order-form/store/OrderFormStore.ts +++ b/src/pages/trade/ui/order-form/store/OrderFormStore.ts @@ -1,4 +1,4 @@ -import { makeAutoObservable } from 'mobx'; +import { makeAutoObservable, reaction } from 'mobx'; import { LimitOrderFormStore } from './LimitOrderFormStore'; import { MarketOrderFormStore } from './MarketOrderFormStore'; import { RangeOrderFormStore } from './RangeOrderFormStore'; @@ -15,8 +15,11 @@ import { connectionStore } from '@/shared/model/connection'; import { useSubaccounts } from '@/widgets/header/api/subaccounts'; import { useEffect } from 'react'; import { useMarketPrice } from '@/pages/trade/model/useMarketPrice'; -import { planBuildBroadcast } from '../helpers'; +import { plan, planBuildBroadcast } from '../helpers'; 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'; export type WhichForm = 'Market' | 'Limit' | 'Range'; @@ -24,6 +27,8 @@ 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(); @@ -33,9 +38,67 @@ export class OrderFormStore { 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(async () => { + if (!this.plan || !this._umAsset) { + return; + } + this._gasFeeLoading = true; + try { + const res = await plan(this.plan); + const fee = res.transactionParameters?.fee; + if (!fee) { + return; + } + this._gasFee = { + symbol: this._umAsset.symbol, + display: pnum(fee.amount, this._umAsset.exponent).toNumber().toString(), + }; + } finally { + this._gasFeeLoading = false; + } + }, GAS_DEBOUNCE_MS), + ); + } + + async calculateGasFee() { + if (!this.plan || !this._umAsset) { + return; + } + this._gasFeeLoading = true; + try { + const res = await plan(this.plan); + const fee = res.transactionParameters?.fee; + if (!fee) { + return; + } + this._gasFee = { + symbol: this._umAsset.symbol, + display: pnum(fee.amount, this._umAsset.exponent).toNumber().toString(), + }; + } finally { + this._gasFeeLoading = false; + } + } + + set umAsset(x: AssetInfo) { + this._umAsset = x; + } + + get gasFee(): { symbol: string; display: string } { + return this._gasFee; + } + + get gasFeeLoading(): boolean { + return this._gasFeeLoading; } assetChange(base: AssetInfo, quote: AssetInfo) { @@ -146,8 +209,8 @@ const pluckAssetBalance = (symbol: string, balances: BalancesResponse[]): undefi 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 (balance.balanceView.valueView.value.metadata?.symbol === symbol) { + const amount = balance.balanceView.valueView.value.amount; if (amount) { return amount; } @@ -159,6 +222,14 @@ const pluckAssetBalance = (symbol: string, balances: BalancesResponse[]): undefi const orderFormStore = new OrderFormStore(); export const useOrderFormStore = () => { + const { data: assets } = useRegistryAssets(); + let umAsset: AssetInfo | undefined; + if (assets) { + const meta = assets.find(x => x.symbol === 'UM'); + if (meta) { + umAsset = AssetInfo.fromMetadata(meta); + } + } const { baseAsset, quoteAsset } = usePathToMetadata(); const { data: subAccounts } = useSubaccounts(); const subAccount = subAccounts ? subAccounts[connectionStore.subaccount] : undefined; @@ -175,9 +246,9 @@ export const useOrderFormStore = () => { orderFormStore.address = address; if ( !baseAsset?.symbol || - !baseAsset?.penumbraAssetId || + !baseAsset.penumbraAssetId || !quoteAsset?.symbol || - !quoteAsset?.penumbraAssetId + !quoteAsset.penumbraAssetId ) { return; } @@ -200,5 +271,11 @@ export const useOrderFormStore = () => { } orderFormStore.marketPrice = marketPrice; }, [orderFormStore, marketPrice]); + + useEffect(() => { + if (umAsset) { + orderFormStore.umAsset = umAsset; + } + }, [umAsset]); return orderFormStore; }; diff --git a/src/pages/trade/ui/order-form/store/PriceLinkedInputs.ts b/src/pages/trade/ui/order-form/store/PriceLinkedInputs.ts index 9994c637..4d479ecc 100644 --- a/src/pages/trade/ui/order-form/store/PriceLinkedInputs.ts +++ b/src/pages/trade/ui/order-form/store/PriceLinkedInputs.ts @@ -27,10 +27,10 @@ const computeBFromA = (price: number, a: string): string | undefined => { * or if they adjust the limit price. */ export class PriceLinkedInputs { - private _inputA: string = ''; - private _inputB: string = ''; + private _inputA = ''; + private _inputB = ''; private _lastEdited: LastEdited = 'A'; - private _price: number = 1; + private _price = 1; constructor() { makeAutoObservable(this); diff --git a/src/pages/trade/ui/order-form/store/RangeOrderFormStore.ts b/src/pages/trade/ui/order-form/store/RangeOrderFormStore.ts index 110f151a..00792b1a 100644 --- a/src/pages/trade/ui/order-form/store/RangeOrderFormStore.ts +++ b/src/pages/trade/ui/order-form/store/RangeOrderFormStore.ts @@ -10,10 +10,10 @@ const extractAmount = (positions: Position[], asset: AssetInfo): number => { for (const position of positions) { const asset1 = position.phi?.pair?.asset1; const asset2 = position.phi?.pair?.asset2; - if (asset1 && asset1.equals(asset.id)) { + if (asset1?.equals(asset.id)) { out += pnum(position.reserves?.r1, asset.exponent).toNumber(); } - if (asset2 && asset2.equals(asset.id)) { + if (asset2?.equals(asset.id)) { out += pnum(position.reserves?.r2, asset.exponent).toNumber(); } } @@ -60,7 +60,7 @@ export class RangeOrderFormStore { // Treat fees that don't parse as 0 get feeTierPercent(): number { - return Math.max(0, Math.min(parseNumber(this.feeTierPercentInput) ?? 0, 100)); + return Math.max(0, Math.min(parseNumber(this.feeTierPercentInput) ?? 0, 50)); } get positionCountInput(): string { 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 93dbaaba..00000000 --- a/src/pages/trade/ui/order-form/store/asset.ts +++ /dev/null @@ -1,107 +0,0 @@ -import { makeAutoObservable } from 'mobx'; -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 { pnum } from '@penumbra-zone/types/pnum'; -import { LoHi } from '@penumbra-zone/types/lo-hi'; -import { - AddressView, - Address, - AddressIndex, -} from '@penumbra-zone/protobuf/penumbra/core/keys/v1/keys_pb'; - -export class OrderFormAsset { - metadata?: Metadata; - balanceView?: ValueView; - addressView?: AddressView; - amount?: number | string; - onAmountChangeCallback?: (asset: OrderFormAsset) => Promise; - isEstimating = false; - - constructor(metadata?: Metadata) { - makeAutoObservable(this); - - this.metadata = metadata; - } - - setBalanceView = (balanceView: ValueView): void => { - this.balanceView = balanceView; - }; - - setAccountAddress = (addressView: AddressView): void => { - this.addressView = addressView; - }; - - get accountAddress(): Address | undefined { - return this.addressView ? getAddress(this.addressView) : undefined; - } - - get accountIndex(): AddressIndex | undefined { - return this.addressView ? getAddressIndex(this.addressView) : undefined; - } - - get assetId(): AssetId | undefined { - return this.metadata ? getAssetId(this.metadata) : undefined; - } - - get exponent(): number | undefined { - return this.metadata ? getDisplayDenomExponent(this.metadata) : undefined; - } - - get balance(): string | undefined { - if (!this.balanceView) { - return undefined; - } - - return pnum(this.balanceView).toFormattedString({ - decimals: this.exponent, - }); - } - - get symbol(): string { - return this.metadata?.symbol ?? ''; - } - - setAmount = (amount: string | number, callOnAmountChange = true): void => { - if (this.amount !== amount) { - this.amount = amount; - - 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; - }; - - toLoHi = (): LoHi => { - return pnum(this.amount, this.exponent).toLoHi(); - }; - - toBaseUnits = (): bigint => { - return pnum(this.amount, this.exponent).toBigInt(); - }; - - toValue = (): Value => { - return new Value({ - assetId: this.assetId, - amount: this.toLoHi(), - }); - }; -} 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 14ce0443..00000000 --- a/src/pages/trade/ui/order-form/store/index.ts +++ /dev/null @@ -1,510 +0,0 @@ -import { useEffect } from 'react'; -import { makeAutoObservable } from 'mobx'; -import debounce from 'lodash/debounce'; -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 { getAssetId } from '@penumbra-zone/getters/metadata'; -import { getSwapCommitmentFromTx } from '@penumbra-zone/getters/transaction'; -import { getAssetIdFromValueView } from '@penumbra-zone/getters/value-view'; -import { pnum } from '@penumbra-zone/types/pnum'; -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 { LimitOrder } from './limit-order'; -import { limitOrderPosition } from '@/shared/math/position'; - -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(); - limitOrder = new LimitOrder(); - 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); - try { - if (!this.balances?.length) { - return; - } - - const baseAssetBalance = this.balances.find(resp => - getAssetIdFromValueView(resp.balanceView).equals(getAssetId(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(this.quoteAsset.metadata)), - ); - if (quoteAssetBalance?.balanceView) { - this.quoteAsset.setBalanceView(quoteAssetBalance.balanceView); - } - if (quoteAssetBalance?.accountAddress) { - this.quoteAsset.setAccountAddress(quoteAssetBalance.accountAddress); - } - } catch (e) { - openToast({ - type: 'error', - message: 'Error setting form balances', - description: JSON.stringify(e), - }); - } - }; - - 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 && - ![ - 'ConnectError', - 'PenumbraNotInstalledError', - 'PenumbraProviderNotAvailableError', - 'PenumbraProviderNotConnectedError', - ].includes(e.name) - ) { - 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); - - 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; - - if (this.type === FormType.Market) { - try { - void this.calculateGasFee(); - - assetOut.setIsEstimating(true); - - const output = await this.simulateSwapTx(assetIn, assetOut); - if (!output) { - return; - } - - assetOut.setAmount(pnum(output).toFormattedString(), false); - } finally { - assetOut.setIsEstimating(false); - } - } - - if (this.type === FormType.Limit) { - try { - void this.calculateGasFee(); - - assetOut.setIsEstimating(true); - - if (assetIsBaseAsset) { - const amount = Number(this.baseAsset.amount) * this.limitOrder.price; - - if (amount !== this.quoteAsset.amount) { - this.quoteAsset.setAmount(amount, true); - } - } else { - const amount = Number(this.quoteAsset.amount) / this.limitOrder.price; - - if (amount !== this.baseAsset.amount) { - this.baseAsset.setAmount(amount, true); - } - } - } 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; - } - - this.exchangeRate = pnum(output).toRoundedNumber(); - }; - - 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.toLoHi(), - assetId: assetIn.assetId, - }, - claimAddress: assetIn.accountAddress, - }, - ], - source: assetIn.accountIndex, - }); - - const txPlan = await plan(req); - const fee = txPlan.transactionParameters?.fee; - console.log('TCL: OrderFormStore -> fee', fee); - - this.gasFee = pnum(fee?.amount, this.baseAsset.exponent).toRoundedNumber(); - }; - - calculateLimitGasFee = async (): Promise => { - console.log('called'); - this.gasFee = null; - - const isBuy = this.direction === Direction.Buy; - const assetIn = isBuy ? this.quoteAsset : this.baseAsset; - console.log('TCL: OrderFormStore -> assetIn', assetIn, assetIn.amount); - const assetOut = isBuy ? this.baseAsset : this.quoteAsset; - console.log('TCL: OrderFormStore -> assetOut', assetOut, assetOut.amount); - - if (!assetIn.amount || !assetOut.amount) { - this.gasFee = 0; - return; - } - - const positionsReq = new TransactionPlannerRequest({ - positionOpens: [ - { - position: this.buildLimitPosition(), - }, - ], - source: this.quoteAsset.accountIndex, - }); - - console.log('TCL: OrderFormStore -> positionsReq', positionsReq); - const txPlan = await plan(positionsReq); - const fee = txPlan.transactionParameters?.fee; - console.log('TCL: OrderFormStore -> fee', fee); - - this.gasFee = pnum(fee?.amount, this.baseAsset.exponent).toRoundedNumber(); - }; - - // ref: https://github.com/penumbra-zone/penumbra/blob/main/crates/bin/pcli/src/command/tx/replicate/linear.rs - buildLimitPosition = (): Position => { - const { price } = this.limitOrder as Required; - if ( - !this.baseAsset.assetId || - !this.quoteAsset.assetId || - !this.quoteAsset.exponent || - !this.baseAsset.exponent || - !this.quoteAsset.amount - ) { - throw new Error('incomplete limit position form'); - } - - return limitOrderPosition({ - buy: this.direction === Direction.Buy ? 'buy' : 'sell', - price: Number(price), - baseAsset: { - id: this.baseAsset.assetId, - exponent: this.baseAsset.exponent, - }, - quoteAsset: { - id: this.quoteAsset.assetId, - exponent: this.quoteAsset.exponent, - }, - input: - this.direction === Direction.Buy - ? Number(this.quoteAsset.amount) - : Number(this.quoteAsset.amount) / Number(price), - }); - }; - - calculateGasFee = async (): Promise => { - if (this.type === FormType.Market) { - await this.calculateMarketGasFee(); - } - - if (this.type === FormType.Limit) { - await this.calculateLimitGasFee(); - } - }; - - 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.toLoHi(), - 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; - } - }; - - initiateLimitPositionTx = async (): Promise => { - try { - this.isLoading = true; - - const { price } = this.limitOrder; - if (!price) { - openToast({ - type: 'error', - message: 'Please enter a valid limit price.', - }); - return; - } - - const positionsReq = new TransactionPlannerRequest({ - positionOpens: [ - { - position: this.buildLimitPosition(), - }, - ], - source: this.quoteAsset.accountIndex, - }); - - await planBuildBroadcast('positionOpen', positionsReq); - - this.baseAsset.unsetAmount(); - this.quoteAsset.unsetAmount(); - } finally { - this.isLoading = false; - } - }; - - initiateRangePositionsTx = async (): Promise => { - try { - this.isLoading = true; - - const { target, lowerBound, upperBound, positions, marketPrice, feeTier } = - this.rangeLiquidity; - - if (!target || !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 linearPositions = this.rangeLiquidity.buildPositions(); - const positionsReq = new TransactionPlannerRequest({ - positionOpens: linearPositions.map(position => ({ position })), - 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.Limit) { - void this.initiateLimitPositionTx(); - } - - if (this.type === FormType.RangeLiquidity) { - void this.initiateRangePositionsTx(); - } - }; -} - -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/limit-order.ts b/src/pages/trade/ui/order-form/store/limit-order.ts deleted file mode 100644 index 82d19ff2..00000000 --- a/src/pages/trade/ui/order-form/store/limit-order.ts +++ /dev/null @@ -1,75 +0,0 @@ -import { makeAutoObservable } from 'mobx'; -import { pnum } from '@penumbra-zone/types/pnum'; - -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 LimitOrder { - priceInput?: string | number; - exponent?: number; - marketPrice?: number; - - constructor() { - makeAutoObservable(this); - } - - get price(): number { - if (this.priceInput === undefined || this.priceInput === '') { - return ''; - } - return pnum(this.priceInput, this.exponent).toRoundedNumber(); - } - - setPrice = (price: string) => { - this.priceInput = price; - }; - - setBuyLimitPriceOption = (option: BuyLimitOrderOptions) => { - if (this.marketPrice) { - this.priceInput = this.marketPrice * BuyLimitOrderMultipliers[option]; - } - }; - - setSellLimitPriceOption = (option: SellLimitOrderOptions) => { - if (this.marketPrice) { - this.priceInput = this.marketPrice * SellLimitOrderMultipliers[option]; - } - }; - - setMarketPrice = (price: number) => { - this.marketPrice = price; - }; - - setExponent = (exponent: number) => { - this.exponent = exponent; - }; -} 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 eaa6069f..00000000 --- a/src/pages/trade/ui/order-form/store/range-liquidity.ts +++ /dev/null @@ -1,233 +0,0 @@ -import { makeAutoObservable } from 'mobx'; -import { pnum } from '@penumbra-zone/types/pnum'; -import { Position } from '@penumbra-zone/protobuf/penumbra/core/component/dex/v1/dex_pb'; -import { TransactionPlannerRequest } from '@penumbra-zone/protobuf/penumbra/view/v1/view_pb'; -import { OrderFormAsset } from './asset'; -import { plan } from '../helpers'; -import { rangeLiquidityPositions } from '@/shared/math/position'; - -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 const DEFAULT_POSITIONS = 10; -export const MIN_POSITIONS = 5; -export const MAX_POSITIONS = 15; - -export class RangeLiquidity { - target?: number | string = ''; - upperBoundInput?: string | number; - lowerBoundInput?: string | number; - positionsInput?: string | number; - feeTier?: number; - marketPrice?: number; - baseAsset?: OrderFormAsset; - quoteAsset?: OrderFormAsset; - gasFee?: number; - onFieldChangeCallback?: () => Promise; - - constructor() { - makeAutoObservable(this); - - this.onFieldChangeCallback = this.calculateFeesAndAmounts; - } - - get upperBound(): string | number { - if (this.upperBoundInput === undefined || this.upperBoundInput === '' || !this.quoteAsset) { - return ''; - } - return pnum(this.upperBoundInput, this.quoteAsset.exponent).toRoundedNumber(); - } - - get lowerBound(): string | number { - if (this.lowerBoundInput === undefined || this.lowerBoundInput === '' || !this.quoteAsset) { - return ''; - } - return pnum(this.lowerBoundInput, this.quoteAsset.exponent).toRoundedNumber(); - } - - get positions(): number | undefined { - return this.positionsInput === '' - ? undefined - : Math.max( - MIN_POSITIONS, - Math.min(MAX_POSITIONS, Number(this.positionsInput ?? DEFAULT_POSITIONS)), - ); - } - - // logic from: /penumbra/core/crates/bin/pcli/src/command/tx/replicate/linear.rs - buildPositions = (): Position[] => { - if ( - !this.positions || - !this.target || - !this.baseAsset?.assetId || - !this.quoteAsset?.assetId || - !this.baseAsset.exponent || - !this.quoteAsset.exponent || - !this.marketPrice - ) { - return []; - } - - const positions = rangeLiquidityPositions({ - baseAsset: { - id: this.baseAsset.assetId, - exponent: this.baseAsset.exponent, - }, - quoteAsset: { - id: this.quoteAsset.assetId, - exponent: this.quoteAsset.exponent, - }, - targetLiquidity: Number(this.target), - upperPrice: Number(this.upperBound), - lowerPrice: Number(this.lowerBound), - marketPrice: this.marketPrice, - feeBps: (this.feeTier ?? 0.1) * 100, - positions: this.positions, - }); - - return positions; - }; - - calculateFeesAndAmounts = async (): Promise => { - const positions = this.buildPositions(); - - if (!positions.length) { - return; - } - - const { baseAmount, quoteAmount } = positions.reduce( - (amounts, position: Position) => { - return { - baseAmount: amounts.baseAmount + pnum(position.reserves?.r1).toBigInt(), - quoteAmount: amounts.quoteAmount + pnum(position.reserves?.r2).toBigInt(), - }; - }, - { baseAmount: 0n, quoteAmount: 0n }, - ); - - this.baseAsset?.setAmount(Number(baseAmount)); - this.quoteAsset?.setAmount(Number(quoteAmount)); - - const positionsReq = new TransactionPlannerRequest({ - positionOpens: positions.map(position => ({ position })), - source: this.quoteAsset?.accountIndex, - }); - - const txPlan = await plan(positionsReq); - const fee = txPlan.transactionParameters?.fee; - - this.gasFee = pnum(fee?.amount, this.baseAsset?.exponent).toRoundedNumber(); - }; - - setTarget = (target: string | number): void => { - this.target = target; - if (this.onFieldChangeCallback) { - void this.onFieldChangeCallback(); - } - }; - - setUpperBound = (amount: string) => { - this.upperBoundInput = amount; - if (this.onFieldChangeCallback) { - void this.onFieldChangeCallback(); - } - }; - - setUpperBoundOption = (option: UpperBoundOptions) => { - if (this.marketPrice) { - this.upperBoundInput = this.marketPrice * UpperBoundMultipliers[option]; - if (this.onFieldChangeCallback) { - void this.onFieldChangeCallback(); - } - } - }; - - setLowerBound = (amount: string) => { - this.lowerBoundInput = amount; - if (this.onFieldChangeCallback) { - void this.onFieldChangeCallback(); - } - }; - - setLowerBoundOption = (option: LowerBoundOptions) => { - if (this.marketPrice) { - this.lowerBoundInput = this.marketPrice * LowerBoundMultipliers[option]; - 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.positionsInput = positions; - if (this.onFieldChangeCallback) { - void this.onFieldChangeCallback(); - } - }; - - setMarketPrice = (price: number) => { - this.marketPrice = price; - }; - - setAssets = (baseAsset: OrderFormAsset, quoteAsset: OrderFormAsset): void => { - this.baseAsset = baseAsset; - this.quoteAsset = quoteAsset; - }; -} From 228196ca7b814cc35f94f7585d10dbfe84021ec8 Mon Sep 17 00:00:00 2001 From: Lucas Meier Date: Thu, 12 Dec 2024 00:20:05 -0800 Subject: [PATCH 34/44] Fix remaining lints --- .../trade/ui/order-form/order-form-limit.tsx | 2 +- .../trade/ui/order-form/order-form-market.tsx | 4 +- .../order-form/order-form-range-liquidity.tsx | 2 +- .../order-form/store/LimitOrderFormStore.ts | 32 +++--- .../order-form/store/MarketOrderFormStore.ts | 99 ++++++++++--------- .../ui/order-form/store/OrderFormStore.ts | 64 ++++++------ .../ui/order-form/store/PriceLinkedInputs.ts | 10 +- .../order-form/store/RangeOrderFormStore.ts | 26 ++--- 8 files changed, 125 insertions(+), 114 deletions(-) diff --git a/src/pages/trade/ui/order-form/order-form-limit.tsx b/src/pages/trade/ui/order-form/order-form-limit.tsx index 13b6854a..e734f592 100644 --- a/src/pages/trade/ui/order-form/order-form-limit.tsx +++ b/src/pages/trade/ui/order-form/order-form-limit.tsx @@ -82,7 +82,7 @@ export const LimitOrderForm = observer(() => { 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 20ff0062..c6e37734 100644 --- a/src/pages/trade/ui/order-form/order-form-market.tsx +++ b/src/pages/trade/ui/order-form/order-form-market.tsx @@ -87,8 +87,8 @@ export const MarketOrderForm = observer(() => { {connected ? ( 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 631b21f8..97c96094 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 @@ -178,7 +178,7 @@ export const RangeLiquidityOrderForm = observer(() => { diff --git a/src/pages/trade/ui/order-form/store/LimitOrderFormStore.ts b/src/pages/trade/ui/order-form/store/LimitOrderFormStore.ts index 8510ce03..07bcf50b 100644 --- a/src/pages/trade/ui/order-form/store/LimitOrderFormStore.ts +++ b/src/pages/trade/ui/order-form/store/LimitOrderFormStore.ts @@ -31,14 +31,30 @@ export class LimitOrderFormStore { return this._input.inputA; } + set baseInput(x: string) { + this._input.inputA = x; + } + get quoteInput(): string { return this._input.inputB; } + set quoteInput(x: string) { + this._input.inputB = x; + } + get priceInput(): string { return this._priceInput; } + set priceInput(x: string) { + this._priceInput = x; + const price = this.price; + if (price !== undefined) { + this._input.price = price; + } + } + get price(): number | undefined { return parseNumber(this._priceInput); } @@ -65,20 +81,4 @@ export class LimitOrderFormStore { this._input.inputB = ''; this._priceInput = ''; } - - set baseInput(x: string) { - this._input.inputA = x; - } - - set quoteInput(x: string) { - this._input.inputB = x; - } - - set priceInput(x: string) { - this._priceInput = x; - const price = this.price; - if (price !== undefined) { - this._input.price = price; - } - } } diff --git a/src/pages/trade/ui/order-form/store/MarketOrderFormStore.ts b/src/pages/trade/ui/order-form/store/MarketOrderFormStore.ts index 02f4f9e4..9c40235e 100644 --- a/src/pages/trade/ui/order-form/store/MarketOrderFormStore.ts +++ b/src/pages/trade/ui/order-form/store/MarketOrderFormStore.ts @@ -76,47 +76,56 @@ export class MarketOrderFormStore { // Two reactions to avoid a double trigger. reaction( () => [this._lastEdited, this._baseAssetInput, this._baseAsset, this._quoteAsset], - debounce(async () => { - if (!this._baseAsset || !this._quoteAsset || this._lastEdited !== 'Base') { - return; - } - const input = this.baseInputAmount; - if (input === undefined) { - return; - } - this._quoteEstimating = true; - try { - const res = await estimateAmount(this._quoteAsset, this._baseAsset, input); - if (res === undefined) { - return; - } - this._quoteAssetInput = res.toString(); - } finally { - this._quoteEstimating = false; - } - }, ESTIMATE_DEBOUNCE_MS), + debounce( + () => + void (async () => { + if (!this._baseAsset || !this._quoteAsset || this._lastEdited !== 'Base') { + return; + } + const input = this.baseInputAmount; + if (input === undefined) { + return; + } + this._quoteEstimating = true; + try { + const res = await estimateAmount(this._quoteAsset, this._baseAsset, input); + if (res === undefined) { + return; + } + this._quoteAssetInput = res.toString(); + } finally { + this._quoteEstimating = false; + } + })(), + ESTIMATE_DEBOUNCE_MS, + ), ); reaction( () => [this._lastEdited, this._quoteAssetInput, this._baseAsset, this._quoteAsset], - debounce(async () => { - if (!this._baseAsset || !this._quoteAsset || this._lastEdited !== 'Quote') { - return; - } - const input = this.quoteInputAmount; - if (input === undefined) { - return; - } - this._baseEstimating = true; - try { - const res = await estimateAmount(this._baseAsset, this._quoteAsset, input); - if (res === undefined) { - return; - } - this._baseAssetInput = res.toString(); - } finally { - this._baseEstimating = false; - } - }, ESTIMATE_DEBOUNCE_MS), + // linter pleasing + debounce( + () => + void (async () => { + if (!this._baseAsset || !this._quoteAsset || this._lastEdited !== 'Quote') { + return; + } + const input = this.quoteInputAmount; + if (input === undefined) { + return; + } + this._baseEstimating = true; + try { + const res = await estimateAmount(this._baseAsset, this._quoteAsset, input); + if (res === undefined) { + return; + } + this._baseAssetInput = res.toString(); + } finally { + this._baseEstimating = false; + } + })(), + ESTIMATE_DEBOUNCE_MS, + ), ); } @@ -124,15 +133,15 @@ export class MarketOrderFormStore { return this._baseAssetInput; } - get quoteInput(): string { - return this._quoteAssetInput; - } - set baseInput(x: string) { this._lastEdited = 'Base'; this._baseAssetInput = x; } + get quoteInput(): string { + return this._quoteAssetInput; + } + set quoteInput(x: string) { this._lastEdited = 'Quote'; this._quoteAssetInput = x; @@ -168,12 +177,12 @@ export class MarketOrderFormStore { } setBalanceFraction(x: number) { - x = Math.max(0.0, Math.min(1.0, x)); + const clamped = Math.max(0.0, Math.min(1.0, x)); if (this.buySell === 'buy' && this._quoteAsset?.balance) { - this.quoteInput = (x * this._quoteAsset.balance).toString(); + this.quoteInput = (clamped * this._quoteAsset.balance).toString(); } if (this.buySell === 'sell' && this._baseAsset?.balance) { - this.baseInput = (x * this._baseAsset.balance).toString(); + this.baseInput = (clamped * this._baseAsset.balance).toString(); } } diff --git a/src/pages/trade/ui/order-form/store/OrderFormStore.ts b/src/pages/trade/ui/order-form/store/OrderFormStore.ts index 6e6a97cc..b164b22c 100644 --- a/src/pages/trade/ui/order-form/store/OrderFormStore.ts +++ b/src/pages/trade/ui/order-form/store/OrderFormStore.ts @@ -47,25 +47,30 @@ export class OrderFormStore { reaction( () => this.plan, - debounce(async () => { - if (!this.plan || !this._umAsset) { - return; - } - this._gasFeeLoading = true; - try { - const res = await plan(this.plan); - const fee = res.transactionParameters?.fee; - if (!fee) { - return; - } - this._gasFee = { - symbol: this._umAsset.symbol, - display: pnum(fee.amount, this._umAsset.exponent).toNumber().toString(), - }; - } finally { - this._gasFeeLoading = false; - } - }, GAS_DEBOUNCE_MS), + debounce( + // To please the linter + () => + void (async () => { + if (!this.plan || !this._umAsset) { + return; + } + this._gasFeeLoading = true; + try { + const res = await plan(this.plan); + const fee = res.transactionParameters?.fee; + if (!fee) { + return; + } + this._gasFee = { + symbol: this._umAsset.symbol, + display: pnum(fee.amount, this._umAsset.exponent).toNumber().toString(), + }; + } finally { + this._gasFeeLoading = false; + } + })(), + GAS_DEBOUNCE_MS, + ), ); } @@ -161,17 +166,14 @@ export class OrderFormStore { source: this.subAccountIndex, }); } - if (this._whichForm === 'Range') { - const plan = this._range.plan; - if (plan === undefined) { - return undefined; - } - return new TransactionPlannerRequest({ - positionOpens: plan.map(x => ({ position: x })), - source: this.subAccountIndex, - }); + const plan = this._range.plan; + if (plan === undefined) { + return undefined; } - return undefined; + return new TransactionPlannerRequest({ + positionOpens: plan.map(x => ({ position: x })), + source: this.subAccountIndex, + }); } get canSubmit(): boolean { @@ -261,7 +263,7 @@ export const useOrderFormStore = () => { orderFormStore.assetChange(baseAssetInfo, quoteAssetInfo); orderFormStore.subAccountIndex = addressIndex; } - }, [orderFormStore, baseAsset, quoteAsset, balances, address, addressIndex]); + }, [baseAsset, quoteAsset, balances, address, addressIndex]); const marketPrice = useMarketPrice(); @@ -270,7 +272,7 @@ export const useOrderFormStore = () => { return; } orderFormStore.marketPrice = marketPrice; - }, [orderFormStore, marketPrice]); + }, [marketPrice]); useEffect(() => { if (umAsset) { diff --git a/src/pages/trade/ui/order-form/store/PriceLinkedInputs.ts b/src/pages/trade/ui/order-form/store/PriceLinkedInputs.ts index 4d479ecc..121617bc 100644 --- a/src/pages/trade/ui/order-form/store/PriceLinkedInputs.ts +++ b/src/pages/trade/ui/order-form/store/PriceLinkedInputs.ts @@ -48,16 +48,16 @@ export class PriceLinkedInputs { return this._inputA; } - get inputB(): string { - return this._inputB; - } - 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; @@ -68,7 +68,7 @@ export class PriceLinkedInputs { this._price = x; if (this._lastEdited === 'A') { this.computeBFromA(); - } else if (this._lastEdited === 'B') { + } 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 index 00792b1a..9ca5ab71 100644 --- a/src/pages/trade/ui/order-form/store/RangeOrderFormStore.ts +++ b/src/pages/trade/ui/order-form/store/RangeOrderFormStore.ts @@ -67,10 +67,23 @@ export class RangeOrderFormStore { return this._positionCountInput; } + set positionCountInput(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; } + set positionCountSlider(x: number) { + this._positionCountSlider = x; + this._positionCountInput = x.toString(); + } + get positionCount(): undefined | number { return parseNumber(this._positionCountInput); } @@ -126,17 +139,4 @@ export class RangeOrderFormStore { this._positionCountInput = '5'; this._positionCountSlider = 5; } - - set positionCountInput(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)); - } - } - - set positionCountSlider(x: number) { - this._positionCountSlider = x; - this._positionCountInput = x.toString(); - } } From 238d24a0a3843cca0f3e869767b0fb3beb6736a4 Mon Sep 17 00:00:00 2001 From: Lucas Meier Date: Thu, 12 Dec 2024 08:36:12 -0800 Subject: [PATCH 35/44] Correct order of p and q in position construction --- src/shared/math/position.test.ts | 2 +- src/shared/math/position.ts | 5 +++-- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/src/shared/math/position.test.ts b/src/shared/math/position.test.ts index a77a0dc5..5cf68afc 100644 --- a/src/shared/math/position.test.ts +++ b/src/shared/math/position.test.ts @@ -8,7 +8,7 @@ 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?.q).toNumber() / pnum(position.phi?.component?.p).toNumber(); + return pnum(position.phi?.component?.p).toNumber() / pnum(position.phi?.component?.q).toNumber(); }; describe('planToPosition', () => { diff --git a/src/shared/math/position.ts b/src/shared/math/position.ts index 3fa6fb82..4dc73b29 100644 --- a/src/shared/math/position.ts +++ b/src/shared/math/position.ts @@ -73,8 +73,9 @@ const priceToPQ = ( // = 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 - const [q, p] = basePrice.toFraction(10 ** PRECISION_DECIMALS); + // USD / UM -> [USD, UM], with a given precision. + // Then, we want the invariant that p * UM + q * USD = constant, so + const [p, q] = basePrice.toFraction(10 ** PRECISION_DECIMALS); return { p: pnum(BigInt(p.toFixed(0))).toAmount(), q: pnum(BigInt(q.toFixed(0))).toAmount() }; }; From b29b48eda4fa34cbda01c7cee27072ef917fa629 Mon Sep 17 00:00:00 2001 From: Lucas Meier Date: Thu, 12 Dec 2024 09:11:52 -0800 Subject: [PATCH 36/44] 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 --- src/shared/math/position.ts | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/src/shared/math/position.ts b/src/shared/math/position.ts index 4dc73b29..4bf313d3 100644 --- a/src/shared/math/position.ts +++ b/src/shared/math/position.ts @@ -9,12 +9,6 @@ import { Amount } from '@penumbra-zone/protobuf/penumbra/core/num/v1/num_pb'; import { pnum } from '@penumbra-zone/types/pnum'; import BigNumber from 'bignumber.js'; -// This should be set so that we can still represent prices as numbers even multiplied by 10 ** this. -// -// For example, if this is set to 6, we should be able to represent PRICE * 10**6 as a number. -// In the year 202X, when 1 BTC = 1 million USD, then this is still only 1e12 < 2^50. -const PRECISION_DECIMALS = 12; - export const compareAssetId = (a: AssetId, b: AssetId): number => { for (let i = 31; i >= 0; --i) { const a_i = a.inner[i] ?? -Infinity; @@ -75,7 +69,15 @@ const priceToPQ = ( // USD / UM -> [USD, UM], with a given precision. // Then, we want the invariant that p * UM + q * USD = constant, so - const [p, q] = basePrice.toFraction(10 ** PRECISION_DECIMALS); + 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() }; }; From 86d6027aa33bd65ee680b3989f44505dee3f0a71 Mon Sep 17 00:00:00 2001 From: "Jason M. Hasperhoven" Date: Fri, 13 Dec 2024 18:07:26 +0400 Subject: [PATCH 37/44] Remove wheel input changes --- src/pages/trade/ui/order-form/order-input.tsx | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/pages/trade/ui/order-form/order-input.tsx b/src/pages/trade/ui/order-form/order-input.tsx index 0c6ffa74..3b50dc2d 100644 --- a/src/pages/trade/ui/order-form/order-input.tsx +++ b/src/pages/trade/ui/order-form/order-input.tsx @@ -90,6 +90,10 @@ export const OrderInput = forwardRef( style={{ paddingRight: denomWidth + 20 }} 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} From cfc32563764628f100475e4ea27040fbf315238a Mon Sep 17 00:00:00 2001 From: "Jason M. Hasperhoven" Date: Fri, 13 Dec 2024 18:20:44 +0400 Subject: [PATCH 38/44] Fix market slider input change --- .../trade/ui/order-form/order-form-market.tsx | 70 +++++++++++-------- .../order-form/store/MarketOrderFormStore.ts | 7 ++ 2 files changed, 48 insertions(+), 29 deletions(-) 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 c6e37734..753b6171 100644 --- a/src/pages/trade/ui/order-form/order-form-market.tsx +++ b/src/pages/trade/ui/order-form/order-form-market.tsx @@ -11,38 +11,45 @@ import { useOrderFormStore } from './store/OrderFormStore'; import { Slider as PenumbraSlider } from '@penumbra-zone/ui/Slider'; interface SliderProps { - balance?: string; + inputValue: string; + balance?: number; + balanceDisplay?: string; setBalanceFraction: (fraction: number) => void; } -const Slider = observer(({ balance, setBalanceFraction }: SliderProps) => { - return ( -
-
- setBalanceFraction(x / 10)} - showTrackGaps={true} - trackGapBackground='base.black' - showFill={true} - /> -
-
- - Available Balance - - + +
-
- ); -}); + ); + }, +); export const MarketOrderForm = observer(() => { const { connected } = connectionStore; @@ -74,7 +81,12 @@ export const MarketOrderForm = observer(() => { denominator={store.quoteAsset?.symbol} />
- store.setBalanceFraction(x)} /> + store.setBalanceFraction(x)} + />
Date: Fri, 13 Dec 2024 20:29:43 +0400 Subject: [PATCH 39/44] Refactor setter methods for mobx strict mode, fix rerenders --- src/pages/trade/ui/form-tabs.tsx | 2 +- .../trade/ui/order-form/order-form-limit.tsx | 10 +- .../trade/ui/order-form/order-form-market.tsx | 6 +- .../order-form/order-form-range-liquidity.tsx | 12 +- .../order-form/store/LimitOrderFormStore.ts | 16 ++- .../order-form/store/MarketOrderFormStore.ts | 16 ++- .../ui/order-form/store/OrderFormStore.ts | 117 +++++++++++------- .../order-form/store/RangeOrderFormStore.ts | 24 +++- src/pages/trade/ui/page.tsx | 42 +++++-- 9 files changed, 161 insertions(+), 84 deletions(-) diff --git a/src/pages/trade/ui/form-tabs.tsx b/src/pages/trade/ui/form-tabs.tsx index 0e179c9a..3a80c6e7 100644 --- a/src/pages/trade/ui/form-tabs.tsx +++ b/src/pages/trade/ui/form-tabs.tsx @@ -20,7 +20,7 @@ export const FormTabs = observer(() => { actionType='accent' onChange={value => { if (isWhichForm(value)) { - store.whichForm = value; + store.setWhichForm(value); } }} options={[ diff --git a/src/pages/trade/ui/order-form/order-form-limit.tsx b/src/pages/trade/ui/order-form/order-form-limit.tsx index e734f592..a6d4b812 100644 --- a/src/pages/trade/ui/order-form/order-form-limit.tsx +++ b/src/pages/trade/ui/order-form/order-form-limit.tsx @@ -36,20 +36,20 @@ export const LimitOrderForm = observer(() => { return (
- (store.buySell = x)} /> +
(store.priceInput = x)} + onChange={store.setPriceInput} denominator={store.quoteAsset?.symbol} />
- (store.priceInput = (priceOptions[o] ?? (x => x))(store.marketPrice).toString()) + store.setPriceInput((priceOptions[o] ?? (x => x))(store.marketPrice).toString()) } />
@@ -57,7 +57,7 @@ export const LimitOrderForm = observer(() => { (store.baseInput = x)} + onChange={store.setBaseInput} denominator={store.baseAsset?.symbol} />
@@ -65,7 +65,7 @@ export const LimitOrderForm = observer(() => { (store.quoteInput = x)} + onChange={store.setQuoteInput} denominator={store.quoteAsset?.symbol} />
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 753b6171..61134f3a 100644 --- a/src/pages/trade/ui/order-form/order-form-market.tsx +++ b/src/pages/trade/ui/order-form/order-form-market.tsx @@ -60,12 +60,12 @@ export const MarketOrderForm = observer(() => { return (
- (store.buySell = x)} /> +
(store.baseInput = x)} + onChange={store.setBaseInput} isEstimating={store.baseEstimating} isApproximately={isBuy} denominator={store.baseAsset?.symbol} @@ -75,7 +75,7 @@ export const MarketOrderForm = observer(() => { (store.quoteInput = x)} + onChange={store.setQuoteInput} isEstimating={store.quoteEstimating} isApproximately={!isBuy} denominator={store.quoteAsset?.symbol} 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 97c96094..28b2da7f 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 @@ -46,7 +46,7 @@ export const RangeLiquidityOrderForm = observer(() => { (store.liquidityTargetInput = x)} + onChange={store.setLiquidityTargetInput} denominator={store.quoteAsset?.symbol} />
@@ -84,7 +84,7 @@ export const RangeLiquidityOrderForm = observer(() => { (store.upperPriceInput = x)} + onChange={store.setUpperPriceInput} denominator={store.quoteAsset?.symbol} />
@@ -102,7 +102,7 @@ export const RangeLiquidityOrderForm = observer(() => { (store.lowerPriceInput = x)} + onChange={store.setLowerPriceInput} denominator={store.quoteAsset?.symbol} />
@@ -120,7 +120,7 @@ export const RangeLiquidityOrderForm = observer(() => { (store.feeTierPercentInput = x)} + onChange={store.setFeeTierPercentInput} denominator='%' />
@@ -137,7 +137,7 @@ export const RangeLiquidityOrderForm = observer(() => { (store.positionCountInput = x)} + onChange={store.setPositionCountInput} /> { step={1} value={store.positionCountSlider} showValue={false} - onChange={x => (store.positionCountSlider = x)} + onChange={store.setPositionCountSlider} showTrackGaps={true} trackGapBackground='base.black' showFill={true} diff --git a/src/pages/trade/ui/order-form/store/LimitOrderFormStore.ts b/src/pages/trade/ui/order-form/store/LimitOrderFormStore.ts index 07bcf50b..97cf9bc7 100644 --- a/src/pages/trade/ui/order-form/store/LimitOrderFormStore.ts +++ b/src/pages/trade/ui/order-form/store/LimitOrderFormStore.ts @@ -19,6 +19,10 @@ export class LimitOrderFormStore { makeAutoObservable(this); } + setBuySell = (x: BuySell) => { + this.buySell = x; + }; + get baseAsset(): undefined | AssetInfo { return this._baseAsset; } @@ -31,29 +35,29 @@ export class LimitOrderFormStore { return this._input.inputA; } - set baseInput(x: string) { + setBaseInput = (x: string) => { this._input.inputA = x; - } + }; get quoteInput(): string { return this._input.inputB; } - set quoteInput(x: string) { + setQuoteInput = (x: string) => { this._input.inputB = x; - } + }; get priceInput(): string { return this._priceInput; } - set priceInput(x: string) { + setPriceInput = (x: string) => { this._priceInput = x; const price = this.price; if (price !== undefined) { this._input.price = price; } - } + }; get price(): number | undefined { return parseNumber(this._priceInput); diff --git a/src/pages/trade/ui/order-form/store/MarketOrderFormStore.ts b/src/pages/trade/ui/order-form/store/MarketOrderFormStore.ts index f6ea3ece..b10b7ecf 100644 --- a/src/pages/trade/ui/order-form/store/MarketOrderFormStore.ts +++ b/src/pages/trade/ui/order-form/store/MarketOrderFormStore.ts @@ -129,23 +129,27 @@ export class MarketOrderFormStore { ); } + setBuySell = (x: BuySell) => { + this.buySell = x; + }; + get baseInput(): string { return this._baseAssetInput; } - set baseInput(x: string) { + setBaseInput = (x: string) => { this._lastEdited = 'Base'; this._baseAssetInput = x; - } + }; get quoteInput(): string { return this._quoteAssetInput; } - set quoteInput(x: string) { + setQuoteInput = (x: string) => { this._lastEdited = 'Quote'; this._quoteAssetInput = x; - } + }; get baseInputAmount(): undefined | number { return parseNumber(this._baseAssetInput); @@ -186,10 +190,10 @@ export class MarketOrderFormStore { setBalanceFraction(x: number) { const clamped = Math.max(0.0, Math.min(1.0, x)); if (this.buySell === 'buy' && this._quoteAsset?.balance) { - this.quoteInput = (clamped * this._quoteAsset.balance).toString(); + this.setQuoteInput((clamped * this._quoteAsset.balance).toString()); } if (this.buySell === 'sell' && this._baseAsset?.balance) { - this.baseInput = (clamped * this._baseAsset.balance).toString(); + this.setBaseInput((clamped * this._baseAsset.balance).toString()); } } diff --git a/src/pages/trade/ui/order-form/store/OrderFormStore.ts b/src/pages/trade/ui/order-form/store/OrderFormStore.ts index b164b22c..f5dd73c9 100644 --- a/src/pages/trade/ui/order-form/store/OrderFormStore.ts +++ b/src/pages/trade/ui/order-form/store/OrderFormStore.ts @@ -1,3 +1,4 @@ +import { useRef, useEffect } from 'react'; import { makeAutoObservable, reaction } from 'mobx'; import { LimitOrderFormStore } from './LimitOrderFormStore'; import { MarketOrderFormStore } from './MarketOrderFormStore'; @@ -7,19 +8,22 @@ import { BalancesResponse, TransactionPlannerRequest, } from '@penumbra-zone/protobuf/penumbra/view/v1/view_pb'; -import { Address, AddressIndex } from '@penumbra-zone/protobuf/penumbra/core/keys/v1/keys_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 { useEffect } from 'react'; import { useMarketPrice } from '@/pages/trade/model/useMarketPrice'; -import { plan, planBuildBroadcast } from '../helpers'; 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'; export type WhichForm = 'Market' | 'Limit' | 'Range'; @@ -94,8 +98,20 @@ export class OrderFormStore { } } - set umAsset(x: AssetInfo) { + 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 } { @@ -112,7 +128,7 @@ export class OrderFormStore { this._range.assetChange(base, quote); } - set marketPrice(price: number) { + setMarketPrice(price: number) { this._marketPrice = price; this._range.marketPrice = price; this._limit.marketPrice = price; @@ -122,7 +138,7 @@ export class OrderFormStore { return this._marketPrice; } - set whichForm(x: WhichForm) { + setWhichForm(x: WhichForm) { this._whichForm = x; } @@ -221,19 +237,7 @@ const pluckAssetBalance = (symbol: string, balances: BalancesResponse[]): undefi return undefined; }; -const orderFormStore = new OrderFormStore(); - -export const useOrderFormStore = () => { - const { data: assets } = useRegistryAssets(); - let umAsset: AssetInfo | undefined; - if (assets) { - const meta = assets.find(x => x.symbol === 'UM'); - if (meta) { - umAsset = AssetInfo.fromMetadata(meta); - } - } - const { baseAsset, quoteAsset } = usePathToMetadata(); - const { data: subAccounts } = useSubaccounts(); +function getAccountAddress(subAccounts: AddressView[] | undefined) { const subAccount = subAccounts ? subAccounts[connectionStore.subaccount] : undefined; let addressIndex = undefined; let address = undefined; @@ -242,42 +246,67 @@ export const useOrderFormStore = () => { address = addressView.value.address; addressIndex = addressView.value.index; } + return { + address, + addressIndex, + }; +} + +export const useOrderFormStore = () => { + const orderFormStoreRef = useRef(new OrderFormStore()); + const orderFormStore = orderFormStoreRef.current; + + 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(() => { - orderFormStore.subAccountIndex = addressIndex; - orderFormStore.address = address; if ( - !baseAsset?.symbol || - !baseAsset.penumbraAssetId || - !quoteAsset?.symbol || - !quoteAsset.penumbraAssetId + baseAsset?.symbol && + baseAsset.penumbraAssetId && + quoteAsset?.symbol && + quoteAsset.penumbraAssetId ) { - return; - } - 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); - if (baseAssetInfo && quoteAssetInfo) { - orderFormStore.assetChange(baseAssetInfo, quoteAssetInfo); - orderFormStore.subAccountIndex = addressIndex; + 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); + if (baseAssetInfo && quoteAssetInfo) { + orderFormStore.assetChange(baseAssetInfo, quoteAssetInfo); + } } - }, [baseAsset, quoteAsset, balances, address, addressIndex]); + }, [baseAsset, quoteAsset, balances, orderFormStore]); - const marketPrice = useMarketPrice(); + useEffect(() => { + if (address && addressIndex) { + orderFormStore.setSubAccountIndex(addressIndex); + orderFormStore.setAddress(address); + } + }, [address, addressIndex, orderFormStore]); useEffect(() => { - if (!marketPrice) { - return; + if (marketPrice) { + orderFormStore.setMarketPrice(marketPrice); } - orderFormStore.marketPrice = marketPrice; - }, [marketPrice]); + }, [marketPrice, orderFormStore]); useEffect(() => { - if (umAsset) { - orderFormStore.umAsset = umAsset; + 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); } - }, [umAsset]); + }, [assets, orderFormStore]); + return orderFormStore; }; diff --git a/src/pages/trade/ui/order-form/store/RangeOrderFormStore.ts b/src/pages/trade/ui/order-form/store/RangeOrderFormStore.ts index 9ca5ab71..bf8975dc 100644 --- a/src/pages/trade/ui/order-form/store/RangeOrderFormStore.ts +++ b/src/pages/trade/ui/order-form/store/RangeOrderFormStore.ts @@ -50,39 +50,55 @@ export class RangeOrderFormStore { return parseNumber(this.liquidityTargetInput); } + setLiquidityTargetInput = (x: string) => { + this.liquidityTargetInput = x; + }; + get upperPrice(): number | undefined { return parseNumber(this.upperPriceInput); } + setUpperPriceInput = (x: string) => { + this.upperPriceInput = x; + }; + get lowerPrice(): number | undefined { return parseNumber(this.lowerPriceInput); } + setLowerPriceInput = (x: string) => { + this.lowerPriceInput = x; + }; + // 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) => { + this.feeTierPercentInput = x; + }; + get positionCountInput(): string { return this._positionCountInput; } - set positionCountInput(x: string) { + 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; } - set positionCountSlider(x: number) { + setPositionCountSlider = (x: number) => { this._positionCountSlider = x; this._positionCountInput = x.toString(); - } + }; get positionCount(): undefined | number { return parseNumber(this._positionCountInput); 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 ; }; From c19a32240f9c36967d506dfd9aa7e09cf4b60ac5 Mon Sep 17 00:00:00 2001 From: "Jason M. Hasperhoven" Date: Fri, 13 Dec 2024 21:02:17 +0400 Subject: [PATCH 40/44] Fix hydration issue caused by connectionStore.connected --- app/app.tsx | 13 +++++++++++-- src/shared/model/connection/index.ts | 4 ---- 2 files changed, 11 insertions(+), 6 deletions(-) diff --git a/app/app.tsx b/app/app.tsx index 4dcf1834..9de86aa2 100644 --- a/app/app.tsx +++ b/app/app.tsx @@ -1,23 +1,32 @@ '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'); +const SetupConnection = observer(() => { + useEffect(() => { + connectionStore.setup(); + }, []); + return null; +}); + export const App = ({ children }: { children: ReactNode }) => { return (
+
{children}
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) { From be9c4966504aecd35764cde4502489bcefae534b Mon Sep 17 00:00:00 2001 From: "Jason M. Hasperhoven" Date: Mon, 16 Dec 2024 18:05:45 +0400 Subject: [PATCH 41/44] Fix inputs disappearing --- src/pages/trade/ui/form-tabs.tsx | 6 +- .../trade/ui/order-form/order-form-limit.tsx | 5 +- .../trade/ui/order-form/order-form-market.tsx | 5 +- .../order-form/order-form-range-liquidity.tsx | 303 +++++++++--------- .../order-form/store/LimitOrderFormStore.ts | 41 ++- .../order-form/store/MarketOrderFormStore.ts | 115 ++++--- .../ui/order-form/store/OrderFormStore.ts | 127 +++++--- .../order-form/store/RangeOrderFormStore.ts | 20 +- 8 files changed, 350 insertions(+), 272 deletions(-) diff --git a/src/pages/trade/ui/form-tabs.tsx b/src/pages/trade/ui/form-tabs.tsx index 3a80c6e7..104993ca 100644 --- a/src/pages/trade/ui/form-tabs.tsx +++ b/src/pages/trade/ui/form-tabs.tsx @@ -32,9 +32,9 @@ export const FormTabs = observer(() => {
- {store.whichForm === 'Market' && } - {store.whichForm === 'Limit' && } - {store.whichForm === 'Range' && } + {store.whichForm === 'Market' && } + {store.whichForm === 'Limit' && } + {store.whichForm === 'Range' && }
); diff --git a/src/pages/trade/ui/order-form/order-form-limit.tsx b/src/pages/trade/ui/order-form/order-form-limit.tsx index a6d4b812..7a62e9d1 100644 --- a/src/pages/trade/ui/order-form/order-form-limit.tsx +++ b/src/pages/trade/ui/order-form/order-form-limit.tsx @@ -8,7 +8,7 @@ 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 { useOrderFormStore } from './store/OrderFormStore'; +import { OrderFormStore } from './store/OrderFormStore'; const BUY_PRICE_OPTIONS: Record number> = { Market: (mp: number) => mp, @@ -26,9 +26,8 @@ const SELL_PRICE_OPTIONS: Record number> = { '+15%': mp => 1.15 * mp, }; -export const LimitOrderForm = observer(() => { +export const LimitOrderForm = observer(({ parentStore }: { parentStore: OrderFormStore }) => { const { connected } = connectionStore; - const parentStore = useOrderFormStore(); const store = parentStore.limitForm; const isBuy = store.buySell === 'buy'; 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 61134f3a..e1d6e131 100644 --- a/src/pages/trade/ui/order-form/order-form-market.tsx +++ b/src/pages/trade/ui/order-form/order-form-market.tsx @@ -7,7 +7,7 @@ import { SegmentedControl } from './segmented-control'; import { ConnectButton } from '@/features/connect/connect-button'; import { InfoRowGasFee } from './info-row-gas-fee'; import { InfoRowTradingFee } from './info-row-trading-fee'; -import { useOrderFormStore } from './store/OrderFormStore'; +import { OrderFormStore } from './store/OrderFormStore'; import { Slider as PenumbraSlider } from '@penumbra-zone/ui/Slider'; interface SliderProps { @@ -51,9 +51,8 @@ const Slider = observer( }, ); -export const MarketOrderForm = observer(() => { +export const MarketOrderForm = observer(({ parentStore }: { parentStore: OrderFormStore }) => { const { connected } = connectionStore; - const parentStore = useOrderFormStore(); const store = parentStore.marketForm; const isBuy = store.buySell === 'buy'; 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 28b2da7f..270ee2e4 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 @@ -8,7 +8,7 @@ import { OrderInput } from './order-input'; import { SelectGroup } from './select-group'; import { InfoRow } from './info-row'; import { InfoRowGasFee } from './info-row-gas-fee'; -import { useOrderFormStore } from './store/OrderFormStore'; +import { OrderFormStore } from './store/OrderFormStore'; import { MAX_POSITION_COUNT, MIN_POSITION_COUNT } from './store/RangeOrderFormStore'; const LOWER_PRICE_OPTIONS: Record number> = { @@ -34,168 +34,169 @@ const FEE_TIERS: Record = { '1.00%': 1, }; -export const RangeLiquidityOrderForm = observer(() => { - const { connected } = connectionStore; - const parentStore = useOrderFormStore(); - const store = parentStore.rangeForm; +export const RangeLiquidityOrderForm = observer( + ({ parentStore }: { parentStore: OrderFormStore }) => { + const { connected } = connectionStore; + const store = parentStore.rangeForm; - return ( -
-
-
- -
-
-
- - Available Balances - + return ( +
+
+
+
-
-
- - {store.baseAsset?.formatBalance() ?? `-- ${store.baseAsset?.symbol}`} +
+
+ + Available Balances
- +
+
+ + {store.baseAsset?.formatBalance() ?? `-- ${store.baseAsset?.symbol}`} + +
+ +
-
-
-
- +
+ +
+ + store.setUpperPriceInput( + (UPPER_PRICE_OPTIONS[o] ?? (x => x))(store.marketPrice).toString(), + ) + } />
- - (store.upperPriceInput = (UPPER_PRICE_OPTIONS[o] ?? (x => x))( - store.marketPrice, - ).toString()) - } - /> -
-
-
- +
+ +
+ + store.setLowerPriceInput( + (LOWER_PRICE_OPTIONS[o] ?? (x => x))(store.marketPrice).toString(), + ) + } />
- - (store.lowerPriceInput = (LOWER_PRICE_OPTIONS[o] ?? (x => x))( - store.marketPrice, - ).toString()) - } - /> -
-
-
+
+
+ +
+ { + if (o in FEE_TIERS) { + store.setFeeTierPercentInput((FEE_TIERS[o] ?? 0).toString()); + } + }} + /> +
+
+
- { - if (o in FEE_TIERS) { - store.feeTierPercentInput = (FEE_TIERS[o] ?? 0).toString(); - } - }} - /> -
-
- - -
-
- - - - -
-
- {connected ? ( - - ) : ( - +
+ + + + +
+
+ {connected ? ( + + ) : ( + + )} +
+ {parentStore.marketPrice && ( +
+ + 1 {store.baseAsset?.symbol} ={' '} + + {store.quoteAsset?.formatDisplayAmount(parentStore.marketPrice)} + + +
)}
- {parentStore.marketPrice && ( -
- - 1 {store.baseAsset?.symbol} ={' '} - - {store.quoteAsset?.formatDisplayAmount(parentStore.marketPrice)} - - -
- )} -
- ); -}); + ); + }, +); diff --git a/src/pages/trade/ui/order-form/store/LimitOrderFormStore.ts b/src/pages/trade/ui/order-form/store/LimitOrderFormStore.ts index 97cf9bc7..f9ff8c02 100644 --- a/src/pages/trade/ui/order-form/store/LimitOrderFormStore.ts +++ b/src/pages/trade/ui/order-form/store/LimitOrderFormStore.ts @@ -1,12 +1,28 @@ 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 } from 'mobx'; +import { makeAutoObservable, reaction } from 'mobx'; import { AssetInfo } from '@/pages/trade/model/AssetInfo'; import { parseNumber } from '@/shared/utils/num'; export type BuySell = 'buy' | 'sell'; +export const BUY_PRICE_OPTIONS: Record number> = { + Market: (mp: number) => mp, + '-2%': mp => 0.98 * mp, + '-5%': mp => 0.95 * mp, + '-10%': mp => 0.9 * mp, + '-15%': mp => 0.85 * mp, +}; + +export const SELL_PRICE_OPTIONS: Record number> = { + Market: (mp: number) => mp, + '+2%': mp => 1.02 * mp, + '+5%': mp => 1.05 * mp, + '+10%': mp => 1.1 * mp, + '+15%': mp => 1.15 * mp, +}; + export class LimitOrderFormStore { private _baseAsset?: AssetInfo; private _quoteAsset?: AssetInfo; @@ -17,8 +33,16 @@ export class LimitOrderFormStore { constructor() { makeAutoObservable(this); + + reaction(() => [this.buySell], this._resetInputs); } + private _resetInputs = () => { + this._input.inputA = ''; + this._input.inputB = ''; + this._priceInput = ''; + }; + setBuySell = (x: BuySell) => { this.buySell = x; }; @@ -59,6 +83,11 @@ export class LimitOrderFormStore { } }; + setPriceInputFromOption = (x: string) => { + const price = (BUY_PRICE_OPTIONS[x] ?? (x => x))(this.marketPrice); + this.setPriceInput(price.toString()); + }; + get price(): number | undefined { return parseNumber(this._priceInput); } @@ -78,11 +107,13 @@ export class LimitOrderFormStore { }); } - assetChange(base: AssetInfo, quote: AssetInfo) { + setAssets(base: AssetInfo, quote: AssetInfo, resetInputs = false) { this._baseAsset = base; this._quoteAsset = quote; - this._input.inputA = ''; - this._input.inputB = ''; - this._priceInput = ''; + if (resetInputs) { + this._input.inputA = ''; + this._input.inputB = ''; + this._priceInput = ''; + } } } diff --git a/src/pages/trade/ui/order-form/store/MarketOrderFormStore.ts b/src/pages/trade/ui/order-form/store/MarketOrderFormStore.ts index b10b7ecf..e31a4135 100644 --- a/src/pages/trade/ui/order-form/store/MarketOrderFormStore.ts +++ b/src/pages/trade/ui/order-form/store/MarketOrderFormStore.ts @@ -1,4 +1,4 @@ -import { makeAutoObservable, reaction } from 'mobx'; +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'; @@ -76,59 +76,70 @@ export class MarketOrderFormStore { // Two reactions to avoid a double trigger. reaction( () => [this._lastEdited, this._baseAssetInput, this._baseAsset, this._quoteAsset], - debounce( - () => - void (async () => { - if (!this._baseAsset || !this._quoteAsset || this._lastEdited !== 'Base') { - return; - } - const input = this.baseInputAmount; - if (input === undefined) { - return; - } - this._quoteEstimating = true; - try { - const res = await estimateAmount(this._quoteAsset, this._baseAsset, input); - if (res === undefined) { - return; - } - this._quoteAssetInput = res.toString(); - } finally { - this._quoteEstimating = false; - } - })(), - ESTIMATE_DEBOUNCE_MS, - ), + debounce(() => { + void this.estimateQuote(); + }, ESTIMATE_DEBOUNCE_MS), ); reaction( () => [this._lastEdited, this._quoteAssetInput, this._baseAsset, this._quoteAsset], - // linter pleasing - debounce( - () => - void (async () => { - if (!this._baseAsset || !this._quoteAsset || this._lastEdited !== 'Quote') { - return; - } - const input = this.quoteInputAmount; - if (input === undefined) { - return; - } - this._baseEstimating = true; - try { - const res = await estimateAmount(this._baseAsset, this._quoteAsset, input); - if (res === undefined) { - return; - } - this._baseAssetInput = res.toString(); - } finally { - this._baseEstimating = false; - } - })(), - ESTIMATE_DEBOUNCE_MS, - ), + 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; + }); + } + }; + setBuySell = (x: BuySell) => { this.buySell = x; }; @@ -201,11 +212,13 @@ export class MarketOrderFormStore { return this._lastEdited; } - assetChange(base: AssetInfo, quote: AssetInfo) { + setAssets(base: AssetInfo, quote: AssetInfo, resetInputs = false) { this._baseAsset = base; this._quoteAsset = quote; - this._baseAssetInput = ''; - this._quoteAssetInput = ''; + if (resetInputs) { + this._baseAssetInput = ''; + this._quoteAssetInput = ''; + } } get baseAsset(): undefined | AssetInfo { diff --git a/src/pages/trade/ui/order-form/store/OrderFormStore.ts b/src/pages/trade/ui/order-form/store/OrderFormStore.ts index f5dd73c9..832c67ec 100644 --- a/src/pages/trade/ui/order-form/store/OrderFormStore.ts +++ b/src/pages/trade/ui/order-form/store/OrderFormStore.ts @@ -1,5 +1,5 @@ import { useRef, useEffect } from 'react'; -import { makeAutoObservable, reaction } from 'mobx'; +import { makeAutoObservable, reaction, runInAction } from 'mobx'; import { LimitOrderFormStore } from './LimitOrderFormStore'; import { MarketOrderFormStore } from './MarketOrderFormStore'; import { RangeOrderFormStore } from './RangeOrderFormStore'; @@ -24,6 +24,7 @@ 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'; @@ -51,52 +52,63 @@ export class OrderFormStore { reaction( () => this.plan, - debounce( - // To please the linter - () => - void (async () => { - if (!this.plan || !this._umAsset) { - return; - } - this._gasFeeLoading = true; - try { - const res = await plan(this.plan); - const fee = res.transactionParameters?.fee; - if (!fee) { - return; - } - this._gasFee = { - symbol: this._umAsset.symbol, - display: pnum(fee.amount, this._umAsset.exponent).toNumber().toString(), - }; - } finally { - this._gasFeeLoading = false; - } - })(), - GAS_DEBOUNCE_MS, - ), + debounce(() => void this.estimateGasFee(), GAS_DEBOUNCE_MS), ); } - async calculateGasFee() { + private estimateGasFee = async (): Promise => { if (!this.plan || !this._umAsset) { return; } - this._gasFeeLoading = true; + runInAction(() => { + this._gasFeeLoading = true; + }); try { const res = await plan(this.plan); const fee = res.transactionParameters?.fee; if (!fee) { return; } - this._gasFee = { - symbol: this._umAsset.symbol, - display: pnum(fee.amount, this._umAsset.exponent).toNumber().toString(), - }; + 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 { - this._gasFeeLoading = false; + runInAction(() => { + this._gasFeeLoading = false; + }); } - } + }; setUmAsset = (x: AssetInfo) => { this._umAsset = x; @@ -122,10 +134,10 @@ export class OrderFormStore { return this._gasFeeLoading; } - assetChange(base: AssetInfo, quote: AssetInfo) { - this._market.assetChange(base, quote); - this._limit.assetChange(base, quote); - this._range.assetChange(base, quote); + 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) { @@ -204,7 +216,10 @@ export class OrderFormStore { if (!plan || !source) { return; } - this._submitting = true; + + runInAction(() => { + this._submitting = true; + }); try { const tx = await planBuildBroadcast(wasSwap ? 'swap' : 'positionOpen', plan); if (!wasSwap || !tx) { @@ -217,7 +232,9 @@ export class OrderFormStore { }); await planBuildBroadcast('swapClaim', req, { skipAuth: true }); } finally { - this._submitting = false; + runInAction(() => { + this._submitting = false; + }); } } } @@ -252,10 +269,9 @@ function getAccountAddress(subAccounts: AddressView[] | undefined) { }; } -export const useOrderFormStore = () => { - const orderFormStoreRef = useRef(new OrderFormStore()); - const orderFormStore = orderFormStoreRef.current; +const orderFormStore = new OrderFormStore(); +export const useOrderFormStore = () => { const { data: assets } = useRegistryAssets(); const { data: subAccounts } = useSubaccounts(); const { address, addressIndex } = getAccountAddress(subAccounts); @@ -275,24 +291,41 @@ export const useOrderFormStore = () => { 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.assetChange(baseAssetInfo, quoteAssetInfo); + orderFormStore.setAssets(baseAssetInfo, quoteAssetInfo, isChangingAssetPair); } } - }, [baseAsset, quoteAsset, balances, orderFormStore]); + }, [baseAsset, quoteAsset, balances]); useEffect(() => { if (address && addressIndex) { orderFormStore.setSubAccountIndex(addressIndex); orderFormStore.setAddress(address); } - }, [address, addressIndex, orderFormStore]); + }, [address, addressIndex]); useEffect(() => { if (marketPrice) { orderFormStore.setMarketPrice(marketPrice); } - }, [marketPrice, orderFormStore]); + }, [marketPrice]); useEffect(() => { let umAsset: AssetInfo | undefined; @@ -306,7 +339,7 @@ export const useOrderFormStore = () => { if (umAsset && orderFormStore.umAsset?.symbol !== umAsset.symbol) { orderFormStore.setUmAsset(umAsset); } - }, [assets, orderFormStore]); + }, [assets]); return orderFormStore; }; diff --git a/src/pages/trade/ui/order-form/store/RangeOrderFormStore.ts b/src/pages/trade/ui/order-form/store/RangeOrderFormStore.ts index bf8975dc..3ec0d699 100644 --- a/src/pages/trade/ui/order-form/store/RangeOrderFormStore.ts +++ b/src/pages/trade/ui/order-form/store/RangeOrderFormStore.ts @@ -30,8 +30,8 @@ export class RangeOrderFormStore { upperPriceInput = ''; lowerPriceInput = ''; feeTierPercentInput = ''; - private _positionCountInput = '5'; - private _positionCountSlider = 5; + private _positionCountInput = '10'; + private _positionCountSlider = 10; marketPrice = 1; constructor() { @@ -145,14 +145,16 @@ export class RangeOrderFormStore { return quoteAsset.formatDisplayAmount(extractAmount(plan, quoteAsset)); } - assetChange(base: AssetInfo, quote: AssetInfo) { + setAssets(base: AssetInfo, quote: AssetInfo, resetInputs = false) { this._baseAsset = base; this._quoteAsset = quote; - this.liquidityTargetInput = ''; - this.upperPriceInput = ''; - this.lowerPriceInput = ''; - this.feeTierPercentInput = ''; - this._positionCountInput = '5'; - this._positionCountSlider = 5; + if (resetInputs) { + this.liquidityTargetInput = ''; + this.upperPriceInput = ''; + this.lowerPriceInput = ''; + this.feeTierPercentInput = ''; + this._positionCountInput = '10'; + this._positionCountSlider = 10; + } } } From a95d19c7d7cf6fd9cf41f5ae06ba513102a0a7cc Mon Sep 17 00:00:00 2001 From: "Jason M. Hasperhoven" Date: Mon, 16 Dec 2024 18:26:18 +0400 Subject: [PATCH 42/44] Add active states to select groups --- .../trade/ui/order-form/order-form-limit.tsx | 31 ++----- .../trade/ui/order-form/order-form-market.tsx | 4 +- .../order-form/order-form-range-liquidity.tsx | 64 ++++---------- .../order-form/store/LimitOrderFormStore.ts | 83 ++++++++++++----- .../order-form/store/MarketOrderFormStore.ts | 16 ++-- .../ui/order-form/store/OrderFormStore.ts | 2 +- .../order-form/store/RangeOrderFormStore.ts | 88 ++++++++++++++++++- 7 files changed, 182 insertions(+), 106 deletions(-) diff --git a/src/pages/trade/ui/order-form/order-form-limit.tsx b/src/pages/trade/ui/order-form/order-form-limit.tsx index 7a62e9d1..f518486c 100644 --- a/src/pages/trade/ui/order-form/order-form-limit.tsx +++ b/src/pages/trade/ui/order-form/order-form-limit.tsx @@ -9,46 +9,31 @@ import { InfoRowTradingFee } from './info-row-trading-fee'; import { InfoRowGasFee } from './info-row-gas-fee'; import { SelectGroup } from './select-group'; import { OrderFormStore } from './store/OrderFormStore'; - -const BUY_PRICE_OPTIONS: Record number> = { - Market: (mp: number) => mp, - '-2%': mp => 0.98 * mp, - '-5%': mp => 0.95 * mp, - '-10%': mp => 0.9 * mp, - '-15%': mp => 0.85 * mp, -}; - -const SELL_PRICE_OPTIONS: Record number> = { - Market: (mp: number) => mp, - '+2%': mp => 1.02 * mp, - '+5%': mp => 1.05 * mp, - '+10%': mp => 1.1 * mp, - '+15%': mp => 1.15 * mp, -}; +import { BuyLimitOrderOptions, SellLimitOrderOptions } from './store/LimitOrderFormStore'; export const LimitOrderForm = observer(({ parentStore }: { parentStore: OrderFormStore }) => { const { connected } = connectionStore; const store = parentStore.limitForm; - const isBuy = store.buySell === 'buy'; - const priceOptions = isBuy ? BUY_PRICE_OPTIONS : SELL_PRICE_OPTIONS; + const isBuy = store.direction === 'buy'; return (
- +
store.setPriceInput(price)} denominator={store.quoteAsset?.symbol} />
- store.setPriceInput((priceOptions[o] ?? (x => x))(store.marketPrice).toString()) + options={Object.values(isBuy ? BuyLimitOrderOptions : SellLimitOrderOptions)} + value={store.priceInputOption} + onChange={option => + store.setPriceInputOption(option as BuyLimitOrderOptions | SellLimitOrderOptions) } />
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 e1d6e131..2536a7e6 100644 --- a/src/pages/trade/ui/order-form/order-form-market.tsx +++ b/src/pages/trade/ui/order-form/order-form-market.tsx @@ -55,11 +55,11 @@ export const MarketOrderForm = observer(({ parentStore }: { parentStore: OrderFo const { connected } = connectionStore; const store = parentStore.marketForm; - const isBuy = store.buySell === 'buy'; + const isBuy = store.direction === 'buy'; return (
- +
number> = { - Market: (mp: number) => mp, - '-2%': mp => 0.98 * mp, - '-5%': mp => 0.95 * mp, - '-10%': mp => 0.9 * mp, - '-15%': mp => 0.85 * mp, -}; - -const UPPER_PRICE_OPTIONS: Record number> = { - Market: (mp: number) => mp, - '+2%': mp => 1.02 * mp, - '+5%': mp => 1.05 * mp, - '+10%': mp => 1.1 * mp, - '+15%': mp => 1.15 * mp, -}; - -const FEE_TIERS: Record = { - '0.1%': 0.1, - '0.25%': 0.25, - '0.5%': 0.5, - '1.00%': 1, -}; +import { + MAX_POSITION_COUNT, + MIN_POSITION_COUNT, + UpperBoundOptions, + LowerBoundOptions, + FeeTierOptions, +} from './store/RangeOrderFormStore'; export const RangeLiquidityOrderForm = observer( ({ parentStore }: { parentStore: OrderFormStore }) => { @@ -84,17 +67,14 @@ export const RangeLiquidityOrderForm = observer( store.setUpperPriceInput(price)} denominator={store.quoteAsset?.symbol} />
- store.setUpperPriceInput( - (UPPER_PRICE_OPTIONS[o] ?? (x => x))(store.marketPrice).toString(), - ) - } + options={Object.values(UpperBoundOptions)} + value={store.upperPriceInputOption} + onChange={option => store.setUpperPriceInputOption(option as UpperBoundOptions)} />
@@ -102,17 +82,14 @@ export const RangeLiquidityOrderForm = observer( store.setLowerPriceInput(price)} denominator={store.quoteAsset?.symbol} />
- store.setLowerPriceInput( - (LOWER_PRICE_OPTIONS[o] ?? (x => x))(store.marketPrice).toString(), - ) - } + options={Object.values(LowerBoundOptions)} + value={store.lowerPriceInputOption} + onChange={option => store.setLowerPriceInputOption(option as LowerBoundOptions)} />
@@ -120,17 +97,14 @@ export const RangeLiquidityOrderForm = observer( store.setFeeTierPercentInput(amount)} denominator='%' />
{ - if (o in FEE_TIERS) { - store.setFeeTierPercentInput((FEE_TIERS[o] ?? 0).toString()); - } - }} + options={Object.values(FeeTierOptions)} + value={store.feeTierPercentInputOption} + onChange={option => store.setFeeTierPercentInputOption(option as FeeTierOptions)} />
diff --git a/src/pages/trade/ui/order-form/store/LimitOrderFormStore.ts b/src/pages/trade/ui/order-form/store/LimitOrderFormStore.ts index f9ff8c02..bdb79104 100644 --- a/src/pages/trade/ui/order-form/store/LimitOrderFormStore.ts +++ b/src/pages/trade/ui/order-form/store/LimitOrderFormStore.ts @@ -5,36 +5,53 @@ import { makeAutoObservable, reaction } from 'mobx'; import { AssetInfo } from '@/pages/trade/model/AssetInfo'; import { parseNumber } from '@/shared/utils/num'; -export type BuySell = 'buy' | 'sell'; - -export const BUY_PRICE_OPTIONS: Record number> = { - Market: (mp: number) => mp, - '-2%': mp => 0.98 * mp, - '-5%': mp => 0.95 * mp, - '-10%': mp => 0.9 * mp, - '-15%': mp => 0.85 * mp, +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 SELL_PRICE_OPTIONS: Record number> = { - Market: (mp: number) => mp, - '+2%': mp => 1.02 * mp, - '+5%': mp => 1.05 * mp, - '+10%': mp => 1.1 * mp, - '+15%': mp => 1.15 * mp, +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(); - buySell: BuySell = 'buy'; + direction: Direction = 'buy'; marketPrice = 1.0; private _priceInput = ''; + private _priceInputOption: SellLimitOrderOptions | BuyLimitOrderOptions | undefined; constructor() { makeAutoObservable(this); - reaction(() => [this.buySell], this._resetInputs); + reaction(() => [this.direction], this._resetInputs); } private _resetInputs = () => { @@ -43,8 +60,8 @@ export class LimitOrderFormStore { this._priceInput = ''; }; - setBuySell = (x: BuySell) => { - this.buySell = x; + setDirection = (x: Direction) => { + this.direction = x; }; get baseAsset(): undefined | AssetInfo { @@ -75,17 +92,34 @@ export class LimitOrderFormStore { return this._priceInput; } - setPriceInput = (x: string) => { + 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; + } }; - setPriceInputFromOption = (x: string) => { - const price = (BUY_PRICE_OPTIONS[x] ?? (x => x))(this.marketPrice); - this.setPriceInput(price.toString()); + 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 { @@ -94,12 +128,12 @@ export class LimitOrderFormStore { get plan(): Position | undefined { const input = - this.buySell === 'buy' ? parseNumber(this.quoteInput) : parseNumber(this.baseInput); + this.direction === 'buy' ? parseNumber(this.quoteInput) : parseNumber(this.baseInput); if (!input || !this._baseAsset || !this._quoteAsset || !this.price) { return undefined; } return limitOrderPosition({ - buy: this.buySell, + buy: this.direction, price: this.price, input, baseAsset: this._baseAsset, @@ -114,6 +148,7 @@ export class LimitOrderFormStore { 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 index e31a4135..2a862da9 100644 --- a/src/pages/trade/ui/order-form/store/MarketOrderFormStore.ts +++ b/src/pages/trade/ui/order-form/store/MarketOrderFormStore.ts @@ -47,7 +47,7 @@ const estimateAmount = async ( } }; -export type BuySell = 'buy' | 'sell'; +export type Direction = 'buy' | 'sell'; export type LastEdited = 'Base' | 'Quote'; @@ -67,7 +67,7 @@ export class MarketOrderFormStore { private _quoteAssetInput = ''; private _baseEstimating = false; private _quoteEstimating = false; - buySell: BuySell = 'buy'; + direction: Direction = 'buy'; private _lastEdited: LastEdited = 'Base'; constructor() { @@ -140,8 +140,8 @@ export class MarketOrderFormStore { } }; - setBuySell = (x: BuySell) => { - this.buySell = x; + setDirection = (x: Direction) => { + this.direction = x; }; get baseInput(): string { @@ -179,7 +179,7 @@ export class MarketOrderFormStore { } get balance(): undefined | string { - if (this.buySell === 'buy') { + if (this.direction === 'buy') { if (!this._quoteAsset?.balance) { return undefined; } @@ -200,10 +200,10 @@ export class MarketOrderFormStore { setBalanceFraction(x: number) { const clamped = Math.max(0.0, Math.min(1.0, x)); - if (this.buySell === 'buy' && this._quoteAsset?.balance) { + if (this.direction === 'buy' && this._quoteAsset?.balance) { this.setQuoteInput((clamped * this._quoteAsset.balance).toString()); } - if (this.buySell === 'sell' && this._baseAsset?.balance) { + if (this.direction === 'sell' && this._baseAsset?.balance) { this.setBaseInput((clamped * this._baseAsset.balance).toString()); } } @@ -234,7 +234,7 @@ export class MarketOrderFormStore { return; } const { inputAsset, inputAmount, output } = - this.buySell === 'buy' + this.direction === 'buy' ? { inputAsset: this._quoteAsset, inputAmount: this.quoteInputAmount, diff --git a/src/pages/trade/ui/order-form/store/OrderFormStore.ts b/src/pages/trade/ui/order-form/store/OrderFormStore.ts index 832c67ec..39cfe171 100644 --- a/src/pages/trade/ui/order-form/store/OrderFormStore.ts +++ b/src/pages/trade/ui/order-form/store/OrderFormStore.ts @@ -1,4 +1,4 @@ -import { useRef, useEffect } from 'react'; +import { useEffect } from 'react'; import { makeAutoObservable, reaction, runInAction } from 'mobx'; import { LimitOrderFormStore } from './LimitOrderFormStore'; import { MarketOrderFormStore } from './MarketOrderFormStore'; diff --git a/src/pages/trade/ui/order-form/store/RangeOrderFormStore.ts b/src/pages/trade/ui/order-form/store/RangeOrderFormStore.ts index 3ec0d699..9b6b5dac 100644 --- a/src/pages/trade/ui/order-form/store/RangeOrderFormStore.ts +++ b/src/pages/trade/ui/order-form/store/RangeOrderFormStore.ts @@ -5,6 +5,45 @@ import { Position } from '@penumbra-zone/protobuf/penumbra/core/component/dex/v1 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) { @@ -29,7 +68,10 @@ export class RangeOrderFormStore { liquidityTargetInput = ''; upperPriceInput = ''; lowerPriceInput = ''; + upperPriceInputOption: UpperBoundOptions | undefined; + lowerPriceInputOption: LowerBoundOptions | undefined; feeTierPercentInput = ''; + feeTierPercentInputOption: FeeTierOptions | undefined; private _positionCountInput = '10'; private _positionCountSlider = 10; marketPrice = 1; @@ -58,16 +100,46 @@ export class RangeOrderFormStore { return parseNumber(this.upperPriceInput); } - setUpperPriceInput = (x: string) => { + 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) => { + 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 @@ -75,8 +147,16 @@ export class RangeOrderFormStore { return Math.max(0, Math.min(parseNumber(this.feeTierPercentInput) ?? 0, 50)); } - setFeeTierPercentInput = (x: string) => { + 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 { @@ -151,7 +231,9 @@ export class RangeOrderFormStore { if (resetInputs) { this.liquidityTargetInput = ''; this.upperPriceInput = ''; + this.upperPriceInputOption = undefined; this.lowerPriceInput = ''; + this.lowerPriceInputOption = undefined; this.feeTierPercentInput = ''; this._positionCountInput = '10'; this._positionCountSlider = 10; From 8d38dcc34ea26adf6cf9a8c88a3f4f706d5b85b4 Mon Sep 17 00:00:00 2001 From: "Jason M. Hasperhoven" Date: Mon, 16 Dec 2024 18:38:38 +0400 Subject: [PATCH 43/44] Remove setup connection hoc --- app/app.tsx | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/app/app.tsx b/app/app.tsx index 9de86aa2..6d4058ee 100644 --- a/app/app.tsx +++ b/app/app.tsx @@ -13,20 +13,16 @@ import { connectionStore } from '@/shared/model/connection'; // and no garbage collection problems are introduced. enableStaticRendering(typeof window === 'undefined'); -const SetupConnection = observer(() => { +export const App = ({ children }: { children: ReactNode }) => { useEffect(() => { connectionStore.setup(); }, []); - return null; -}); -export const App = ({ children }: { children: ReactNode }) => { return (
-
{children}
From 72ca46c46986323b8908050661509804d13cc1dd Mon Sep 17 00:00:00 2001 From: "Jason M. Hasperhoven" Date: Mon, 16 Dec 2024 18:43:44 +0400 Subject: [PATCH 44/44] Wrapp app with observer --- app/app.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/app.tsx b/app/app.tsx index 6d4058ee..2a0b6dbe 100644 --- a/app/app.tsx +++ b/app/app.tsx @@ -13,7 +13,7 @@ import { connectionStore } from '@/shared/model/connection'; // 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(); }, []); @@ -30,4 +30,4 @@ export const App = ({ children }: { children: ReactNode }) => {
); -}; +});