): void => {
- this.onFieldChangeCallback = callback;
- };
-}
diff --git a/src/pages/trade/ui/page.tsx b/src/pages/trade/ui/page.tsx
index 470fed0b..f9e809ad 100644
--- a/src/pages/trade/ui/page.tsx
+++ b/src/pages/trade/ui/page.tsx
@@ -7,6 +7,7 @@ import { RouteTabs } from './route-tabs';
import { TradesTabs } from './trades-tabs';
import { HistoryTabs } from './history-tabs';
import { FormTabs } from './form-tabs';
+import { useEffect, useState } from 'react';
const sharedStyle = 'w-full border-t border-t-other-solidStroke overflow-x-hidden';
@@ -143,13 +144,36 @@ const MobileLayout = () => {
};
export const TradePage = () => {
- return (
- <>
-
-
-
-
-
- >
- );
+ const [width, setWidth] = useState(1366);
+
+ useEffect(() => {
+ const resize = () => {
+ setWidth(document.body.clientWidth);
+ };
+
+ window.addEventListener('resize', resize);
+ resize();
+
+ return () => {
+ window.removeEventListener('resize', resize);
+ };
+ }, []);
+
+ if (width > 1600) {
+ return ;
+ }
+
+ if (width > 1200) {
+ return ;
+ }
+
+ if (width > 900) {
+ return ;
+ }
+
+ if (width > 600) {
+ return ;
+ }
+
+ return ;
};
diff --git a/src/pages/trade/ui/pair-selector.tsx b/src/pages/trade/ui/pair-selector.tsx
index 1c24a0c9..d7bdfee8 100644
--- a/src/pages/trade/ui/pair-selector.tsx
+++ b/src/pages/trade/ui/pair-selector.tsx
@@ -65,7 +65,15 @@ export const PairSelector = observer(({ disabled, dialogTitle }: PairSelectorPro
const { data: balances } = useBalances();
const { baseAsset, quoteAsset, error, isLoading } = usePathToMetadata();
- if (error) {
+ if (
+ error instanceof Error &&
+ ![
+ 'ConnectError',
+ 'PenumbraNotInstalledError',
+ 'PenumbraProviderNotAvailableError',
+ 'PenumbraProviderNotConnectedError',
+ ].includes(error.name)
+ ) {
return Error loading pair selector: ${String(error)}
;
}
diff --git a/src/pages/trade/ui/trade-row.tsx b/src/pages/trade/ui/trade-row.tsx
index 432fac7c..62a3c742 100644
--- a/src/pages/trade/ui/trade-row.tsx
+++ b/src/pages/trade/ui/trade-row.tsx
@@ -86,7 +86,7 @@ const RouteDisplay = ({ tokens }: { tokens: string[] }) => {
return (
{tokens.map((token, index) => (
-
+
{index > 0 && }
{token}
diff --git a/src/shared/api/balances.ts b/src/shared/api/balances.ts
index c3477055..d5dfcb92 100644
--- a/src/shared/api/balances.ts
+++ b/src/shared/api/balances.ts
@@ -3,19 +3,20 @@ import { BalancesResponse } from '@penumbra-zone/protobuf/penumbra/view/v1/view_
import { penumbra } from '@/shared/const/penumbra';
import { connectionStore } from '@/shared/model/connection';
import { useQuery } from '@tanstack/react-query';
+import { AddressIndex } from '@penumbra-zone/protobuf/penumbra/core/keys/v1/keys_pb';
-const fetchQuery = async (): Promise => {
- return Array.fromAsync(penumbra.service(ViewService).balances({}));
+const fetchQuery = (accountFilter?: AddressIndex) => async (): Promise => {
+ return Array.fromAsync(penumbra.service(ViewService).balances({ accountFilter }));
};
/**
* Fetches the `BalancesResponse[]` based on the provider connection state.
* Must be used within the `observer` mobX HOC
*/
-export const useBalances = () => {
+export const useBalances = (accountFilter?: AddressIndex) => {
return useQuery({
queryKey: ['view-service-balances'],
- queryFn: fetchQuery,
+ queryFn: fetchQuery(accountFilter),
enabled: connectionStore.connected,
});
};
diff --git a/src/shared/math/position.test.ts b/src/shared/math/position.test.ts
new file mode 100644
index 00000000..5cf68afc
--- /dev/null
+++ b/src/shared/math/position.test.ts
@@ -0,0 +1,77 @@
+import { AssetId } from '@penumbra-zone/protobuf/penumbra/core/asset/v1/asset_pb';
+import { describe, expect, it } from 'vitest';
+import { planToPosition } from './position';
+import { pnum } from '@penumbra-zone/types/pnum';
+import { Position } from '@penumbra-zone/protobuf/penumbra/core/component/dex/v1/dex_pb';
+
+const ASSET_A = new AssetId({ inner: new Uint8Array(Array(32).fill(0xaa)) });
+const ASSET_B = new AssetId({ inner: new Uint8Array(Array(32).fill(0xbb)) });
+
+const getPrice = (position: Position): number => {
+ return pnum(position.phi?.component?.p).toNumber() / pnum(position.phi?.component?.q).toNumber();
+};
+
+describe('planToPosition', () => {
+ it('works for plans with no exponent', () => {
+ const position = planToPosition({
+ baseAsset: {
+ id: ASSET_A,
+ exponent: 0,
+ },
+ quoteAsset: {
+ id: ASSET_B,
+ exponent: 0,
+ },
+ price: 20.5,
+ feeBps: 100,
+ baseReserves: 1000,
+ quoteReserves: 2000,
+ });
+ expect(position.phi?.component?.fee).toEqual(100);
+ expect(getPrice(position)).toEqual(20.5);
+ expect(pnum(position.reserves?.r1).toNumber()).toEqual(1000);
+ expect(pnum(position.reserves?.r2).toNumber()).toEqual(2000);
+ });
+
+ it('works for plans with identical exponent', () => {
+ const position = planToPosition({
+ baseAsset: {
+ id: ASSET_A,
+ exponent: 6,
+ },
+ quoteAsset: {
+ id: ASSET_B,
+ exponent: 6,
+ },
+ price: 12.34,
+ feeBps: 100,
+ baseReserves: 5,
+ quoteReserves: 7,
+ });
+ expect(position.phi?.component?.fee).toEqual(100);
+ expect(getPrice(position)).toEqual(12.34);
+ expect(pnum(position.reserves?.r1).toNumber()).toEqual(5e6);
+ expect(pnum(position.reserves?.r2).toNumber()).toEqual(7e6);
+ });
+
+ it('works for plans with different exponents', () => {
+ const position = planToPosition({
+ baseAsset: {
+ id: ASSET_A,
+ exponent: 6,
+ },
+ quoteAsset: {
+ id: ASSET_B,
+ exponent: 8,
+ },
+ price: 12.34,
+ feeBps: 100,
+ baseReserves: 5,
+ quoteReserves: 7,
+ });
+ expect(position.phi?.component?.fee).toEqual(100);
+ expect(getPrice(position) * 10 ** (6 - 8)).toEqual(12.34);
+ expect(pnum(position.reserves?.r1).toNumber()).toEqual(5e6);
+ expect(pnum(position.reserves?.r2).toNumber()).toEqual(7e8);
+ });
+});
diff --git a/src/shared/math/position.ts b/src/shared/math/position.ts
new file mode 100644
index 00000000..4bf313d3
--- /dev/null
+++ b/src/shared/math/position.ts
@@ -0,0 +1,223 @@
+import { AssetId } from '@penumbra-zone/protobuf/penumbra/core/asset/v1/asset_pb';
+import {
+ Position,
+ PositionState,
+ PositionState_PositionStateEnum,
+ TradingPair,
+} from '@penumbra-zone/protobuf/penumbra/core/component/dex/v1/dex_pb';
+import { Amount } from '@penumbra-zone/protobuf/penumbra/core/num/v1/num_pb';
+import { pnum } from '@penumbra-zone/types/pnum';
+import BigNumber from 'bignumber.js';
+
+export const compareAssetId = (a: AssetId, b: AssetId): number => {
+ for (let i = 31; i >= 0; --i) {
+ const a_i = a.inner[i] ?? -Infinity;
+ const b_i = b.inner[i] ?? -Infinity;
+ if (a_i < b_i) {
+ return -1;
+ }
+ if (b_i < a_i) {
+ return 1;
+ }
+ }
+ return 0;
+};
+
+/**
+ * A slimmed-down representation for assets, restricted to what we need for math.
+ *
+ * We have an identifier for the kind of asset, which is needed to construct a position,
+ * and an exponent E, such that 10**E units of the base denom constitute a unit of the display denom.
+ *
+ * For example, 10**6 uUSD make up one USD.
+ */
+export interface Asset {
+ id: AssetId;
+ exponent: number;
+}
+
+/**
+ * A basic plan to create a position.
+ *
+ * This can then be passed to `planToPosition` to fill out the position.
+ */
+export interface PositionPlan {
+ baseAsset: Asset;
+ quoteAsset: Asset;
+ /** How much of the quote asset do you get for each unit of the base asset?
+ *
+ * This will be in terms of the *display* denoms, e.g. USD / UM.
+ */
+ price: number;
+ /** The fee, in [0, 10_000]*/
+ feeBps: number;
+ /** How much of the base asset we want to provide, in display units. */
+ baseReserves: number;
+ /** How much of the quote asset we want to provide, in display units. */
+ quoteReserves: number;
+}
+
+const priceToPQ = (
+ price: number,
+ pExponent: number,
+ qExponent: number,
+): { p: Amount; q: Amount } => {
+ // e.g. price = X USD / UM
+ // basePrice = Y uUM / uUSD = X USD / UM * uUSD / USD * UM / uUM
+ // = X * 10 ** qExponent * 10 ** -pExponent
+ const basePrice = new BigNumber(price).times(new BigNumber(10).pow(qExponent - pExponent));
+
+ // USD / UM -> [USD, UM], with a given precision.
+ // Then, we want the invariant that p * UM + q * USD = constant, so
+ let [p, q] = basePrice.toFraction();
+ // These can be higher, but this gives us some leg room.
+ const max_p_or_q = new BigNumber(10).pow(20);
+ while (p.isGreaterThanOrEqualTo(max_p_or_q) || q.isGreaterThanOrEqualTo(max_p_or_q)) {
+ p = p.shiftedBy(-1);
+ q = q.shiftedBy(-1);
+ }
+ p = p.plus(Number(p.isEqualTo(0)));
+ q = q.plus(Number(p.isEqualTo(0)));
+ return { p: pnum(BigInt(p.toFixed(0))).toAmount(), q: pnum(BigInt(q.toFixed(0))).toAmount() };
+};
+
+/**
+ * Convert a plan into a position.
+ *
+ * Try using `rangeLiquidityPositions` or `limitOrderPosition` instead, with this method existing
+ * as an escape hatch in case any of those use cases aren't sufficient.
+ */
+export const planToPosition = (plan: PositionPlan): Position => {
+ const { p: rawP, q: rawQ } = priceToPQ(
+ plan.price,
+ plan.baseAsset.exponent,
+ plan.quoteAsset.exponent,
+ );
+ const rawA1 = plan.baseAsset;
+ const rawA2 = plan.quoteAsset;
+ const rawR1 = pnum(plan.baseReserves, plan.baseAsset.exponent).toAmount();
+ const rawR2 = pnum(plan.quoteReserves, plan.quoteAsset.exponent).toAmount();
+
+ const correctOrder = compareAssetId(plan.baseAsset.id, plan.quoteAsset.id) <= 0;
+ const [[p, q], [r1, r2], [a1, a2]] = correctOrder
+ ? [
+ [rawP, rawQ],
+ [rawR1, rawR2],
+ [rawA1, rawA2],
+ ]
+ : [
+ [rawQ, rawP],
+ [rawR2, rawR1],
+ [rawA2, rawA1],
+ ];
+
+ return new Position({
+ phi: {
+ component: {
+ fee: plan.feeBps,
+ p: pnum(p).toAmount(),
+ q: pnum(q).toAmount(),
+ },
+ pair: new TradingPair({
+ asset1: a1.id,
+ asset2: a2.id,
+ }),
+ },
+ nonce: crypto.getRandomValues(new Uint8Array(32)),
+ state: new PositionState({ state: PositionState_PositionStateEnum.OPENED }),
+ reserves: { r1, r2 },
+ closeOnFill: false,
+ });
+};
+
+/**
+ * A range liquidity plan provides for creating multiple positions across a range of prices.
+ *
+ * This plan attempts to distribute reserves across equally spaced price points.
+ *
+ * It needs to know the market price, to know when to switch from positions that sell the quote
+ * asset, to positions that buy the quote asset.
+ *
+ * All prices are in terms of quoteAsset / baseAsset, in display units.
+ */
+interface RangeLiquidityPlan {
+ baseAsset: Asset;
+ quoteAsset: Asset;
+ targetLiquidity: number;
+ upperPrice: number;
+ lowerPrice: number;
+ marketPrice: number;
+ feeBps: number;
+ positions: number;
+}
+
+/** Given a plan for providing range liquidity, create all the necessary positions to accomplish the plan. */
+export const rangeLiquidityPositions = (plan: RangeLiquidityPlan): Position[] => {
+ // The step width is positions-1 because it's between the endpoints
+ // |---|---|---|---|
+ // 0 1 2 3 4
+ // 0 1 2 3
+ const stepWidth = (plan.upperPrice - plan.lowerPrice) / plan.positions;
+ return Array.from({ length: plan.positions }, (_, i) => {
+ const price = plan.lowerPrice + i * stepWidth;
+
+ let baseReserves: number;
+ let quoteReserves: number;
+ if (price < plan.marketPrice) {
+ // If the price is < market price, then people *paying* that price are getting a good deal,
+ // and receiving the base asset in exchange, so we don't want to offer them any of that.
+ baseReserves = 0;
+ quoteReserves = plan.targetLiquidity / plan.positions;
+ } else {
+ // Conversely, when price > market price, then the people that are selling the base asset,
+ // receiving the quote asset in exchange are getting a good deal, so we don't want to offer that.
+ baseReserves = plan.targetLiquidity / plan.positions / price;
+ quoteReserves = 0;
+ }
+
+ return planToPosition({
+ baseAsset: plan.baseAsset,
+ quoteAsset: plan.quoteAsset,
+ feeBps: plan.feeBps,
+ price,
+ baseReserves,
+ quoteReserves,
+ });
+ });
+};
+
+/** A limit order plan attempts to buy or sell the baseAsset at a given price.
+ *
+ * This price is always in terms of quoteAsset / baseAsset.
+ *
+ * The input is the quote asset when buying, and the base asset when selling, and in display units.
+ */
+interface LimitOrderPlan {
+ buy: 'buy' | 'sell';
+ price: number;
+ input: number;
+ baseAsset: Asset;
+ quoteAsset: Asset;
+}
+
+export const limitOrderPosition = (plan: LimitOrderPlan): Position => {
+ let baseReserves: number;
+ let quoteReserves: number;
+ if (plan.buy === 'buy') {
+ baseReserves = 0;
+ quoteReserves = plan.input;
+ } else {
+ baseReserves = plan.input;
+ quoteReserves = 0;
+ }
+ const pos = planToPosition({
+ baseAsset: plan.baseAsset,
+ quoteAsset: plan.quoteAsset,
+ feeBps: 0,
+ price: plan.price,
+ baseReserves,
+ quoteReserves,
+ });
+ pos.closeOnFill = true;
+ return pos;
+};
diff --git a/src/shared/model/connection/index.ts b/src/shared/model/connection/index.ts
index 35103525..b6c0eb2a 100644
--- a/src/shared/model/connection/index.ts
+++ b/src/shared/model/connection/index.ts
@@ -17,10 +17,6 @@ class ConnectionStateStore {
constructor() {
makeAutoObservable(this);
-
- if (typeof window !== 'undefined') {
- this.setup();
- }
}
private setManifest(manifest: PenumbraManifest | undefined) {
diff --git a/src/shared/utils/num.ts b/src/shared/utils/num.ts
new file mode 100644
index 00000000..cef4662a
--- /dev/null
+++ b/src/shared/utils/num.ts
@@ -0,0 +1,5 @@
+/** Attempt to parse a string into a number, returning `undefined` on failure. */
+export const parseNumber = (x: string): number | undefined => {
+ const out = Number(x);
+ return isNaN(out) || x.length <= 0 ? undefined : out;
+};