Skip to content

Commit

Permalink
Limit Orders (#181)
Browse files Browse the repository at this point in the history
* Add limit order form

* Construct limit order position

* Refactor order form stores & introduce pnum util

* Update pnum description

* Use remote pnum

* Fix lint issues

* Fix handling of input fields numbers

* tmp commit

* Convert to base units

* Start fleshing out shared position logic

* Add tests for position math

* Integrate position logic into range liquidity form

* Define limit order position function

* Add some documentation to position math

* Wire up limit position form

* Remove slider from limit order form

* Remove key error

* Apply same p,q,r1,r2 values as rust code

* Construct positions taking into account asset order

* Have position plans take in display units for reserves

* Fix limit order amount handling + gas fee calc

* Fix display amounts for range liquidity form

* Bug: don't display weird text in order input

* Define PriceLinkedInputs store

This store will be very useful for market and limit orders, and already addresses
some yet unreported sources of UX oddities, like the inputs not updating with the price.

* Define new store for MarketOrderForm

* Move AssetInfo to separate file

* Add store for limit order position

* Add RangeOrderFormStore

* Basic wiring of form refactor

* Implement order submission

* Fix range liquidity fee tier selection

* Correct range liquidity

* Gas fee calculation, remove unused stuff

* Fix remaining lints

* Correct order of p and q in position construction

* Use a more robust method of getting p and q in range

This should handle cases where they're out of range by losing only the necessary precision, and it should handle cases where the price gets flattened to 0

* Remove wheel input changes

* Fix market slider input change

* Refactor setter methods for mobx strict mode, fix rerenders

* Fix hydration issue caused by connectionStore.connected

* Fix inputs disappearing

* Add active states to select groups

* Remove setup connection hoc

* Wrapp app with observer

---------

Co-authored-by: Lucas Meier <lucas@cronokirby.com>
  • Loading branch information
JasonMHasperhoven and cronokirby authored Dec 16, 2024
1 parent 843a6bb commit 699f741
Show file tree
Hide file tree
Showing 30 changed files with 1,921 additions and 1,007 deletions.
13 changes: 9 additions & 4 deletions app/app.tsx
Original file line number Diff line number Diff line change
@@ -1,18 +1,23 @@
'use client';

import { ReactNode } from 'react';
import { enableStaticRendering } from 'mobx-react-lite';
import { ReactNode, useEffect } from 'react';
import { enableStaticRendering, observer } from 'mobx-react-lite';
import { QueryClientProvider } from '@tanstack/react-query';
import { ToastProvider } from '@penumbra-zone/ui/Toast';
import { TooltipProvider } from '@penumbra-zone/ui/Tooltip';
import { Header, SyncBar } from '@/widgets/header';
import { queryClient } from '@/shared/const/queryClient';
import { connectionStore } from '@/shared/model/connection';

// Used so that observer() won't subscribe to any observables used in an SSR environment
// and no garbage collection problems are introduced.
enableStaticRendering(typeof window === 'undefined');

export const App = ({ children }: { children: ReactNode }) => {
export const App = observer(({ children }: { children: ReactNode }) => {
useEffect(() => {
connectionStore.setup();
}, []);

return (
<QueryClientProvider client={queryClient}>
<TooltipProvider>
Expand All @@ -25,4 +30,4 @@ export const App = ({ children }: { children: ReactNode }) => {
</TooltipProvider>
</QueryClientProvider>
);
};
});
57 changes: 57 additions & 0 deletions src/pages/trade/model/AssetInfo.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
import { AssetId, Metadata, Value } from '@penumbra-zone/protobuf/penumbra/core/asset/v1/asset_pb';
import { Amount } from '@penumbra-zone/protobuf/penumbra/core/num/v1/num_pb';
import { pnum } from '@penumbra-zone/types/pnum';

/** A basic utility class containing information we need about an asset.
*
* This extracts out the useful components we might need for the current
* asset.
*/
export class AssetInfo {
/**
* @param balance the balance, in display units.
* @param the exponent to convert from base units to display units.
*/
constructor(
public id: AssetId,
public exponent: number,
public symbol: string,
public balance?: number,
) {}

static fromMetadata(metadata: Metadata, balance?: Amount): undefined | AssetInfo {
const displayDenom = metadata.denomUnits.find(x => x.denom === metadata.display);
if (!displayDenom || !metadata.penumbraAssetId) {
return undefined;
}
return new AssetInfo(
metadata.penumbraAssetId,
displayDenom.exponent,
metadata.symbol,
balance && pnum(balance, displayDenom.exponent).toNumber(),
);
}

/** Convert an amount, in display units, into a Value (of this asset). */
value(display: number): Value {
return new Value({
amount: pnum(display, this.exponent).toAmount(),
assetId: this.id,
});
}

/** Format an amount (in display units) as a simple string. */
formatDisplayAmount(amount: number): string {
const amountString = pnum(amount, this.exponent).toFormattedString({
commas: true,
decimals: 4,
trailingZeros: false,
});
return `${amountString} ${this.symbol}`;
}

/** Format the balance of this asset as a simple string. */
formatBalance(): undefined | string {
return this.balance !== undefined ? this.formatDisplayAmount(this.balance) : undefined;
}
}
9 changes: 9 additions & 0 deletions src/pages/trade/model/useMarketPrice.ts
Original file line number Diff line number Diff line change
@@ -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;
};
39 changes: 18 additions & 21 deletions src/pages/trade/ui/form-tabs.tsx
Original file line number Diff line number Diff line change
@@ -1,44 +1,41 @@
import { useState } from 'react';
import { useAutoAnimate } from '@formkit/auto-animate/react';
import { Tabs } from '@penumbra-zone/ui/Tabs';
import { Density } from '@penumbra-zone/ui/Density';
import { MarketOrderForm } from './order-form/order-form-market';
import { LimitOrderForm } from './order-form/order-form-limit';
import { RangeLiquidityOrderForm } from './order-form/order-form-range-liquidity';
import { isWhichForm, useOrderFormStore } from './order-form/store/OrderFormStore';
import { observer } from 'mobx-react-lite';

enum FormTabsType {
Market = 'market',
Limit = 'limit',
Range = 'range',
}

export const FormTabs = () => {
export const FormTabs = observer(() => {
const [parent] = useAutoAnimate();
const [tab, setTab] = useState<FormTabsType>(FormTabsType.Market);
const store = useOrderFormStore();

return (
<div ref={parent} className='flex flex-col'>
<div className='px-4 lg:pt-2 border-b border-b-other-solidStroke'>
<Density compact>
<Tabs
value={tab}
value={store.whichForm}
actionType='accent'
onChange={value => setTab(value as FormTabsType)}
onChange={value => {
if (isWhichForm(value)) {
store.setWhichForm(value);
}
}}
options={[
{ value: FormTabsType.Market, label: 'Market' },
{ value: FormTabsType.Limit, label: 'Limit' },
{ value: FormTabsType.Range, label: 'Range Liquidity' },
{ value: 'Market', label: 'Market' },
{ value: 'Limit', label: 'Limit' },
{ value: 'Range', label: 'Range Liquidity' },
]}
/>
</Density>
</div>

<div className='overflow-y-auto'>
{tab === FormTabsType.Market && <MarketOrderForm />}
{tab === FormTabsType.Limit && (
<div className='h-[380px] p-4 text-text-secondary'>Limit order form</div>
)}
{tab === FormTabsType.Range && <RangeLiquidityOrderForm />}
{store.whichForm === 'Market' && <MarketOrderForm parentStore={store} />}
{store.whichForm === 'Limit' && <LimitOrderForm parentStore={store} />}
{store.whichForm === 'Range' && <RangeLiquidityOrderForm parentStore={store} />}
</div>
</div>
);
};
});
20 changes: 20 additions & 0 deletions src/pages/trade/ui/order-form/info-row-gas-fee.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import { InfoRow } from './info-row';

export const InfoRowGasFee = ({
gasFee,
symbol,
isLoading,
}: {
gasFee: string;
symbol: string;
isLoading: boolean;
}) => {
return (
<InfoRow
label='Gas Fee'
isLoading={isLoading}
value={`${gasFee} ${symbol}`}
toolTip='The gas cost of the transaction. Gas fees are burned as part of transaction processing.'
/>
);
};
12 changes: 12 additions & 0 deletions src/pages/trade/ui/order-form/info-row-trading-fee.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import { InfoRow } from './info-row';

export const InfoRowTradingFee = () => {
return (
<InfoRow
label='Trading Fee'
value='Free'
valueColor='success'
toolTip='Penumbra has no platform trading fee. LPs set their own fees, which are included in the quoted price.'
/>
);
};
2 changes: 1 addition & 1 deletion src/pages/trade/ui/order-form/info-row.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
89 changes: 89 additions & 0 deletions src/pages/trade/ui/order-form/order-form-limit.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
import { observer } from 'mobx-react-lite';
import { Button } from '@penumbra-zone/ui/Button';
import { Text } from '@penumbra-zone/ui/Text';
import { connectionStore } from '@/shared/model/connection';
import { OrderInput } from './order-input';
import { SegmentedControl } from './segmented-control';
import { ConnectButton } from '@/features/connect/connect-button';
import { InfoRowTradingFee } from './info-row-trading-fee';
import { InfoRowGasFee } from './info-row-gas-fee';
import { SelectGroup } from './select-group';
import { OrderFormStore } from './store/OrderFormStore';
import { BuyLimitOrderOptions, SellLimitOrderOptions } from './store/LimitOrderFormStore';

export const LimitOrderForm = observer(({ parentStore }: { parentStore: OrderFormStore }) => {
const { connected } = connectionStore;
const store = parentStore.limitForm;

const isBuy = store.direction === 'buy';

return (
<div className='p-4'>
<SegmentedControl direction={store.direction} setDirection={store.setDirection} />
<div className='mb-4'>
<div className='mb-2'>
<OrderInput
label={`When ${store.baseAsset?.symbol} is`}
value={store.priceInput}
onChange={price => store.setPriceInput(price)}
denominator={store.quoteAsset?.symbol}
/>
</div>
<SelectGroup
options={Object.values(isBuy ? BuyLimitOrderOptions : SellLimitOrderOptions)}
value={store.priceInputOption}
onChange={option =>
store.setPriceInputOption(option as BuyLimitOrderOptions | SellLimitOrderOptions)
}
/>
</div>
<div className='mb-4'>
<OrderInput
label={isBuy ? 'Buy' : 'Sell'}
value={store.baseInput}
onChange={store.setBaseInput}
denominator={store.baseAsset?.symbol}
/>
</div>
<div className='mb-4'>
<OrderInput
label={isBuy ? 'Pay with' : 'Receive'}
value={store.quoteInput}
onChange={store.setQuoteInput}
denominator={store.quoteAsset?.symbol}
/>
</div>
<div className='mb-4'>
<InfoRowTradingFee />
<InfoRowGasFee
gasFee={parentStore.gasFee.display}
symbol={parentStore.gasFee.symbol}
isLoading={parentStore.gasFeeLoading}
/>
</div>
<div className='mb-4'>
{connected ? (
<Button
actionType='accent'
disabled={!parentStore.canSubmit}
onClick={() => void parentStore.submit()}
>
{isBuy ? 'Buy' : 'Sell'} {store.baseAsset?.symbol}
</Button>
) : (
<ConnectButton actionType='default' />
)}
</div>
{parentStore.marketPrice && (
<div className='flex justify-center p-1'>
<Text small color='text.secondary'>
1 {store.baseAsset?.symbol} ={' '}
<Text small color='text.primary'>
{store.quoteAsset?.formatDisplayAmount(parentStore.marketPrice)}
</Text>
</Text>
</div>
)}
</div>
);
});
Loading

0 comments on commit 699f741

Please sign in to comment.